From b0d3edaa792c746d6449594978835be818161cde 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 | 76 +++++- crates/build/src/manifest.rs | 9 +- crates/factors-test/src/lib.rs | 2 +- crates/loader/src/lib.rs | 7 +- crates/loader/src/local.rs | 25 +- crates/loader/tests/ui.rs | 1 + crates/manifest/src/compat.rs | 1 + crates/manifest/src/normalize.rs | 45 +++- crates/manifest/src/schema/common.rs | 4 +- crates/manifest/src/schema/v2.rs | 235 ++++++++++++++++++ crates/manifest/tests/ui.rs | 2 +- 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 | 17 +- src/commands/watch/buildifier.rs | 4 + src/commands/watch/uppificator.rs | 4 + .../src/runtimes/in_process_spin.rs | 1 + 20 files changed, 484 insertions(+), 33 deletions(-) diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index 8ad3be2907..3a62f8f552 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. @@ -89,8 +95,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( @@ -215,6 +232,50 @@ 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); + + if profile != latest_build.as_deref() { + 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 +303,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 +311,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 +319,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(); @@ -273,7 +334,8 @@ mod tests { #[tokio::test] async fn has_meaningful_error_if_target_env_does_not_match() { let manifest_file = test_data_root().join("bad_target_env.toml"); - let manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap(); + let mut manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap(); + spin_manifest::normalize::normalize_manifest(&mut manifest, None); let application = spin_environments::ApplicationToValidate::new( manifest.clone(), manifest_file.parent().unwrap(), diff --git a/crates/build/src/manifest.rs b/crates/build/src/manifest.rs index db570e4145..f2fd7adec5 100644 --- a/crates/build/src/manifest.rs +++ b/crates/build/src/manifest.rs @@ -66,11 +66,16 @@ 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) => { - spin_manifest::normalize::normalize_manifest(&mut manifest); + manifest.ensure_profile(profile)?; + + spin_manifest::normalize::normalize_manifest(&mut manifest, profile); let components = build_configs_from_manifest(&manifest); let deployment_targets = deployment_targets_from_manifest(&manifest); Ok(ManifestBuildInfo::Loadable { 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,8 +48,8 @@ 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?; - loader.load_manifest(manifest).await + let loader = LocalLoader::new(&app_root, FilesMountStrategy::Direct, None, None).await?; + loader.load_manifest(manifest, None).await } /// The strategy to use for mounting WASI files into a guest. diff --git a/crates/loader/src/local.rs b/crates/loader/src/local.rs index abcd9015e6..e30f877097 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()), }) } @@ -59,7 +62,7 @@ impl LocalLoader { ) })?; let mut locked = self - .load_manifest(manifest) + .load_manifest(manifest, self.profile()) .await .with_context(|| format!("Failed to load Spin app from {}", quoted_path(path)))?; @@ -68,12 +71,23 @@ 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) } // Load the given manifest into a LockedApp, ready for execution. - pub(crate) async fn load_manifest(&self, mut manifest: AppManifest) -> Result { - spin_manifest::normalize::normalize_manifest(&mut manifest); + pub(crate) async fn load_manifest( + &self, + mut manifest: AppManifest, + profile: Option<&str>, + ) -> Result { + spin_manifest::normalize::normalize_manifest(&mut manifest, profile); manifest.validate_dependencies()?; @@ -538,6 +552,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 +943,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 eb88524167..9ef390762d 100644 --- a/crates/manifest/src/compat.rs +++ b/crates/manifest/src/compat.rs @@ -73,6 +73,7 @@ pub fn v1_to_v2_app(manifest: v1::AppManifestV1) -> Result) { normalize_trigger_ids(manifest); normalize_inline_components(manifest); + apply_profile_overrides(manifest, profile); } fn normalize_inline_components(manifest: &mut AppManifest) { @@ -103,3 +104,45 @@ fn normalize_trigger_ids(manifest: &mut AppManifest) { } } } + +fn apply_profile_overrides(manifest: &mut AppManifest, profile: Option<&str>) { + let Some(profile) = profile else { + return; + }; + + for (_, component) in &mut manifest.components { + let Some(overrides) = component.profile.get(profile) else { + continue; + }; + + if let Some(profile_build) = overrides.build.as_ref() { + match component.build.as_mut() { + None => { + component.build = Some(crate::schema::v2::ComponentBuildConfig { + command: profile_build.command.clone(), + workdir: None, + watch: vec![], + }) + } + Some(build) => { + build.command = profile_build.command.clone(); + } + } + } + + if let Some(source) = overrides.source.as_ref() { + component.source = source.clone(); + } + + for (name, value) in &overrides.environment { + component.environment.insert(name.clone(), value.clone()); + } + + for (reference, value) in &overrides.dependencies.inner { + component + .dependencies + .inner + .insert(reference.clone(), value.clone()); + } + } +} diff --git a/crates/manifest/src/schema/common.rs b/crates/manifest/src/schema/common.rs index 0c6127a2d9..8514430427 100644 --- a/crates/manifest/src/schema/common.rs +++ b/crates/manifest/src/schema/common.rs @@ -161,10 +161,10 @@ pub enum WasiFilesMount { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ComponentBuildConfig { - /// The command or commands to build the application. If multiple commands + /// The command or commands to build the component. If multiple commands /// are specified, they are run sequentially from left to right. /// - /// Example: `command = "cargo build"`, `command = ["npm install", "npm run build"]` + /// Example: `command = "cargo build --release"`, `command = ["npm install", "npm run build"]` /// /// Learn more: https://spinframework.dev/build#setting-up-for-spin-build pub command: Commands, diff --git a/crates/manifest/src/schema/v2.rs b/crates/manifest/src/schema/v2.rs index 238139ced0..f6b850adcf 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 @@ -395,6 +414,59 @@ pub struct Component { /// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies #[serde(default, skip_serializing_if = "ComponentDependencies::is_empty")] pub 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")] + pub(crate) 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")] + pub(crate) 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")] + pub(crate) 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")] + pub(crate) 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 + pub(crate) command: super::common::Commands, } /// Component dependencies @@ -765,6 +837,7 @@ mod tests { tool: Map::new(), dependencies_inherit_configuration: false, dependencies: Default::default(), + profile: Default::default(), } } @@ -957,4 +1030,166 @@ mod tests { .validate() .is_err()); } + + fn normalized_component( + manifest: &AppManifest, + component: &str, + profile: Option<&str>, + ) -> Component { + use crate::normalize::normalize_manifest; + + let id = + KebabId::try_from(component.to_owned()).expect("component ID should have been kebab"); + + let mut manifest = manifest.clone(); + normalize_manifest(&mut manifest, profile); + manifest + .components + .get(&id) + .expect("should have compopnent with id profile-test") + .clone() + } + + #[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 = "profile-test"; + + let component = normalized_component(&manifest, id, None); + assert!(matches!(&component.source, ComponentSource::Local(p) if p == "original")); + + let component = normalized_component(&manifest, id, Some("fancy")); + assert!(matches!(&component.source, ComponentSource::Local(p) if p == "fancy-schmancy")); + + let component = normalized_component(&manifest, id, Some("non-existent")); + assert!(matches!(&component.source, 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 = "profile-test"; + + let build = normalized_component(&manifest, id, None) + .build + .expect("should have default build"); + assert_eq!(1, build.commands().len()); + assert_eq!("buildme --release", build.commands().next().unwrap()); + + let build = normalized_component(&manifest, id, Some("fancy")) + .build + .expect("should have fancy build"); + assert_eq!(2, build.commands().len()); + assert_eq!("buildme --fancy", build.commands().next().unwrap()); + assert_eq!("lintme", build.commands().nth(1).unwrap()); + + let build = normalized_component(&manifest, id, Some("non-existent")) + .build + .expect("should fall back to default build"); + assert_eq!(1, build.commands().len()); + assert_eq!("buildme --release", build.commands().next().unwrap()); + } + + #[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 component = normalized_component(&manifest, "profile-test", None); + assert!(component.build.is_none(), "shouldn't have default build"); + + let component = normalized_component(&manifest, "profile-test", Some("fancy")); + assert!(component.build.is_some(), "should have fancy build"); + + let build = component.build.expect("should have fancy build"); + + assert_eq!(2, build.commands().len()); + assert_eq!("buildme --fancy", build.commands().next().unwrap()); + assert_eq!("lintme", build.commands().nth(1).unwrap()); + } + + #[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 = "profile-test"; + + let component = normalized_component(&manifest, id, None); + + assert_eq!(1, component.environment.len()); + assert_eq!( + "pg://production", + component + .environment + .get("DB_URL") + .expect("DB_URL should have been set") + ); + + let component = normalized_component(&manifest, id, Some("fancy")); + + assert_eq!(2, component.environment.len()); + assert_eq!( + "pg://fancy", + component + .environment + .get("DB_URL") + .expect("DB_URL should have been set") + ); + assert_eq!( + "1", + component + .environment + .get("FANCINESS") + .expect("FANCINESS should have been set") + ); + } } diff --git a/crates/manifest/tests/ui.rs b/crates/manifest/tests/ui.rs index 99b2504b8e..d02797bae3 100644 --- a/crates/manifest/tests/ui.rs +++ b/crates/manifest/tests/ui.rs @@ -45,6 +45,6 @@ fn run_v1_to_v2_test(input: &Path) -> Result { fn run_normalization_test(input: impl AsRef) -> Result { let mut manifest = spin_manifest::manifest_from_file(input)?; - normalize_manifest(&mut manifest); + normalize_manifest(&mut manifest, None); Ok(toml::to_string(&manifest).expect("serialization should work")) } 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 35dd3e286a..ff93e4a5bb 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)] 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 34eb8aad93..2ff64701eb 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 47c1763d5c..68ec4eba8b 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..40480fceb3 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, @@ -281,7 +290,9 @@ pub struct RuntimeConfigFactory { impl RuntimeConfigFactory { async fn build_config(&self) -> anyhow::Result { let manifest_str = tokio::fs::read_to_string(&self.manifest_file).await?; - let manifest = spin_manifest::manifest_from_str(&manifest_str)?; + let mut manifest = spin_manifest::manifest_from_str(&manifest_str)?; + spin_manifest::normalize::normalize_manifest(&mut manifest, self.profile()); + let filterer = self .filter_factory .build_filter(&self.manifest_file, &self.manifest_dir, &manifest) @@ -296,6 +307,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/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/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?;