diff --git a/crates/icp-cli/tests/recipe_tests.rs b/crates/icp-cli/tests/recipe_tests.rs index 6c85e0a3..217dfaea 100644 --- a/crates/icp-cli/tests/recipe_tests.rs +++ b/crates/icp-cli/tests/recipe_tests.rs @@ -332,35 +332,3 @@ fn recipe_local_file_valid_checksum() { .assert() .success(); } - -#[test] -fn recipe_builtin_ignores_checksum() { - let ctx = TestContext::new(); - - for recipe_type in ["assets", "motoko", "rust"] { - // Setup project - let project_dir = ctx.create_project_dir("icp"); - - let pm = formatdoc! {" - canister: - name: my-canister - recipe: - type: {recipe_type} - sha256: invalid_checksum_should_be_ignored - "}; - - write_string( - &project_dir.join("icp.yaml"), // path - &pm, // contents - ) - .expect("failed to write project manifest"); - - // Invoke build - should succeed because local files don't verify checksums - ctx.icp() - .current_dir(project_dir) - .args(["build"]) - .assert() - .append_context("test-case", recipe_type) - .success(); - } -} diff --git a/crates/icp/src/canister/build.rs b/crates/icp/src/canister/build.rs index c1e771c5..710ce518 100644 --- a/crates/icp/src/canister/build.rs +++ b/crates/icp/src/canister/build.rs @@ -7,7 +7,10 @@ use tokio::sync::mpsc::{Sender, error::SendError}; use crate::{ canister::{build, script::ScriptError}, - manifest::adapter::{prebuilt, script}, + manifest::{ + adapter::{prebuilt, script}, + serde_helpers::non_empty_vec, + }, prelude::*, }; @@ -51,6 +54,7 @@ impl fmt::Display for Step { /// including the adapters and build steps responsible for the build. #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] pub struct Steps { + #[serde(deserialize_with = "non_empty_vec")] pub steps: Vec, } diff --git a/crates/icp/src/canister/recipe/handlebars.rs b/crates/icp/src/canister/recipe/handlebars.rs index 9e011ba2..1b9e1aac 100644 --- a/crates/icp/src/canister/recipe/handlebars.rs +++ b/crates/icp/src/canister/recipe/handlebars.rs @@ -1,4 +1,5 @@ use indoc::formatdoc; +use serde::Deserialize; use std::{str::FromStr, string::FromUtf8Error}; use crate::{ @@ -74,51 +75,17 @@ pub enum HandlebarsError { #[async_trait] impl Resolve for Handlebars { async fn resolve(&self, recipe: &Recipe) -> Result<(build::Steps, sync::Steps), ResolveError> { - // Sanity check recipe type - let recipe_type = match &recipe.recipe_type { - RecipeType::Unknown(typ) => typ.to_owned(), + // Find the template + let tmpl = match &recipe.recipe_type { + RecipeType::File(path) => TemplateSource::LocalPath(Path::new(&path).into()), + RecipeType::Url(url) => TemplateSource::RemoteUrl(url.to_owned()), + RecipeType::Registry { + name, + recipe, + version, + } => TemplateSource::Registry(name.to_owned(), recipe.to_owned(), version.to_owned()), }; - // Infer source for recipe template (local, remote, built-in, etc) - let tmpl = (|recipe_type: String| { - if recipe_type.starts_with("file://") { - let path = recipe_type - .strip_prefix("file://") - .map(Path::new) - .expect("prefix missing") - .into(); - - return TemplateSource::LocalPath(path); - } - - if recipe_type.starts_with("http://") || recipe_type.starts_with("https://") { - return TemplateSource::RemoteUrl(recipe_type); - } - - if recipe_type.starts_with("@") { - let recipe_type = recipe_type.strip_prefix("@").expect("prefix missing"); - - // Check for version delimiter - let (v, version) = if recipe_type.contains("@") { - // Version is specified - recipe_type.rsplit_once("@").expect("delimiter missing") - } else { - // Assume latest - (recipe_type, "latest") - }; - - let (registry, recipe) = v.split_once("/").expect("delimiter missing"); - - return TemplateSource::Registry( - registry.to_owned(), - recipe.to_owned(), - version.to_owned(), - ); - } - - panic!("Invalid recipe type: {recipe_type}"); - })(recipe_type.clone()); - // TMP(or.ricon): Temporarily hardcode a dfinity registry let tmpl = match tmpl { TemplateSource::Registry(registry, recipe, version) => { @@ -208,24 +175,36 @@ impl Resolve for Handlebars { .map_err(|err| ResolveError::Handlebars { source: HandlebarsError::Render { source: err, - recipe: recipe_type.to_owned(), + recipe: recipe.recipe_type.clone().into(), template: tmpl.to_owned(), }, })?; // Read the rendered YAML canister manifest - let insts = serde_yaml::from_str::(&out); + // Recipes can only render buid/sync + #[derive(Deserialize)] + struct BuildSyncHelper { + build: build::Steps, + #[serde(default)] + sync: sync::Steps, + } + + let insts = serde_yaml::from_str::(&out); let insts = match insts { - Ok(insts) => insts, + Ok(helper) => Instructions::BuildSync { + build: helper.build, + sync: helper.sync, + }, Err(e) => panic!( "{}", formatdoc! {r#" - Unable to render template into valid yaml: {e} + Unable to render recipe {} template into valid yaml: {e} + Rendered content: ------ {out} ------ - "#} + "#, recipe.recipe_type} ), }; diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index f9bfbf87..d7a8c715 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -70,7 +70,7 @@ pub enum LoadError { #[error("failed to load path")] Path, - #[error("failed to load manifest")] + #[error("failed to load the project manifest")] Manifest, #[error(transparent)] diff --git a/crates/icp/src/manifest/canister.rs b/crates/icp/src/manifest/canister.rs index a46bf163..f647d859 100644 --- a/crates/icp/src/manifest/canister.rs +++ b/crates/icp/src/manifest/canister.rs @@ -6,8 +6,7 @@ use crate::{ manifest::recipe::Recipe, }; -#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema)] -#[serde(untagged)] +#[derive(Clone, Debug, PartialEq, JsonSchema, Deserialize)] pub enum Instructions { Recipe { recipe: Recipe, @@ -24,23 +23,6 @@ pub enum Instructions { }, } -/// Represents the manifest describing a single canister. -/// This struct is typically loaded from a `canister.yaml` file and defines -/// the canister's name and how it should be built into WebAssembly. -#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema)] -pub struct CanisterInner { - /// The unique name of the canister as defined in this manifest. - pub name: String, - - /// The configuration specifying the various settings when - /// creating the canister. - #[serde(default)] - pub settings: Settings, - - #[serde(flatten)] - pub instructions: Option, -} - /// Represents the manifest describing a single canister. /// This struct is typically loaded from a `canister.yaml` file and defines /// the canister's name and how it should be built into WebAssembly. @@ -51,52 +33,153 @@ pub struct CanisterManifest { /// The configuration specifying the various settings when /// creating the canister. + #[serde(default)] pub settings: Settings, + #[serde(flatten)] pub instructions: Instructions, } -#[derive(Debug, thiserror::Error)] -pub enum ParseError { - #[error( - "Please provide instructions for building your canister in the form of a recipe or build/sync steps." - )] - MissingInstructions, - - #[error(transparent)] - Unexpected(#[from] anyhow::Error), -} +impl<'de> Deserialize<'de> for CanisterManifest { + fn deserialize>(d: D) -> Result { + use serde::de::{Error, MapAccess, Visitor}; + use std::fmt; -impl TryFrom for CanisterManifest { - type Error = ParseError; + struct CanisterManifestVisitor; - fn try_from(v: CanisterInner) -> Result { - let CanisterInner { - name, - settings, - instructions, - } = v; + impl<'de> Visitor<'de> for CanisterManifestVisitor { + type Value = CanisterManifest; - // Instructions - let instructions = instructions.ok_or(ParseError::MissingInstructions)?; + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a canister manifest with a name, optional settings and either a recipe or build instructions") + } - Ok(CanisterManifest { - name, - settings, - instructions, - }) - } -} + // We're going to build the canister manifest manually + // to be able to give good error messages + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut temp_map = serde_yaml::Mapping::new(); + while let Some((key, value)) = + map.next_entry::()? + { + temp_map.insert(key, value); + } + + // All the keys to check + let name_key = serde_yaml::Value::String("name".to_string()); + let settings_key = serde_yaml::Value::String("settings".to_string()); + let recipe_key = serde_yaml::Value::String("recipe".to_string()); + let build_key = serde_yaml::Value::String("build".to_string()); + let sync_key = serde_yaml::Value::String("sync".to_string()); + + // Extract name (required) + let name: String = temp_map + .remove(&name_key) + .ok_or_else(|| Error::custom("missing 'name' field"))? + .as_str() + .ok_or_else(|| Error::custom("'name' must be a string"))? + .to_string(); + + // Extract settings (optional, with default) + let settings: Settings = + if let Some(settings_value) = temp_map.remove(&settings_key) { + serde_yaml::from_value(settings_value).map_err(|e| { + Error::custom(format!( + "Failed to parse settings for canister `{name}`: {}", + e + )) + })? + } else { + Settings::default() + }; + + // + // Build out the instructions + // + let has_recipe = temp_map.contains_key(&recipe_key); + let has_build = temp_map.contains_key(&build_key); + let has_sync = temp_map.contains_key(&sync_key); + + match (has_recipe, has_build, has_sync) { + (true, true, _) => { + // Can't have a recipe and a build + Err(Error::custom(format!( + "Canister {name} cannot have both a `recipe` and a `build` section" + ))) + } + (true, false, true) => { + // Can't have a recipe and a sync sections + Err(Error::custom(format!( + "Canister {name} cannot have both a `recipe` and a `sync` section" + ))) + } + (false, false, _) => { + // We must have recipe or build + Err(Error::custom(format!( + "Canister {name} must have a `recipe` or a `build` section" + ))) + } + (true, false, false) => { + // We have a a recipe + let recipe: Recipe = serde_yaml::from_value( + temp_map + .get(&recipe_key) + .ok_or_else(|| Error::custom("recipe field not found"))? + .clone(), + ) + .map_err(|e| { + Error::custom(format!("Canister {name} failed to parse recipe: {}", e)) + })?; + + Ok(CanisterManifest { + name, + settings, + instructions: Instructions::Recipe { recipe }, + }) + } + (false, true, _) => { + // We have a build section + + // Try to deserialize as BuildSync variant + #[derive(Deserialize)] + struct BuildSyncHelper { + build: build::Steps, + #[serde(default)] + sync: sync::Steps, + } + + let helper: BuildSyncHelper = serde_yaml::from_value( + serde_yaml::Value::Mapping(temp_map), + ) + .map_err(|e| { + Error::custom(format!( + "Canister {name} failed to parse build/sync instructions: {}", + e + )) + })?; + + Ok(CanisterManifest { + name, + settings, + instructions: Instructions::BuildSync { + build: helper.build, + sync: helper.sync, + }, + }) + } + } + } + } -impl<'de> Deserialize<'de> for CanisterManifest { - fn deserialize>(d: D) -> Result { - let inner: CanisterInner = Deserialize::deserialize(d)?; - inner.try_into().map_err(serde::de::Error::custom) + d.deserialize_map(CanisterManifestVisitor) } } #[cfg(test)] mod tests { + use indoc::indoc; use std::collections::HashMap; use anyhow::{Error, anyhow}; @@ -105,12 +188,17 @@ mod tests { adapter::{ assets, prebuilt::{self, RemoteSource, SourceField}, + script, }, recipe::RecipeType, }; use super::*; + const CANNOT_HAVE_BOTH: &str = + "Canister my-canister cannot have both a `recipe` and a `build` section"; + const ARRAY_NOT_EMPTY: &str = "Array must not be empty"; + #[test] fn empty() -> Result<(), Error> { match serde_yaml::from_str::(r#"name: my-canister"#) { @@ -123,7 +211,9 @@ mod tests { // Wrong Error Err(err) => { - if !format!("{err}").starts_with("Please provide instructions") { + if !format!("{err}") + .starts_with("Canister my-canister must have a `recipe` or a `build` section") + { return Err(anyhow!( "an empty canister manifest resulted in the wrong error: {err}" )); @@ -134,22 +224,138 @@ mod tests { Ok(()) } + #[test] + fn invalid_recipe_bad_type() -> Result<(), Error> { + // This should now fail because "unknown_type" is not a valid recipe type + match serde_yaml::from_str::(indoc! {r#" + name: my-canister + recipe: + type: unknown_type + configuration: + field: value + + "#}) + { + Ok(_) => { + return Err(anyhow!("An invalid recipe type should result in an error")); + } + Err(err) => { + let err_msg = format!("{err}"); + if !err_msg.contains("Invalid recipe type") { + return Err(anyhow!( + "expected 'Invalid recipe type' error but got: {err}" + )); + } + } + } + + Ok(()) + } + + #[test] + fn invalid_manifest_mix_recipe_and_build() -> Result<(), Error> { + match serde_yaml::from_str::(indoc! {r#" + name: my-canister + recipe: + type: file://my-recipe + build: + steps: + - type: pre-built + url: http://example.com/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + "#}) + { + Ok(_) => { + return Err(anyhow!( + "You should not be able to have a recipe and build steps at the same time" + )); + } + Err(err) => { + let err_msg = format!("{err}"); + if !err_msg.contains(CANNOT_HAVE_BOTH) { + return Err(anyhow!( + "expected '{CANNOT_HAVE_BOTH}' error but got: {err}" + )); + } + } + }; + + Ok(()) + } + + #[test] + fn invalid_manifest_mix_bad_recipe_and_build() -> Result<(), Error> { + match serde_yaml::from_str::(indoc! {r#" + name: my-canister + recipe: + type: INVALID + build: + steps: + - type: pre-built + url: http://example.com/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + "#}) + { + Ok(_) => { + return Err(anyhow!( + "You should not be able to have a recipe and build steps at the same time" + )); + } + Err(err) => { + let err_msg = format!("{err}"); + if !err_msg.contains(CANNOT_HAVE_BOTH) { + return Err(anyhow!( + "expected '{CANNOT_HAVE_BOTH}' error but got: {err}" + )); + } + } + }; + + Ok(()) + } + + #[test] + fn invalid_manifest_mix_recipe_and_bad_build() -> Result<(), Error> { + match serde_yaml::from_str::(indoc! {r#" + name: my-canister + recipe: + type: file://template + build: + invalid: INVALID + "#}) + { + Ok(_) => { + return Err(anyhow!( + "You should not be able to have a recipe and build steps at the same time" + )); + } + Err(err) => { + let err_msg = format!("{err}"); + if !err_msg.contains(CANNOT_HAVE_BOTH) { + return Err(anyhow!( + "expected '{CANNOT_HAVE_BOTH}' error but got: {err}" + )); + } + } + }; + + Ok(()) + } + #[test] fn recipe() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" name: my-canister recipe: - type: my-recipe - "# - )?, + type: file://my-recipe + "#})?, CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: Instructions::Recipe { recipe: Recipe { - recipe_type: RecipeType::Unknown("my-recipe".to_string()), + recipe_type: RecipeType::File("my-recipe".to_string()), configuration: HashMap::new(), sha256: None, } @@ -163,22 +369,20 @@ mod tests { #[test] fn recipe_with_configuration() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" name: my-canister recipe: - type: my-recipe + type: http://my-recipe configuration: key-1: value-1 key-2: value-2 - "# - )?, + "#})?, CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: Instructions::Recipe { recipe: Recipe { - recipe_type: RecipeType::Unknown("my-recipe".to_string()), + recipe_type: RecipeType::Url("http://my-recipe".to_string()), configuration: HashMap::from([ ("key-1".to_string(), "value-1".into()), ("key-2".to_string(), "value-2".into()) @@ -195,20 +399,22 @@ mod tests { #[test] fn recipe_with_sha256() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" name: my-canister recipe: - type: my-recipe + type: "@dfinity/dummy" sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 - "# - )?, + "#})?, CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: Instructions::Recipe { recipe: Recipe { - recipe_type: RecipeType::Unknown("my-recipe".to_string()), + recipe_type: RecipeType::Registry { + name: "dfinity".to_string(), + recipe: "dummy".to_string(), + version: "latest".to_string(), + }, configuration: HashMap::new(), sha256: Some( "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" @@ -222,19 +428,48 @@ mod tests { Ok(()) } + #[test] + fn recipe_with_settings() -> Result<(), Error> { + assert_eq!( + serde_yaml::from_str::(indoc! {r#" + name: my-canister + settings: + compute_allocation: 3 + memory_allocation: 4294967296 + recipe: + type: file://my-recipe + "#})?, + CanisterManifest { + name: "my-canister".to_string(), + settings: Settings { + compute_allocation: Some(3), + memory_allocation: Some(4294967296), + ..Default::default() + }, + instructions: Instructions::Recipe { + recipe: Recipe { + recipe_type: RecipeType::File("my-recipe".to_string()), + configuration: HashMap::new(), + sha256: None, + } + }, + }, + ); + + Ok(()) + } + #[test] fn build_steps() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" name: my-canister build: steps: - type: pre-built url: http://example.com/hello_world.wasm sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a - "# - )?, + "#})?, CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), @@ -258,25 +493,57 @@ mod tests { Ok(()) } + #[test] + fn empty_steps_is_not_allowed() -> Result<(), Error> { + match serde_yaml::from_str::(indoc! {r#" + name: my-canister + build: + steps: [] + sync: + steps: + - type: assets + dir: dist + "#}) + { + Ok(_) => { + return Err(anyhow!( + "You should not be able to have a recipe and build steps at the same time" + )); + } + Err(err) => { + let err_msg = format!("{err}"); + if !err_msg.contains(ARRAY_NOT_EMPTY) { + return Err(anyhow!("expected '{ARRAY_NOT_EMPTY}' error but got: {err}")); + } + } + }; + + Ok(()) + } + #[test] fn sync_steps() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" name: my-canister build: - steps: [] + steps: + - type: script + command: dosomething.sh sync: steps: - type: assets dir: dist - "# - )?, + "#})?, CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: Instructions::BuildSync { - build: build::Steps { steps: vec![] }, + build: build::Steps { + steps: vec![build::Step::Script(script::Adapter { + command: script::CommandField::Command("dosomething.sh".to_string()) + })] + }, sync: sync::Steps { steps: vec![sync::Step::Assets(assets::Adapter { dir: assets::DirField::Dir("dist".to_string()), diff --git a/crates/icp/src/manifest/mod.rs b/crates/icp/src/manifest/mod.rs index 1cce9f7a..abb123c3 100644 --- a/crates/icp/src/manifest/mod.rs +++ b/crates/icp/src/manifest/mod.rs @@ -10,12 +10,13 @@ use crate::manifest::{ project::{Canisters, Environments, Networks}, }; -pub mod adapter; -pub mod canister; -pub mod environment; -pub mod network; +pub(crate) mod adapter; +pub(crate) mod canister; +pub(crate) mod environment; +pub(crate) mod network; pub mod project; -pub mod recipe; +pub(crate) mod recipe; +pub(crate) mod serde_helpers; pub use {canister::CanisterManifest, environment::EnvironmentManifest, network::NetworkManifest}; diff --git a/crates/icp/src/manifest/project.rs b/crates/icp/src/manifest/project.rs index 465915f8..32e47a82 100644 --- a/crates/icp/src/manifest/project.rs +++ b/crates/icp/src/manifest/project.rs @@ -78,74 +78,264 @@ impl From for Vec { } } -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ProjectInner { - #[serde(flatten)] - pub canisters: Option, - - #[serde(flatten)] - pub networks: Option, - - #[serde(flatten)] - pub environments: Option, -} - -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, JsonSchema)] pub struct ProjectManifest { pub canisters: Vec>, pub networks: Vec, pub environments: Vec, } -impl From for ProjectManifest { - fn from(v: ProjectInner) -> Self { - let ProjectInner { - canisters, - networks, - environments, - } = v; - - // Canisters - let canisters = canisters.unwrap_or_default().into(); - - // Networks - let networks = match networks { - // None specified, use defaults - None => Networks::default().into(), - - // Network(s) specified, append to default - Some(vs) => [ - Into::>::into(Networks::default()), - Into::>::into(vs), - ] - .concat(), - }; +impl<'de> Deserialize<'de> for ProjectManifest { + fn deserialize>(d: D) -> Result { + use serde::de::{Error, MapAccess, Visitor}; + use std::fmt; + + struct ProjectManifestVisitor; + + impl<'de> Visitor<'de> for ProjectManifestVisitor { + type Value = ProjectManifest; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str( + "a project manifest with canister, network and environment definitions", + ) + } + + // We're going to build the project manifest manually + // to be able to give good error messages + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut top_map = serde_yaml::Mapping::new(); + while let Some((key, value)) = + map.next_entry::()? + { + top_map.insert(key, value); + } + + // Start with canister definitions + // We need to handle: + // - canister - a single manifest + // - canisters - a list of manifests and/or paths + + let canister_key = serde_yaml::Value::String("canister".to_string()); + let canisters_key = serde_yaml::Value::String("canisters".to_string()); + + let has_canister = top_map.contains_key(&canister_key); + let has_canisters = top_map.contains_key(&canisters_key); + + let canisters: Vec> = match (has_canister, has_canisters) { + (true, true) => { + // This is an invalid case + + return Err(Error::custom( + "Project cannot define both `canister` and `canisters` sections", + )); + } - // Environments - let environments = match environments { - // None specified, use defaults - None => Environments::default().into(), + (true, false) => { + // There is a single inline canister manifest - // Environment(s) specified, append to default - Some(vs) => [ - Into::>::into(Environments::default()), - Into::>::into(vs), - ] - .concat(), - }; + let canister_value = top_map + .remove(&canister_key) + .ok_or_else(|| Error::custom("Invalid `canister` key"))?; + + let canister_manifest: CanisterManifest = + serde_yaml::from_value(canister_value).map_err(|e| { + Error::custom(format!("Failed to load canister manifest: {}", e)) + })?; + + Canisters::Canister(canister_manifest).into() + } + + (false, true) => { + // We have a list of Canisters + + if let serde_yaml::Value::Sequence(seq) = top_map + .remove(&canisters_key) + .ok_or_else(|| Error::custom("`canisters` key does not exist"))? + { + let mut canisters: Vec> = + Vec::with_capacity(seq.len()); + + for v in seq { + let item: Item = match v { + serde_yaml::Value::String(s) => Item::Path(s), + serde_yaml::Value::Mapping(mapping) => { + let canister_manifest: CanisterManifest = + serde_yaml::from_value(mapping.into()).map_err( + |e| { + Error::custom(format!( + "Failed to load canister manifest: {}", + e + )) + }, + )?; + Item::Manifest(canister_manifest) + } + _ => { + return Err(Error::custom( + "Invalid entry type in `canisters`", + )); + } + }; + + canisters.push(item); + } - Self { - canisters, - networks, - environments, + canisters + } else { + return Err(Error::custom("Expected an array for `canisters`")); + } + } + + (false, false) => { + // No canister definition, we use the default + Canisters::default().into() + } + }; + + // Deserialize the environments, we support: + // - no environments defined, in which case we end up with the defaults + // - environment - a single environment is defined + // - environments - a list of environments are defined + let environment_key = serde_yaml::Value::String("environment".to_string()); + let environments_key = serde_yaml::Value::String("environments".to_string()); + + let has_environment = top_map.contains_key(&environment_key); + let has_environments = top_map.contains_key(&environments_key); + + let environments: Vec = match ( + has_environment, + has_environments, + ) { + (true, true) => { + // This is an invalid case + + return Err(Error::custom( + "Project cannot define both `environment` and `environments` sections", + )); + } + (true, false) => { + // Single environment defined + + let environment_value = top_map + .remove(&environment_key) + .ok_or_else(|| Error::custom("Invalid `environment` key"))?; + + let environment_manifest: EnvironmentManifest = + serde_yaml::from_value(environment_value).map_err(|e| { + Error::custom(format!("Failed to load environment manifest: {}", e)) + })?; + + [ + Into::>::into(Environments::default()), + vec![environment_manifest], + ] + .concat() + } + (false, true) => { + if let serde_yaml::Value::Sequence(seq) = top_map + .remove(&environments_key) + .ok_or_else(|| Error::custom("'environments' key does not exist"))? + { + let mut environments: Vec = + Vec::with_capacity(seq.len()); + + for v in seq { + let environment_manifest = + serde_yaml::from_value(v).map_err(|e| { + Error::custom(format!("Failed to load environment: {}", e)) + })?; + + environments.push(environment_manifest); + } + + [ + Into::>::into(Environments::default()), + environments, + ] + .concat() + } else { + return Err(Error::custom("Expected an array for `environments`")); + } + } + (false, false) => Environments::default().into(), + }; + + // Deserialize the networks, we support: + // - no networks defined, in which case we end up with the defaults + // - network - a single network is defined + // - networks - a list of networks are defined + let network_key = serde_yaml::Value::String("network".to_string()); + let networks_key = serde_yaml::Value::String("networks".to_string()); + + let has_network = top_map.contains_key(&network_key); + let has_networks = top_map.contains_key(&networks_key); + + let networks: Vec = match (has_network, has_networks) { + (true, true) => { + // This is an invalid case + + return Err(Error::custom( + "Project cannot define both `network` and `networks` sections", + )); + } + (true, false) => { + // Single network defined + + let network_value = top_map + .remove(&network_key) + .ok_or_else(|| Error::custom("Invalid `network` key"))?; + + let network_manifest: NetworkManifest = + serde_yaml::from_value(network_value).map_err(|e| { + Error::custom(format!("Failed to load network manifest: {}", e)) + })?; + + [ + Into::>::into(Networks::default()), + vec![network_manifest], + ] + .concat() + } + (false, true) => { + if let serde_yaml::Value::Sequence(seq) = top_map + .remove(&networks_key) + .ok_or_else(|| Error::custom("'networks' key does not exist"))? + { + let mut networks: Vec = Vec::with_capacity(seq.len()); + + for v in seq { + let network_manifest = serde_yaml::from_value(v).map_err(|e| { + Error::custom(format!("Failed to load network: {}", e)) + })?; + + networks.push(network_manifest); + } + + [ + Into::>::into(Networks::default()), + networks, + ] + .concat() + } else { + return Err(Error::custom("Expected an array for `networks`")); + } + } + (false, false) => Networks::default().into(), + }; + + Ok(ProjectManifest { + canisters, + networks, + environments, + }) + } } - } -} -impl<'de> Deserialize<'de> for ProjectManifest { - fn deserialize>(d: D) -> Result { - let inner: ProjectInner = Deserialize::deserialize(d)?; - Ok(inner.into()) + d.deserialize_map(ProjectManifestVisitor) } } @@ -154,10 +344,11 @@ mod tests { use std::collections::HashMap; use anyhow::{Error, anyhow}; + use indoc::indoc; use crate::{ canister::{Settings, build, sync}, - manifest::{canister::Instructions, environment::CanisterSelection}, + manifest::{adapter::script, canister::Instructions, environment::CanisterSelection}, network::Configuration, }; @@ -180,20 +371,26 @@ mod tests { #[test] fn canister() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" canister: name: my-canister build: - steps: [] - "# - )?, + steps: + - type: script + command: dosomething.sh + "#})?, ProjectManifest { canisters: vec![Item::Manifest(CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: Instructions::BuildSync { - build: build::Steps { steps: vec![] }, + build: build::Steps { + steps: vec![build::Step::Script(script::Adapter { + command: script::CommandField::Command( + "dosomething.sh".to_string() + ) + })] + }, sync: sync::Steps { steps: vec![] }, }, })], @@ -205,23 +402,57 @@ mod tests { Ok(()) } + #[test] + fn project_with_invalid_canister_should_fail() -> Result<(), Error> { + // This canister is invalid because + match serde_yaml::from_str::(indoc! {r#" + canister: + name: my-canister + build: + steps: [] + "#}) + { + Ok(_) => { + return Err(anyhow!( + "A project manifest with an invalid canister manifest should be invalid" + )); + } + Err(err) => { + let err_msg = format!("{err}"); + if !err_msg.contains("Canister my-canister failed to parse") { + return Err(anyhow!( + "expected 'Canister my-canister failed to parse' error but got: {err}" + )); + } + } + }; + + Ok(()) + } + #[test] fn canisters_in_list() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" canisters: - name: my-canister build: - steps: [] - "# - )?, + steps: + - type: script + command: dosomething.sh + "#})?, ProjectManifest { canisters: vec![Item::Manifest(CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: Instructions::BuildSync { - build: build::Steps { steps: vec![] }, + build: build::Steps { + steps: vec![build::Step::Script(script::Adapter { + command: script::CommandField::Command( + "dosomething.sh".to_string() + ) + })] + }, sync: sync::Steps { steps: vec![] }, }, })], @@ -236,22 +467,28 @@ mod tests { #[test] fn canisters_mixed() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" canisters: - name: my-canister build: - steps: [] + steps: + - type: script + command: dosomething.sh - canisters/* - "# - )?, + "#})?, ProjectManifest { canisters: vec![ Item::Manifest(CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), instructions: crate::manifest::canister::Instructions::BuildSync { - build: build::Steps { steps: vec![] }, + build: build::Steps { + steps: vec![build::Step::Script(script::Adapter { + command: script::CommandField::Command( + "dosomething.sh".to_string() + ) + })] + }, sync: sync::Steps { steps: vec![] }, }, }), @@ -268,12 +505,10 @@ mod tests { #[test] fn network() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" network: name: my-network - "# - )?, + "#})?, ProjectManifest { canisters: Canisters::default().into(), networks: Networks::Networks(vec![NetworkManifest { @@ -292,12 +527,10 @@ mod tests { #[test] fn networks_in_list() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" networks: - name: my-network - "# - )?, + "#})?, ProjectManifest { canisters: Canisters::default().into(), networks: Networks::Networks(vec![NetworkManifest { @@ -316,14 +549,12 @@ mod tests { #[test] fn environment() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" environment: name: my-environment network: my-network canisters: [my-canister] - "# - )?, + "#})?, ProjectManifest { canisters: Canisters::default().into(), networks: Networks::default().into(), @@ -344,14 +575,12 @@ mod tests { #[test] fn environment_in_list() -> Result<(), Error> { assert_eq!( - serde_yaml::from_str::( - r#" + serde_yaml::from_str::(indoc! {r#" environments: - name: my-environment network: my-network canisters: [my-canister] - "# - )?, + "#})?, ProjectManifest { canisters: Canisters::default().into(), networks: Networks::default().into(), diff --git a/crates/icp/src/manifest/recipe.rs b/crates/icp/src/manifest/recipe.rs index 81a39281..17cb11e2 100644 --- a/crates/icp/src/manifest/recipe.rs +++ b/crates/icp/src/manifest/recipe.rs @@ -1,18 +1,95 @@ -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Display}; use schemars::JsonSchema; use serde::Deserialize; -#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema)] +/// Represents the accepted values for a recipe type in +/// the canister manifest +#[derive(Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "lowercase", from = "String")] pub enum RecipeType { - Unknown(String), + /// path to a locally defined recipe + File(String), + + /// url to a remote recipe + Url(String), + + /// A recipe hosted in a known registry + /// in yaml, the format is "@/@" + Registry { + /// the name of registry + name: String, + + /// the name of the recipe + recipe: String, + + /// the version of the recipe, deserializes to `latest` when not provided + version: String, + }, +} + +impl Display for RecipeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s: String = self.clone().into(); + write!(f, "{s}") + } +} + +impl<'de> Deserialize<'de> for RecipeType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let v = String::deserialize(deserializer)?; + + if v.starts_with("file://") { + let path = v.strip_prefix("file://").expect("prefix missing").into(); + + return Ok(Self::File(path)); + } + + if v.starts_with("http://") || v.starts_with("https://") { + return Ok(Self::Url(v.to_owned())); + } + + if v.starts_with("@") { + let recipe_type = v.strip_prefix("@").expect("prefix missing"); + + // Check for version delimiter + let (v, version) = if recipe_type.contains("@") { + // Version is specified + recipe_type.rsplit_once("@").expect("delimiter missing") + } else { + // Assume latest + (recipe_type, "latest") + }; + + let (registry, recipe) = v.split_once("/").expect("delimiter missing"); + + return Ok(Self::Registry { + name: registry.to_owned(), + recipe: recipe.to_owned(), + version: version.to_owned(), + }); + } + + Err(serde::de::Error::custom(format!( + "Invalid recipe type: {v}" + ))) + } } -impl From for RecipeType { - fn from(value: String) -> Self { - let other = value.as_str(); - Self::Unknown(other.to_owned()) +impl From for String { + fn from(value: RecipeType) -> Self { + match value { + RecipeType::File(path) => format!("file://{path}"), + RecipeType::Url(url) => url, + RecipeType::Registry { + name, + recipe, + version, + } => format!("@{name}/{recipe}@{version}"), + } } } diff --git a/crates/icp/src/manifest/serde_helpers.rs b/crates/icp/src/manifest/serde_helpers.rs new file mode 100644 index 00000000..5c46b079 --- /dev/null +++ b/crates/icp/src/manifest/serde_helpers.rs @@ -0,0 +1,16 @@ +/// Deserialization helpers +use serde::Deserialize; +use serde::de::{self, Deserializer}; + +/// Requires that a vector has at least one entry +pub(crate) fn non_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let v = Vec::::deserialize(deserializer)?; + if v.is_empty() { + return Err(de::Error::custom("Array must not be empty")); + } + Ok(v) +} diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index 709108f3..f2dc0390 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -14,7 +14,7 @@ use crate::{ is_glob, manifest::{ CANISTER_MANIFEST, CanisterManifest, Item, Locate, canister::Instructions, - environment::CanisterSelection, project::ProjectManifest, + environment::CanisterSelection, project::ProjectManifest, recipe::RecipeType, }, prelude::*, }; @@ -73,8 +73,8 @@ pub enum LoadManifestError { #[error("failed to load canister manifest")] Canister, - #[error("failed to resolve canister recipe")] - Recipe, + #[error("failed to resolve canister recipe: {0}")] + Recipe(RecipeType), #[error("project contains two similarly named {kind}s: '{name}'")] Duplicate { kind: String, name: String }, @@ -173,7 +173,7 @@ impl LoadManifest for ManifestLoade .recipe .resolve(recipe) .await - .context(LoadManifestError::Recipe)?, + .context(LoadManifestError::Recipe(recipe.recipe_type.clone()))?, }; // Check for duplicates diff --git a/crates/schema-gen/src/main.rs b/crates/schema-gen/src/main.rs index 3a1ab959..b9542d48 100644 --- a/crates/schema-gen/src/main.rs +++ b/crates/schema-gen/src/main.rs @@ -1,4 +1,4 @@ -use icp::manifest::project::ProjectInner; +use icp::manifest::project::ProjectManifest; use schemars::schema_for; const ID: &str = "https://dfinity.org/schemas/icp-yaml/v1.0.0"; @@ -8,7 +8,7 @@ const DESCRIPTION: &str = "Schema for icp.yaml project configuration files used /// Generate JSON Schema for icp.yaml configuration files fn main() -> Result<(), Box> { // Generate schema for the main ProjectManifest type - let schema = schema_for!(ProjectInner); + let schema = schema_for!(ProjectManifest); // Add metadata to the schema let mut schema_json = serde_json::to_value(schema)?; diff --git a/docs/icp-yaml-schema.json b/docs/icp-yaml-schema.json index f34e513d..32bb0c6d 100644 --- a/docs/icp-yaml-schema.json +++ b/docs/icp-yaml-schema.json @@ -4,10 +4,65 @@ "definitions": { "CanisterManifest": { "description": "Represents the manifest describing a single canister. This struct is typically loaded from a `canister.yaml` file and defines the canister's name and how it should be built into WebAssembly.", - "properties": { - "instructions": { - "$ref": "#/definitions/Instructions" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "Recipe": { + "properties": { + "recipe": { + "$ref": "#/definitions/Recipe" + } + }, + "required": [ + "recipe" + ], + "type": "object" + } + }, + "required": [ + "Recipe" + ], + "type": "object" }, + { + "additionalProperties": false, + "properties": { + "BuildSync": { + "properties": { + "build": { + "allOf": [ + { + "$ref": "#/definitions/Steps" + } + ], + "description": "The build configuration specifying how to compile the canister's source code into a WebAssembly module, including the adapter to use." + }, + "sync": { + "allOf": [ + { + "$ref": "#/definitions/Steps2" + } + ], + "default": { + "steps": [] + }, + "description": "The configuration specifying how to sync the canister" + } + }, + "required": [ + "build" + ], + "type": "object" + } + }, + "required": [ + "BuildSync" + ], + "type": "object" + } + ], + "properties": { "name": { "description": "The unique name of the canister as defined in this manifest.", "type": "string" @@ -18,13 +73,20 @@ "$ref": "#/definitions/Settings" } ], + "default": { + "compute_allocation": null, + "environment_variables": null, + "freezing_threshold": null, + "memory_allocation": null, + "reserved_cycles_limit": null, + "wasm_memory_limit": null, + "wasm_memory_threshold": null + }, "description": "The configuration specifying the various settings when creating the canister." } }, "required": [ - "instructions", - "name", - "settings" + "name" ], "type": "object" }, @@ -159,48 +221,6 @@ }, "type": "object" }, - "Instructions": { - "anyOf": [ - { - "properties": { - "recipe": { - "$ref": "#/definitions/Recipe" - } - }, - "required": [ - "recipe" - ], - "type": "object" - }, - { - "properties": { - "build": { - "allOf": [ - { - "$ref": "#/definitions/Steps" - } - ], - "description": "The build configuration specifying how to compile the canister's source code into a WebAssembly module, including the adapter to use." - }, - "sync": { - "allOf": [ - { - "$ref": "#/definitions/Steps2" - } - ], - "default": { - "steps": [] - }, - "description": "The configuration specifying how to sync the canister" - } - }, - "required": [ - "build" - ], - "type": "object" - } - ] - }, "Item_for_CanisterManifest": { "anyOf": [ { @@ -292,16 +312,63 @@ "type": "object" }, "RecipeType": { + "description": "Represents the accepted values for a recipe type in the canister manifest", "oneOf": [ { "additionalProperties": false, + "description": "path to a locally defined recipe", "properties": { - "unknown": { + "file": { "type": "string" } }, "required": [ - "unknown" + "file" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "url to a remote recipe", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A recipe hosted in a known registry in yaml, the format is \"@/@\"", + "properties": { + "registry": { + "properties": { + "name": { + "description": "the name of registry", + "type": "string" + }, + "recipe": { + "description": "the name of the recipe", + "type": "string" + }, + "version": { + "description": "the version of the recipe, deserializes to `latest` when not provided", + "type": "string" + } + }, + "required": [ + "name", + "recipe", + "version" + ], + "type": "object" + } + }, + "required": [ + "registry" ], "type": "object" } @@ -609,34 +676,30 @@ } }, "description": "Schema for icp.yaml project configuration files used by the ICP CLI", - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "canister": { - "$ref": "#/definitions/CanisterManifest" - } + "properties": { + "canisters": { + "items": { + "$ref": "#/definitions/Item_for_CanisterManifest" }, - "required": [ - "canister" - ], - "type": "object" + "type": "array" }, - { - "additionalProperties": false, - "properties": { - "canisters": { - "items": { - "$ref": "#/definitions/Item_for_CanisterManifest" - }, - "type": "array" - } + "environments": { + "items": { + "$ref": "#/definitions/EnvironmentManifest" }, - "required": [ - "canisters" - ], - "type": "object" + "type": "array" + }, + "networks": { + "items": { + "$ref": "#/definitions/NetworkManifest" + }, + "type": "array" } + }, + "required": [ + "canisters", + "environments", + "networks" ], "title": "ICP Project Configuration", "type": "object" diff --git a/examples/icp-motoko-recipe/icp.yaml b/examples/icp-motoko-recipe/icp.yaml index 3301861b..39f1e678 100644 --- a/examples/icp-motoko-recipe/icp.yaml +++ b/examples/icp-motoko-recipe/icp.yaml @@ -1,6 +1,6 @@ canister: name: my-canister recipe: - type: file://recipe.hb.yaml + type: "@dfinity/motoko" configuration: entry: src/main.mo diff --git a/examples/icp-motoko-recipe/recipe.hb.yaml b/examples/icp-motoko-recipe/recipe.hb.yaml deleted file mode 100644 index aec78b60..00000000 --- a/examples/icp-motoko-recipe/recipe.hb.yaml +++ /dev/null @@ -1,44 +0,0 @@ -{{! A recipe for building a motoko canister }} -{{! `entry: string` The entry point for the canister }} -{{! `shrink: boolean` Optimizes the wasm with ic-wasm }} -{{! `compressed: boolean` determins whether the was should be gzipped }} -{{! `metadata: [name: string, value: string]`: An array of name/value pairs that get injected into the wasm metadata section }} - -build: - steps: - - - type: script - commands: - - command -v moc >/dev/null 2>&1 || { echo >&2 "moc not found. To install moc, see https://internetcomputer.org/docs/building-apps/getting-started/install \n"; exit 1; } - - moc {{ entry }} - - mv main.wasm "$ICP_WASM_OUTPUT_PATH" - - - type: script - commands: - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "moc:version" -d "$(moc --version)" --keep-name-section - - - type: script - commands: - - command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm \n"; exit 1; } - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "cargo:version" -d "$(cargo --version)" --keep-name-section - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "template:type" -d "motoko" --keep-name-section - {{#if metadata}} - {{#each metadata}} - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "{{ name }}" -d "{{ value }}" --keep-name-section - {{/each}} - {{/if}} - - {{#if shrink}} - - type: script - commands: - - command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm \n"; exit 1; } - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" shrink --keep-name-section - {{/if}} - - {{#if compress }} - - type: script - commands: - - command -v gzip >/dev/null 2>&1 || { echo >&2 "gzip not found. Please install gzip to compress build output. \n"; exit 1; } - - gzip --no-name "$ICP_WASM_OUTPUT_PATH" - - mv "${ICP_WASM_OUTPUT_PATH}.gz" "$ICP_WASM_OUTPUT_PATH" - {{/if}} diff --git a/examples/icp-rust-recipe/icp.yaml b/examples/icp-rust-recipe/icp.yaml index e40ac293..1846c495 100644 --- a/examples/icp-rust-recipe/icp.yaml +++ b/examples/icp-rust-recipe/icp.yaml @@ -1,7 +1,7 @@ canister: name: my-canister recipe: - type: file://recipe.hb.yaml + type: "@dfinity/rust" configuration: # cargo package for canister (required field) package: icp-canister diff --git a/examples/icp-rust-recipe/recipe.hb.yaml b/examples/icp-rust-recipe/recipe.hb.yaml deleted file mode 100644 index 5f8b253a..00000000 --- a/examples/icp-rust-recipe/recipe.hb.yaml +++ /dev/null @@ -1,38 +0,0 @@ -{{! A recipe for building a rust canister }} -{{! `package: string` The package to build }} -{{! `shrink: boolean` Optimizes the wasm with ic-wasm }} -{{! `compressed: boolean` determins whether the was should be gzipped }} -{{! `metadata: [name: string, value: string]`: An array of name/value pairs that get injected into the wasm metadata section }} - -build: - steps: - - type: script - commands: - - cargo build --package {{ package }} --target wasm32-unknown-unknown --release - - mv target/wasm32-unknown-unknown/release/{{ replace "-" "_" package }}.wasm "$ICP_WASM_OUTPUT_PATH" - - - type: script - commands: - - command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm \n"; exit 1; } - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "cargo:version" -d "$(cargo --version)" --keep-name-section - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "template:type" -d "rust" --keep-name-section - {{#if metadata}} - {{#each metadata}} - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "{{ name }}" -d "{{ value }}" --keep-name-section - {{/each}} - {{/if}} - - {{#if shrink}} - - type: script - commands: - - command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm \n"; exit 1; } - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" shrink --keep-name-section - {{/if}} - - {{#if compress }} - - type: script - commands: - - command -v gzip >/dev/null 2>&1 || { echo >&2 "gzip not found. Please install gzip to compress build output. \n"; exit 1; } - - gzip --no-name "$ICP_WASM_OUTPUT_PATH" - - mv "${ICP_WASM_OUTPUT_PATH}.gz" "$ICP_WASM_OUTPUT_PATH" - {{/if}} diff --git a/examples/icp-static-assets-recipe/icp.yaml b/examples/icp-static-assets-recipe/icp.yaml index 9b5794e6..7a95d5b1 100644 --- a/examples/icp-static-assets-recipe/icp.yaml +++ b/examples/icp-static-assets-recipe/icp.yaml @@ -1,7 +1,7 @@ canister: name: my-canister recipe: - type: file://recipe.hb.yaml + type: "@dfinity/asset-canister" configuration: version: 0.29.2 dir: www diff --git a/examples/icp-static-assets-recipe/recipe.hb.yaml b/examples/icp-static-assets-recipe/recipe.hb.yaml deleted file mode 100644 index 253d6ff5..00000000 --- a/examples/icp-static-assets-recipe/recipe.hb.yaml +++ /dev/null @@ -1,32 +0,0 @@ -{{! A recipe for building static assets and deploying them to an asset canister }} -{{! `version: string` Optional, The version of the asset canister to use. Defaults to the master branch}} -{{! `dir: string` Required, the directory of assets to synchronize }} -{{! `metadata: [name: string, value: string]`: An array of name/value pairs that get injected into the wasm metadata section }} - -build: - - steps: - - - type: pre-built - {{! There is no convenient way to define defaults }} - {{#if version}} - url: https://github.com/dfinity/sdk/raw/refs/tags/{{ version }}/src/distributed/assetstorage.wasm.gz - {{else}} - {{! TODO We needed a latest tag on the sdk repo }} - url: https://github.com/dfinity/sdk/raw/refs/heads/master/src/distributed/assetstorage.wasm.gz - {{/if}} - - {{#if metadata}} - - type: script - commands: - {{#each metadata}} - - sh -c 'command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm \n"; exit 1; }' - - sh -c 'ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "${ICP_WASM_OUTPUT_PATH}" metadata "{{ name }}" -d "{{ value }}" --keep-name-section' - {{/each}} - {{/if}} - -{{! Synchronize assets }} -sync: - steps: - - type: assets - dir: {{ dir }}