From ea595fcfd5df993eb55155409fb56eaf37f4d715 Mon Sep 17 00:00:00 2001 From: itowlson Date: Wed, 27 Aug 2025 14:38:35 +1200 Subject: [PATCH] Build profiles Signed-off-by: itowlson --- crates/build/src/lib.rs | 83 +++- crates/build/src/manifest.rs | 12 +- crates/doctor/src/rustlang/target.rs | 7 +- crates/doctor/src/wasm.rs | 6 +- crates/environments/src/lib.rs | 6 +- crates/environments/src/loader.rs | 20 +- crates/factors-test/src/lib.rs | 2 +- crates/loader/src/lib.rs | 5 +- crates/loader/src/local.rs | 44 ++- crates/loader/tests/ui.rs | 1 + crates/manifest/src/compat.rs | 1 + crates/manifest/src/schema/common.rs | 21 +- crates/manifest/src/schema/v2.rs | 371 +++++++++++++++++- crates/oci/src/client.rs | 2 + src/commands/build.rs | 10 + src/commands/registry.rs | 14 +- src/commands/up.rs | 37 +- src/commands/up/app_source.rs | 21 +- src/commands/watch.rs | 20 +- src/commands/watch/buildifier.rs | 4 + src/commands/watch/filters.rs | 12 +- src/commands/watch/uppificator.rs | 4 + tests/integration.rs | 3 +- .../src/runtimes/in_process_spin.rs | 1 + 24 files changed, 636 insertions(+), 71 deletions(-) diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index 8ad3be2907..f57380c239 100644 --- a/crates/build/src/lib.rs +++ b/crates/build/src/lib.rs @@ -15,14 +15,18 @@ use subprocess::{Exec, Redirection}; use crate::manifest::component_build_configs; +const LAST_BUILD_PROFILE_FILE: &str = "last-build.txt"; +const LAST_BUILD_ANON_VALUE: &str = ""; + /// If present, run the build command of each component. pub async fn build( manifest_file: &Path, + profile: Option<&str>, component_ids: &[String], target_checks: TargetChecking, cache_root: Option, ) -> Result<()> { - let build_info = component_build_configs(manifest_file) + let build_info = component_build_configs(manifest_file, profile) .await .with_context(|| { format!( @@ -53,6 +57,8 @@ pub async fn build( // If the build failed, exit with an error at this point. build_result?; + save_last_build_profile(&app_dir, profile); + let Some(manifest) = build_info.manifest() else { // We can't proceed to checking (because that needs a full healthy manifest), and we've // already emitted any necessary warning, so quit. @@ -71,6 +77,7 @@ pub async fn build( build_info.deployment_targets(), cache_root.clone(), &app_dir, + profile, ) .await .context("unable to check if the application is compatible with deployment targets")?; @@ -89,8 +96,19 @@ pub async fn build( /// Run all component build commands, using the default options (build all /// components, perform target checking). We run a "default build" in several /// places and this centralises the logic of what such a "default build" means. -pub async fn build_default(manifest_file: &Path, cache_root: Option) -> Result<()> { - build(manifest_file, &[], TargetChecking::Check, cache_root).await +pub async fn build_default( + manifest_file: &Path, + profile: Option<&str>, + cache_root: Option, +) -> Result<()> { + build( + manifest_file, + profile, + &[], + TargetChecking::Check, + cache_root, + ) + .await } fn build_components( @@ -148,7 +166,7 @@ fn build_component(build_info: ComponentBuildInfo, app_dir: &Path) -> Result<()> ); } - for (index, command) in b.commands().enumerate() { + for (index, command) in b.commands().iter().enumerate() { if command_count > 1 { terminal::step!( "Running build step", @@ -215,6 +233,56 @@ fn construct_workdir(app_dir: &Path, workdir: Option>) -> Resul Ok(cwd) } +/// Saves the build profile to the "last build profile" file. +/// Errors are ignored as they should not block building. +pub fn save_last_build_profile(app_dir: &Path, profile: Option<&str>) { + let app_stash_dir = app_dir.join(".spin"); + _ = std::fs::create_dir_all(&app_stash_dir); + let last_build_profile_file = app_stash_dir.join(LAST_BUILD_PROFILE_FILE); + _ = std::fs::write( + &last_build_profile_file, + profile.unwrap_or(LAST_BUILD_ANON_VALUE), + ); +} + +/// Reads the last build profile from the "last build profile" file. +/// Errors are ignored. +pub fn read_last_build_profile(app_dir: &Path) -> Option { + let app_stash_dir = app_dir.join(".spin"); + let last_build_profile_file = app_stash_dir.join(LAST_BUILD_PROFILE_FILE); + let last_build_str = std::fs::read_to_string(&last_build_profile_file).ok()?; + + if last_build_str == LAST_BUILD_ANON_VALUE { + None + } else { + Some(last_build_str) + } +} + +/// Prints a warning to stderr if the given profile is not the same +/// as the most recent build in the given application directory. +pub fn warn_if_not_latest_build(manifest_path: &Path, profile: Option<&str>) { + let Some(app_dir) = manifest_path.parent() else { + return; + }; + + let latest_build = read_last_build_profile(app_dir); + + let is_match = match (profile, latest_build) { + (None, None) => true, + (Some(_), None) | (None, Some(_)) => false, + (Some(p), Some(latest)) => p == latest, + }; + + if !is_match { + let profile_opt = match profile { + Some(p) => format!(" --profile {p}"), + None => "".to_string(), + }; + terminal::warn!("You built a different profile more recently than the one you are running. If the app appears to be behaving like an older version then run `spin up --build{profile_opt}`."); + } +} + /// Specifies target environment checking behaviour pub enum TargetChecking { /// The build should check that all components are compatible with all target environments. @@ -242,7 +310,7 @@ mod tests { #[tokio::test] async fn can_load_even_if_trigger_invalid() { let bad_trigger_file = test_data_root().join("bad_trigger.toml"); - build(&bad_trigger_file, &[], TargetChecking::Skip, None) + build(&bad_trigger_file, None, &[], TargetChecking::Skip, None) .await .unwrap(); } @@ -250,7 +318,7 @@ mod tests { #[tokio::test] async fn succeeds_if_target_env_matches() { let manifest_path = test_data_root().join("good_target_env.toml"); - build(&manifest_path, &[], TargetChecking::Check, None) + build(&manifest_path, None, &[], TargetChecking::Check, None) .await .unwrap(); } @@ -258,7 +326,7 @@ mod tests { #[tokio::test] async fn fails_if_target_env_does_not_match() { let manifest_path = test_data_root().join("bad_target_env.toml"); - let err = build(&manifest_path, &[], TargetChecking::Check, None) + let err = build(&manifest_path, None, &[], TargetChecking::Check, None) .await .expect_err("should have failed") .to_string(); @@ -287,6 +355,7 @@ mod tests { &manifest.application.targets, None, manifest_file.parent().unwrap(), + None, ) .await .context("unable to check if the application is compatible with deployment targets") diff --git a/crates/build/src/manifest.rs b/crates/build/src/manifest.rs index db570e4145..709faf61a1 100644 --- a/crates/build/src/manifest.rs +++ b/crates/build/src/manifest.rs @@ -66,12 +66,17 @@ impl ManifestBuildInfo { /// given (v1 or v2) manifest path. If the manifest cannot be loaded, the /// function attempts fallback: if fallback succeeds, result is Ok but the load error /// is also returned via the second part of the return value tuple. -pub async fn component_build_configs(manifest_file: impl AsRef) -> Result { +pub async fn component_build_configs( + manifest_file: impl AsRef, + profile: Option<&str>, +) -> Result { let manifest = spin_manifest::manifest_from_file(&manifest_file); match manifest { Ok(mut manifest) => { + manifest.ensure_profile(profile)?; + spin_manifest::normalize::normalize_manifest(&mut manifest); - let components = build_configs_from_manifest(&manifest); + let components = build_configs_from_manifest(&manifest, profile); let deployment_targets = deployment_targets_from_manifest(&manifest); Ok(ManifestBuildInfo::Loadable { components, @@ -101,13 +106,14 @@ pub async fn component_build_configs(manifest_file: impl AsRef) -> Result< fn build_configs_from_manifest( manifest: &spin_manifest::schema::v2::AppManifest, + profile: Option<&str>, ) -> Vec { manifest .components .iter() .map(|(id, c)| ComponentBuildInfo { id: id.to_string(), - build: c.build.clone(), + build: c.build(profile), }) .collect() } diff --git a/crates/doctor/src/rustlang/target.rs b/crates/doctor/src/rustlang/target.rs index 3d37739f2f..6df1fc4848 100644 --- a/crates/doctor/src/rustlang/target.rs +++ b/crates/doctor/src/rustlang/target.rs @@ -17,10 +17,9 @@ impl Diagnostic for TargetDiagnostic { let manifest_str = patient.manifest_doc.to_string(); let manifest = spin_manifest::manifest_from_str(&manifest_str)?; let uses_rust = manifest.components.values().any(|c| { - c.build - .as_ref() - .map(|b| b.commands().any(|c| c.starts_with("cargo"))) - .unwrap_or_default() + c.build_commands(None) + .iter() + .any(|c| c.starts_with("cargo")) }); if uses_rust { diff --git a/crates/doctor/src/wasm.rs b/crates/doctor/src/wasm.rs index d774cb0844..a51ca8f9fa 100644 --- a/crates/doctor/src/wasm.rs +++ b/crates/doctor/src/wasm.rs @@ -33,14 +33,14 @@ impl PatientWasm { } pub fn source_path(&self) -> Option<&Path> { - match &self.component.source { + match &self.component.source(None) { v2::ComponentSource::Local(path) => Some(Path::new(path)), _ => None, } } pub fn abs_source_path(&self) -> Option { - match &self.component.source { + match &self.component.source(None) { v2::ComponentSource::Local(path) => { // TODO: We probably need a doctor check to see if the path can be expanded! // For now, fall back to the literal path. @@ -54,7 +54,7 @@ impl PatientWasm { } pub fn has_build(&self) -> bool { - self.component.build.is_some() + self.component.build(None).is_some() } } diff --git a/crates/environments/src/lib.rs b/crates/environments/src/lib.rs index 6544594cfd..62a00a2c00 100644 --- a/crates/environments/src/lib.rs +++ b/crates/environments/src/lib.rs @@ -39,13 +39,14 @@ pub async fn validate_application_against_environment_ids( env_ids: &[TargetEnvironmentRef], cache_root: Option, app_dir: &std::path::Path, + profile: Option<&str>, ) -> anyhow::Result { if env_ids.is_empty() { return Ok(Default::default()); } let envs = TargetEnvironment::load_all(env_ids, cache_root, app_dir).await?; - validate_application_against_environments(application, &envs).await + validate_application_against_environments(application, &envs, profile).await } /// Validates *all* application components against the list of (realised) target enviroments. Each component must conform @@ -55,6 +56,7 @@ pub async fn validate_application_against_environment_ids( async fn validate_application_against_environments( application: &ApplicationToValidate, envs: &[TargetEnvironment], + profile: Option<&str>, ) -> anyhow::Result { for trigger_type in application.trigger_types() { if let Some(env) = envs.iter().find(|e| !e.supports_trigger_type(trigger_type)) { @@ -65,7 +67,7 @@ async fn validate_application_against_environments( } } - let components_by_trigger_type = application.components_by_trigger_type().await?; + let components_by_trigger_type = application.components_by_trigger_type(profile).await?; let mut errs = vec![]; diff --git a/crates/environments/src/loader.rs b/crates/environments/src/loader.rs index fa5639775a..bc448b9bcd 100644 --- a/crates/environments/src/loader.rs +++ b/crates/environments/src/loader.rs @@ -67,6 +67,7 @@ impl ApplicationToValidate { fn component_source<'a>( &'a self, trigger: &'a spin_manifest::schema::v2::Trigger, + profile: Option<&str>, ) -> anyhow::Result> { let component_spec = trigger .component @@ -75,8 +76,8 @@ impl ApplicationToValidate { let (id, source, dependencies, service_chaining) = match component_spec { spin_manifest::schema::v2::ComponentSpec::Inline(c) => ( trigger.id.as_str(), - &c.source, - &c.dependencies, + &c.source(profile), + &c.dependencies(profile), spin_loader::requires_service_chaining(c), ), spin_manifest::schema::v2::ComponentSpec::Reference(r) => { @@ -89,8 +90,8 @@ impl ApplicationToValidate { }; ( id, - &component.source, - &component.dependencies, + &component.source(profile), + &component.dependencies(profile), spin_loader::requires_service_chaining(component), ) } @@ -116,11 +117,12 @@ impl ApplicationToValidate { pub(crate) async fn components_by_trigger_type( &self, + profile: Option<&str>, ) -> anyhow::Result>)>> { use futures::FutureExt; let components_by_trigger_type_futs = self.triggers().map(|(ty, ts)| { - self.components_for_trigger(ts) + self.components_for_trigger(ts, profile) .map(|css| css.map(|css| (ty.to_owned(), css))) }); let components_by_trigger_type = try_join_all(components_by_trigger_type_futs) @@ -132,16 +134,20 @@ impl ApplicationToValidate { async fn components_for_trigger<'a>( &'a self, triggers: &'a [spin_manifest::schema::v2::Trigger], + profile: Option<&str>, ) -> anyhow::Result>> { - let component_futures = triggers.iter().map(|t| self.load_and_resolve_trigger(t)); + let component_futures = triggers + .iter() + .map(|t| self.load_and_resolve_trigger(t, profile)); try_join_all(component_futures).await } async fn load_and_resolve_trigger<'a>( &'a self, trigger: &'a spin_manifest::schema::v2::Trigger, + profile: Option<&str>, ) -> anyhow::Result> { - let component = self.component_source(trigger)?; + let component = self.component_source(trigger, profile)?; let loader = ComponentSourceLoader::new(&self.wasm_loader); diff --git a/crates/factors-test/src/lib.rs b/crates/factors-test/src/lib.rs index 449b95bcf6..2a197de914 100644 --- a/crates/factors-test/src/lib.rs +++ b/crates/factors-test/src/lib.rs @@ -102,5 +102,5 @@ pub async fn build_locked_app(manifest: &toml::Table) -> anyhow::Result, files_mount_strategy: FilesMountStrategy, + profile: Option<&str>, cache_root: Option, ) -> Result { let path = manifest_path.as_ref(); let app_root = parent_dir(path).context("manifest path has no parent directory")?; - let loader = LocalLoader::new(&app_root, files_mount_strategy, cache_root).await?; + let loader = LocalLoader::new(&app_root, files_mount_strategy, profile, cache_root).await?; loader.load_file(path).await } @@ -47,7 +48,7 @@ pub async fn from_file( pub async fn from_wasm_file(wasm_path: impl AsRef) -> Result { let app_root = std::env::current_dir()?; let manifest = single_file_manifest(wasm_path)?; - let loader = LocalLoader::new(&app_root, FilesMountStrategy::Direct, None).await?; + let loader = LocalLoader::new(&app_root, FilesMountStrategy::Direct, None, None).await?; loader.load_manifest(manifest).await } diff --git a/crates/loader/src/local.rs b/crates/loader/src/local.rs index abcd9015e6..7552247424 100644 --- a/crates/loader/src/local.rs +++ b/crates/loader/src/local.rs @@ -26,12 +26,14 @@ pub struct LocalLoader { files_mount_strategy: FilesMountStrategy, file_loading_permits: std::sync::Arc, wasm_loader: WasmLoader, + profile: Option, } impl LocalLoader { pub async fn new( app_root: &Path, files_mount_strategy: FilesMountStrategy, + profile: Option<&str>, cache_root: Option, ) -> Result { let app_root = safe_canonicalize(app_root) @@ -44,6 +46,7 @@ impl LocalLoader { // Limit concurrency to avoid hitting system resource limits file_loading_permits: file_loading_permits.clone(), wasm_loader: WasmLoader::new(app_root, cache_root, Some(file_loading_permits)).await?, + profile: profile.map(|s| s.to_owned()), }) } @@ -68,6 +71,13 @@ impl LocalLoader { .metadata .insert("origin".into(), file_url(path)?.into()); + // Set build profile metadata + if let Some(profile) = self.profile.as_ref() { + locked + .metadata + .insert("profile".into(), profile.as_str().into()); + } + Ok(locked) } @@ -155,29 +165,34 @@ impl LocalLoader { let component_requires_service_chaining = requires_service_chaining(&component); + let source = self + .load_component_source(id, component.source(self.profile()).clone()) + .await + .with_context(|| { + format!( + "Failed to load Wasm source {}", + component.source(self.profile()) + ) + })?; + let metadata = ValuesMapBuilder::new() - .string("description", component.description) + .string("description", component.description.clone()) .string_array("allowed_outbound_hosts", allowed_outbound_hosts) - .string_array("key_value_stores", component.key_value_stores) - .string_array("databases", component.sqlite_databases) - .string_array("ai_models", component.ai_models) - .serializable("build", component.build)? + .string_array("key_value_stores", component.key_value_stores.clone()) + .string_array("databases", component.sqlite_databases.clone()) + .string_array("ai_models", component.ai_models.clone()) + .serializable("build", component.build(self.profile()))? .take(); - let source = self - .load_component_source(id, component.source.clone()) - .await - .with_context(|| format!("Failed to load Wasm source {}", component.source))?; - let dependencies = self .load_component_dependencies( id, component.dependencies_inherit_configuration, - &component.dependencies, + &component.dependencies(self.profile()), ) .await?; - let env = component.environment.into_iter().collect(); + let env = component.environment(self.profile()).into_iter().collect(); let files = if component.files.is_empty() { vec![] @@ -538,6 +553,10 @@ impl LocalLoader { path: dest.into(), }) } + + fn profile(&self) -> Option<&str> { + self.profile.as_deref() + } } fn explain_file_mount_source_error(e: anyhow::Error, src: &Path) -> anyhow::Error { @@ -925,6 +944,7 @@ mod test { &app_root, FilesMountStrategy::Copy(wd.path().to_owned()), None, + None, ) .await?; let err = loader diff --git a/crates/loader/tests/ui.rs b/crates/loader/tests/ui.rs index 2debf28203..483dfc2c25 100644 --- a/crates/loader/tests/ui.rs +++ b/crates/loader/tests/ui.rs @@ -50,6 +50,7 @@ fn run_test(input: &Path, normalizer: &mut Normalizer) -> Result input, spin_loader::FilesMountStrategy::Copy(files_mount_root), None, + None, ) .await .map_err(|err| format!("{err:?}"))?; diff --git a/crates/manifest/src/compat.rs b/crates/manifest/src/compat.rs index 4cca17cd3a..a80b8d6e09 100644 --- a/crates/manifest/src/compat.rs +++ b/crates/manifest/src/compat.rs @@ -78,6 +78,7 @@ pub fn v1_to_v2_app(manifest: v1::AppManifestV1) -> Result impl ExactSizeIterator { - let as_vec = match &self.command { - Commands::Single(cmd) => vec![cmd], - Commands::Multiple(cmds) => cmds.iter().collect(), - }; - as_vec.into_iter() + pub fn commands(&self) -> Vec<&String> { + self.command.as_vec() } } @@ -216,6 +212,15 @@ pub enum Commands { Multiple(Vec), } +impl Commands { + pub(crate) fn as_vec(&self) -> Vec<&String> { + match self { + Self::Single(cmd) => vec![cmd], + Self::Multiple(cmds) => cmds.iter().collect(), + } + } +} + fn is_false(v: &bool) -> bool { !*v } diff --git a/crates/manifest/src/schema/v2.rs b/crates/manifest/src/schema/v2.rs index b656169387..d6d8612705 100644 --- a/crates/manifest/src/schema/v2.rs +++ b/crates/manifest/src/schema/v2.rs @@ -52,6 +52,25 @@ impl AppManifest { } Ok(()) } + + /// Whether any component in the application defines the given profile. + /// Not every component defines every profile, and components intentionally + /// fall back to the anonymouse profile if they are asked for a profile + /// they don't define. So this can be used to detect that a user might have + /// mistyped a profile (e.g. `spin up --profile deugb`). + pub fn ensure_profile(&self, profile: Option<&str>) -> anyhow::Result<()> { + let Some(p) = profile else { + return Ok(()); + }; + + let is_defined = self.components.values().any(|c| c.profile.contains_key(p)); + + if is_defined { + Ok(()) + } else { + Err(anyhow!("Profile {p} is not defined in this application")) + } + } } /// App details @@ -279,7 +298,7 @@ pub struct Component { /// Example: `source = "bin/cart.wasm"` /// /// Learn more: https://spinframework.dev/writing-apps#the-component-source - pub source: ComponentSource, + pub(crate) source: ComponentSource, /// A human-readable description of the component. /// /// Example: `description = "Shopping cart"` @@ -298,7 +317,7 @@ pub struct Component { /// /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }` #[serde(default, skip_serializing_if = "Map::is_empty")] - pub environment: Map, + pub(crate) environment: Map, /// The files the component is allowed to read. Each list entry is either: /// /// - a glob pattern (e.g. "assets/**/*.jpg"); or @@ -378,7 +397,7 @@ pub struct Component { /// /// Learn more: https://spinframework.dev/build #[serde(default, skip_serializing_if = "Option::is_none")] - pub build: Option, + pub(crate) build: Option, /// Settings for custom tools or plugins. Spin ignores this field. #[serde(default, skip_serializing_if = "Map::is_empty")] #[schemars(schema_with = "json_schema::map_of_toml_tables")] @@ -394,7 +413,141 @@ pub struct Component { /// /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")] - pub dependencies: ComponentDependencies, + pub(crate) dependencies: ComponentDependencies, + /// Override values to use when building or running a named build profile. + /// + /// Example: `profile.debug.build.command = "npm run build-debug"` + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub(crate) profile: Map, +} + +/// Customisations for a Spin component in a non-default profile. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ComponentProfileOverride { + /// The file, package, or URL containing the component Wasm binary. + /// + /// Example: `source = "bin/debug/cart.wasm"` + /// + /// Learn more: https://spinframework.dev/writing-apps#the-component-source + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + + /// Environment variables for the Wasm module to be overridden in this profile. + /// Environment variables specified in the default profile will still be set + /// if not overridden here. + /// + /// `environment = { DB_URL = "mysql://spin:spin@localhost/dev" }` + #[serde(default, skip_serializing_if = "Map::is_empty")] + environment: Map, + + /// Wasm Component Model imports to be overridden in this profile. + /// Dependencies specified in the default profile will still be composed + /// if not overridden here. + /// + /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies + #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")] + dependencies: ComponentDependencies, + + /// The command or commands for building the component in non-default profiles. + /// If a component has no special build instructions for a profile, the + /// default build command is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + build: Option, +} + +/// Customisations for a Spin component build in a non-default profile. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ComponentProfileBuildOverride { + /// The command or commands to build the component in a named profile. If multiple commands + /// are specified, they are run sequentially from left to right. + /// + /// Example: `build.command = "cargo build"` + /// + /// Learn more: https://spinframework.dev/build#setting-up-for-spin-build + command: super::common::Commands, +} + +impl Component { + fn profile(&self, profile: Option>) -> Option<&ComponentProfileOverride> { + profile.and_then(|p| self.profile.get(p.as_ref())) + } + + /// The commands to execute for the build + pub fn build_commands(&self, profile: Option<&str>) -> Vec<&String> { + let profile_build = self.profile(profile).and_then(|o| o.build.as_ref()); + + match profile_build { + None => match &self.build { + Some(b) => b.command.as_vec(), + None => vec![], + }, + Some(b) => b.command.as_vec(), + } + } + + /// The build configuration for the component + pub fn build(&self, profile: Option<&str>) -> Option { + let build_config = self.build.clone(); + let build_profile = self.profile(profile).and_then(|o| o.build.as_ref()); + + let Some(build_profile) = build_profile else { + return build_config; + }; + + let Some(mut build_config) = build_config else { + return Some(ComponentBuildConfig { + command: build_profile.command.clone(), + workdir: None, + watch: vec![], + }); + }; + + build_config.command = build_profile.command.clone(); + + Some(build_config) + } + + /// The source for the component Wasm binary (e.g. local file, registry reference, etc.). + pub fn source(&self, profile: Option<&str>) -> &ComponentSource { + let profile_source = self.profile(profile).and_then(|o| o.source.as_ref()); + + match profile_source { + Some(s) => s, + None => &self.source, + } + } + + /// The component's environment variables + pub fn environment(&self, profile: Option<&str>) -> Map { + let mut environment = self.environment.clone(); + + let Some(overrides) = self.profile(profile).map(|o| o.environment.clone()) else { + return environment; + }; + + for (name, value) in overrides { + environment.insert(name, value); + } + + environment + } + + /// The component's dependencies + pub fn dependencies(&self, profile: Option<&str>) -> ComponentDependencies { + let mut dependencies = self.dependencies.clone(); + + let Some(overrides) = self.profile(profile).map(|o| o.dependencies.clone()) else { + return dependencies; + }; + + for (itf, dep) in overrides.inner { + dependencies.inner.insert(itf, dep); + } + + dependencies + } } /// Component dependencies @@ -765,6 +918,7 @@ mod tests { tool: Map::new(), dependencies_inherit_configuration: false, dependencies: Default::default(), + profile: Default::default(), } } @@ -957,4 +1111,213 @@ mod tests { .validate() .is_err()); } + + #[test] + fn profiles_override_source() { + let manifest = AppManifest::deserialize(toml! { + spin_manifest_version = 2 + [application] + name = "trigger-configs" + [[trigger.fake]] + component = "profile-test" + [component.profile-test] + source = "original" + [component.profile-test.profile.fancy] + source = "fancy-schmancy" + }) + .expect("manifest should be valid"); + + let id = KebabId::try_from("profile-test".to_owned()) + .expect("profile-test should have been kebab"); + let component = manifest + .components + .get(&id) + .expect("should have compopnent with id profile-test"); + + assert!(matches!(component.source(None), ComponentSource::Local(p) if p == "original")); + assert!( + matches!(component.source(Some("fancy")), ComponentSource::Local(p) if p == "fancy-schmancy") + ); + assert!( + matches!(component.source(Some("non-existent")), ComponentSource::Local(p) if p == "original") + ); + } + + #[test] + fn profiles_override_build_command() { + let manifest = AppManifest::deserialize(toml! { + spin_manifest_version = 2 + [application] + name = "trigger-configs" + [[trigger.fake]] + component = "profile-test" + [component.profile-test] + source = "original" + build.command = "buildme --release" + [component.profile-test.profile.fancy] + source = "fancy-schmancy" + build.command = ["buildme --fancy", "lintme"] + }) + .expect("manifest should be valid"); + + let id = KebabId::try_from("profile-test".to_owned()) + .expect("profile-test should have been kebab"); + let component = manifest + .components + .get(&id) + .expect("should have compopnent with id profile-test"); + + assert_eq!( + 1, + component + .build(None) + .expect("should have default build") + .commands() + .len() + ); + assert_eq!( + "buildme --release", + component + .build(None) + .expect("should have default build") + .commands()[0] + ); + + assert_eq!( + 2, + component + .build(Some("fancy")) + .expect("should have fancy build") + .commands() + .len() + ); + assert_eq!( + "buildme --fancy", + component + .build(Some("fancy")) + .expect("should have fancy build") + .commands()[0] + ); + assert_eq!( + "lintme", + component + .build(Some("fancy")) + .expect("should have fancy build") + .commands()[1] + ); + + assert_eq!( + 1, + component + .build(Some("non-existent")) + .expect("should fall back to default build") + .commands() + .len() + ); + assert_eq!( + "buildme --release", + component + .build(Some("non-existent")) + .expect("should fall back to default build") + .commands()[0] + ); + } + + #[test] + fn profiles_can_have_build_command_when_default_doesnt() { + let manifest = AppManifest::deserialize(toml! { + spin_manifest_version = 2 + [application] + name = "trigger-configs" + [[trigger.fake]] + component = "profile-test" + [component.profile-test] + source = "original" + [component.profile-test.profile.fancy] + source = "fancy-schmancy" + build.command = ["buildme --fancy", "lintme"] + }) + .expect("manifest should be valid"); + + let id = KebabId::try_from("profile-test".to_owned()) + .expect("profile-test should have been kebab"); + let component = manifest + .components + .get(&id) + .expect("should have compopnent with id profile-test"); + + assert!(component.build(None).is_none()); + + assert_eq!( + 2, + component + .build(Some("fancy")) + .expect("should have fancy build") + .commands() + .len() + ); + assert_eq!( + "buildme --fancy", + component + .build(Some("fancy")) + .expect("should have fancy build") + .commands()[0] + ); + assert_eq!( + "lintme", + component + .build(Some("fancy")) + .expect("should have fancy build") + .commands()[1] + ); + } + + #[test] + fn profiles_override_env_vars() { + let manifest = AppManifest::deserialize(toml! { + spin_manifest_version = 2 + [application] + name = "trigger-configs" + [[trigger.fake]] + component = "profile-test" + [component.profile-test] + source = "original" + environment = { DB_URL = "pg://production" } + [component.profile-test.profile.fancy] + environment = { DB_URL = "pg://fancy", FANCINESS = "1" } + }) + .expect("manifest should be valid"); + + let id = KebabId::try_from("profile-test".to_owned()) + .expect("profile-test should have been kebab"); + let component = manifest + .components + .get(&id) + .expect("should have compopnent with id profile-test"); + + assert_eq!(1, component.environment(None).len()); + assert_eq!( + "pg://production", + component + .environment(None) + .get("DB_URL") + .expect("DB_URL should have been set") + ); + + assert_eq!(2, component.environment(Some("fancy")).len()); + assert_eq!( + "pg://fancy", + component + .environment(Some("fancy")) + .get("DB_URL") + .expect("DB_URL should have been set") + ); + assert_eq!( + "1", + component + .environment(Some("fancy")) + .get("FANCINESS") + .expect("FANCINESS should have been set") + ); + } } diff --git a/crates/oci/src/client.rs b/crates/oci/src/client.rs index 7ec254bf46..24ef78b8da 100644 --- a/crates/oci/src/client.rs +++ b/crates/oci/src/client.rs @@ -128,6 +128,7 @@ impl Client { pub async fn push( &mut self, manifest_path: &Path, + profile: Option<&str>, reference: impl AsRef, annotations: Option>, infer_annotations: InferPredefinedAnnotations, @@ -146,6 +147,7 @@ impl Client { let locked = spin_loader::from_file( manifest_path, FilesMountStrategy::Copy(working_dir.path().into()), + profile, None, ) .await?; diff --git a/src/commands/build.rs b/src/commands/build.rs index 042dfbf337..1f03a35c9d 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -25,6 +25,11 @@ pub struct BuildCommand { )] pub app_source: Option, + /// The build profile to build. The default is the anonymous profile (usually + /// the release build). + #[clap(long)] + pub profile: Option, + /// Component ID to build. This can be specified multiple times. The default is all components. #[clap(short = 'c', long, multiple = true)] pub component_id: Vec, @@ -55,6 +60,7 @@ impl BuildCommand { spin_build::build( &manifest_file, + self.profile(), &self.component_id, self.target_checking(), None, @@ -83,4 +89,8 @@ impl BuildCommand { spin_build::TargetChecking::Check } } + + fn profile(&self) -> Option<&str> { + self.profile.as_deref() + } } diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 8370f4b1bb..ffd3be1805 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -40,6 +40,11 @@ pub struct Push { )] pub app_source: Option, + /// The build profile to push. The default is the anonymous profile (usually + /// the release build). + #[clap(long)] + pub profile: Option, + /// Ignore server certificate errors #[clap( name = INSECURE_OPT, @@ -84,9 +89,11 @@ impl Push { notify_if_nondefault_rel(&app_file, distance); if self.build { - spin_build::build_default(&app_file, self.cache_dir.clone()).await?; + spin_build::build_default(&app_file, self.profile(), self.cache_dir.clone()).await?; } + spin_build::warn_if_not_latest_build(&app_file, self.profile()); + let annotations = if self.annotations.is_empty() { None } else { @@ -106,6 +113,7 @@ impl Push { let digest = client .push( &app_file, + self.profile(), &self.reference, annotations, InferPredefinedAnnotations::All, @@ -119,6 +127,10 @@ impl Push { Ok(()) } + + fn profile(&self) -> Option<&str> { + self.profile.as_deref() + } } #[derive(Parser, Debug)] diff --git a/src/commands/up.rs b/src/commands/up.rs index d0d565dcf0..98429b47e2 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -79,6 +79,11 @@ pub struct UpCommand { )] pub registry_source: Option, + /// The build profile to run. The default is the anonymous profile (usually + /// the release build). + #[clap(long)] + pub profile: Option, + /// Ignore server certificate errors from a registry #[clap( name = INSECURE_OPT, @@ -169,6 +174,8 @@ impl UpCommand { .context("Could not canonicalize working directory")?; let resolved_app_source = self.resolve_app_source(&app_source, &working_dir).await?; + resolved_app_source.ensure_profile(self.profile())?; + if self.help { let trigger_cmds = trigger_commands_for_trigger_types(resolved_app_source.trigger_types()) @@ -191,8 +198,11 @@ impl UpCommand { } if self.build { - app_source.build(&self.cache_dir).await?; + app_source.build(self.profile(), &self.cache_dir).await?; } + + app_source.warn_if_not_latest_build(self.profile()); + let mut locked_app = self .load_resolved_app_source(resolved_app_source, &working_dir) .await @@ -493,14 +503,19 @@ impl UpCommand { } else { FilesMountStrategy::Copy(working_dir.join("assets")) }; - spin_loader::from_file(&manifest_path, files_mount_strategy, self.cache_dir.clone()) - .await - .with_context(|| { - format!( - "Failed to load manifest from {}", - quoted_path(&manifest_path) - ) - }) + spin_loader::from_file( + &manifest_path, + files_mount_strategy, + self.profile(), + self.cache_dir.clone(), + ) + .await + .with_context(|| { + format!( + "Failed to load manifest from {}", + quoted_path(&manifest_path) + ) + }) } ResolvedAppSource::OciRegistry { locked_app } => Ok(locked_app), ResolvedAppSource::BareWasm { wasm_path } => spin_loader::from_wasm_file(&wasm_path) @@ -548,6 +563,10 @@ impl UpCommand { groups } + + fn profile(&self) -> Option<&str> { + self.profile.as_deref() + } } fn is_flag_arg(arg: &OsString) -> bool { diff --git a/src/commands/up/app_source.rs b/src/commands/up/app_source.rs index dc5e0a179a..b619649fec 100644 --- a/src/commands/up/app_source.rs +++ b/src/commands/up/app_source.rs @@ -56,12 +56,22 @@ impl AppSource { } } - pub async fn build(&self, cache_root: &Option) -> anyhow::Result<()> { + pub async fn build( + &self, + profile: Option<&str>, + cache_root: &Option, + ) -> anyhow::Result<()> { match self { - Self::File(path) => spin_build::build_default(path, cache_root.clone()).await, + Self::File(path) => spin_build::build_default(path, profile, cache_root.clone()).await, _ => Ok(()), } } + + pub fn warn_if_not_latest_build(&self, profile: Option<&str>) { + if let Self::File(path) = self { + spin_build::warn_if_not_latest_build(path, profile); + } + } } fn is_wasm_file(path: &Path) -> bool { @@ -116,4 +126,11 @@ impl ResolvedAppSource { types.into_iter().collect() } + + pub fn ensure_profile(&self, profile: Option<&str>) -> anyhow::Result<()> { + match self { + Self::File { manifest, .. } => manifest.ensure_profile(profile), + _ => Ok(()), + } + } } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index a17f460fe0..e4dc25250e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -44,6 +44,11 @@ pub struct WatchCommand { )] pub app_source: Option, + /// The build profile to build and run. The default is the anonymous profile (usually + /// the release build). + #[clap(long)] + pub profile: Option, + /// Clear the screen before each run. #[clap( name = WATCH_CLEAR_OPT, @@ -112,6 +117,7 @@ impl WatchCommand { let mut buildifier = Buildifier { spin_bin: spin_bin.clone(), manifest: manifest_file.clone(), + profile: self.profile.clone(), clear_screen: self.clear, has_ever_built: false, watched_changes: source_code_rx, @@ -121,6 +127,7 @@ impl WatchCommand { let mut uppificator = Uppificator { spin_bin: spin_bin.clone(), manifest: manifest_file.clone(), + profile: self.profile.clone(), up_args: self.up_args.clone(), clear_screen: self.clear, watched_changes: artifact_rx, @@ -249,6 +256,7 @@ impl WatchCommand { let rtf = RuntimeConfigFactory { manifest_file: manifest_file.to_owned(), manifest_dir: manifest_dir.to_owned(), + profile: self.profile.clone(), filter_factory, notifier, impact_description, @@ -272,6 +280,7 @@ impl WatchCommand { pub struct RuntimeConfigFactory { manifest_file: PathBuf, manifest_dir: PathBuf, + profile: Option, filter_factory: Box, notifier: Arc>, impact_description: &'static str, @@ -284,7 +293,12 @@ impl RuntimeConfigFactory { let manifest = spin_manifest::manifest_from_str(&manifest_str)?; let filterer = self .filter_factory - .build_filter(&self.manifest_file, &self.manifest_dir, &manifest) + .build_filter( + &self.manifest_file, + &self.manifest_dir, + &manifest, + self.profile(), + ) .await?; let handler = NotifyOnFileChange::new(self.notifier.clone(), self.impact_description); @@ -296,6 +310,10 @@ impl RuntimeConfigFactory { rt.on_action(handler); Ok(rt) } + + fn profile(&self) -> Option<&str> { + self.profile.as_deref() + } } // This is the watchexec action handler that triggers the Uppificator diff --git a/src/commands/watch/buildifier.rs b/src/commands/watch/buildifier.rs index 74bd433d07..ec9fa93b38 100644 --- a/src/commands/watch/buildifier.rs +++ b/src/commands/watch/buildifier.rs @@ -7,6 +7,7 @@ use super::uppificator::Pause; pub(crate) struct Buildifier { pub spin_bin: PathBuf, pub manifest: PathBuf, + pub profile: Option, pub clear_screen: bool, pub has_ever_built: bool, pub watched_changes: tokio::sync::watch::Receiver, // TODO: refine which component(s) a change affects @@ -49,6 +50,9 @@ impl Buildifier { loop { let mut cmd = tokio::process::Command::new(&self.spin_bin); cmd.arg("build").arg("-f").arg(&self.manifest); + if let Some(profile) = &self.profile { + cmd.arg("--profile").arg(profile); + } let mut child = cmd.group_spawn()?; tokio::select! { diff --git a/src/commands/watch/filters.rs b/src/commands/watch/filters.rs index 970266697f..ccc5293f0c 100644 --- a/src/commands/watch/filters.rs +++ b/src/commands/watch/filters.rs @@ -16,6 +16,7 @@ pub(crate) trait FilterFactory: Send + Sync { manifest_file: &Path, manifest_dir: &Path, manifest: &v2::AppManifest, + profile: Option<&str>, ) -> anyhow::Result>; } @@ -34,6 +35,7 @@ impl FilterFactory for ArtifactFilterFactory { manifest_file: &Path, manifest_dir: &Path, manifest: &v2::AppManifest, + profile: Option<&str>, ) -> anyhow::Result> { let manifest_glob = if self.skip_build { vec![manifest_path_to_watch(manifest_file)?] @@ -43,7 +45,7 @@ impl FilterFactory for ArtifactFilterFactory { let wasm_globs = manifest .components .values() - .filter_map(|c| match &c.source { + .filter_map(|c| match c.source(profile) { v2::ComponentSource::Local(path) => Some(path.clone()), _ => None, }); @@ -88,6 +90,7 @@ impl FilterFactory for BuildFilterFactory { manifest_file: &Path, manifest_dir: &Path, manifest: &v2::AppManifest, + profile: Option<&str>, ) -> anyhow::Result> { let mut filterers: Vec> = Vec::with_capacity(manifest.components.len() + 1); @@ -98,7 +101,7 @@ impl FilterFactory for BuildFilterFactory { filterers.push(Box::new(manifest_filterer)); for (cid, c) in &manifest.components { - if let Some(build_globs) = create_source_globs(cid.as_ref(), c) { + if let Some(build_globs) = create_source_globs(cid.as_ref(), c, profile) { let build_filterer = globset_filter(manifest_dir, build_globs).await?; filterers.push(Box::new(build_filterer)); } @@ -110,8 +113,8 @@ impl FilterFactory for BuildFilterFactory { } } -fn create_source_globs(cid: &str, c: &v2::Component) -> Option> { - let build = c.build.as_ref()?; +fn create_source_globs(cid: &str, c: &v2::Component, profile: Option<&str>) -> Option> { + let build = c.build(profile)?; if build.watch.is_empty() { eprintln!( "You haven't configured what to watch for the component: '{cid}'. Learn how to configure Spin watch at https://developer.fermyon.com/common/cli-reference#watch" @@ -158,6 +161,7 @@ impl FilterFactory for ManifestFilterFactory { manifest_file: &Path, manifest_dir: &Path, _: &v2::AppManifest, + _: Option<&str>, ) -> anyhow::Result> { let manifest_glob = manifest_path_to_watch(manifest_file)?; diff --git a/src/commands/watch/uppificator.rs b/src/commands/watch/uppificator.rs index 2e22bc39e0..50494cef82 100644 --- a/src/commands/watch/uppificator.rs +++ b/src/commands/watch/uppificator.rs @@ -6,6 +6,7 @@ pub(crate) struct Uppificator { pub spin_bin: PathBuf, pub up_args: Vec, pub manifest: PathBuf, + pub profile: Option, pub clear_screen: bool, pub watched_changes: tokio::sync::watch::Receiver, pub pause_feed: tokio::sync::mpsc::Receiver, @@ -42,6 +43,9 @@ impl Uppificator { .arg("-f") .arg(&self.manifest) .args(&self.up_args); + if let Some(profile) = &self.profile { + cmd.arg("--profile").arg(profile); + } let mut child = match cmd.group_spawn() { Ok(ch) => ch, Err(e) => { diff --git a/tests/integration.rs b/tests/integration.rs index a2e358195c..29c9396819 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1159,7 +1159,8 @@ route = "/..." .components .iter() .map(|(id, component)| { - let spin_manifest::schema::v2::ComponentSource::Local(file) = &component.source + let spin_manifest::schema::v2::ComponentSource::Local(file) = + &component.source(None) else { panic!( "{}.{}: source is not a file reference", diff --git a/tests/testing-framework/src/runtimes/in_process_spin.rs b/tests/testing-framework/src/runtimes/in_process_spin.rs index 5387ecbdf7..6301c952eb 100644 --- a/tests/testing-framework/src/runtimes/in_process_spin.rs +++ b/tests/testing-framework/src/runtimes/in_process_spin.rs @@ -100,6 +100,7 @@ async fn initialize_trigger( env.path().join("spin.toml"), spin_loader::FilesMountStrategy::Direct, None, + None, ) .await?;