Skip to content

Commit e33142a

Browse files
committed
Build profiles
Signed-off-by: itowlson <[email protected]>
1 parent 513b5d3 commit e33142a

File tree

20 files changed

+557
-36
lines changed

20 files changed

+557
-36
lines changed

crates/build/src/lib.rs

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ use subprocess::{Exec, Redirection};
1616

1717
use crate::manifest::component_build_configs;
1818

19+
const LAST_BUILD_PROFILE_FILE: &str = "last-build.txt";
20+
const LAST_BUILD_ANON_VALUE: &str = "<anonymous>";
21+
1922
/// If present, run the build command of each component.
2023
pub async fn build(
2124
manifest_file: &Path,
25+
profile: Option<&str>,
2226
component_ids: &[String],
2327
target_checks: TargetChecking,
2428
cache_root: Option<PathBuf>,
2529
) -> Result<()> {
26-
let build_info = component_build_configs(manifest_file)
30+
let build_info = component_build_configs(manifest_file, profile)
2731
.await
2832
.with_context(|| {
2933
format!(
@@ -54,6 +58,10 @@ pub async fn build(
5458
// If the build failed, exit with an error at this point.
5559
build_result?;
5660

61+
if let Err(e) = save_last_build_profile(&app_dir, profile) {
62+
tracing::warn!("Failed to save build profile: {e:?}");
63+
}
64+
5765
let Some(manifest) = build_info.manifest() else {
5866
// We can't proceed to checking (because that needs a full healthy manifest), and we've
5967
// already emitted any necessary warning, so quit.
@@ -90,8 +98,19 @@ pub async fn build(
9098
/// Run all component build commands, using the default options (build all
9199
/// components, perform target checking). We run a "default build" in several
92100
/// places and this centralises the logic of what such a "default build" means.
93-
pub async fn build_default(manifest_file: &Path, cache_root: Option<PathBuf>) -> Result<()> {
94-
build(manifest_file, &[], TargetChecking::Check, cache_root).await
101+
pub async fn build_default(
102+
manifest_file: &Path,
103+
profile: Option<&str>,
104+
cache_root: Option<PathBuf>,
105+
) -> Result<()> {
106+
build(
107+
manifest_file,
108+
profile,
109+
&[],
110+
TargetChecking::Check,
111+
cache_root,
112+
)
113+
.await
95114
}
96115

97116
fn build_components(
@@ -316,6 +335,69 @@ fn sort(components: Vec<ComponentBuildInfo>) -> (Vec<ComponentBuildInfo>, bool)
316335
}
317336
}
318337

338+
/// Saves the build profile to the "last build profile" file.
339+
pub fn save_last_build_profile(app_dir: &Path, profile: Option<&str>) -> anyhow::Result<()> {
340+
let app_stash_dir = app_dir.join(".spin");
341+
let last_build_profile_file = app_stash_dir.join(LAST_BUILD_PROFILE_FILE);
342+
343+
// This way, if the user never uses build profiles, they won't see a
344+
// weird savefile that they have no idea what it is.
345+
if profile.is_none() && !last_build_profile_file.exists() {
346+
return Ok(());
347+
}
348+
349+
std::fs::create_dir_all(&app_stash_dir)?;
350+
std::fs::write(
351+
&last_build_profile_file,
352+
profile.unwrap_or(LAST_BUILD_ANON_VALUE),
353+
)?;
354+
355+
Ok(())
356+
}
357+
358+
/// Reads the last build profile from the "last build profile" file.
359+
pub fn read_last_build_profile(app_dir: &Path) -> anyhow::Result<Option<String>> {
360+
let app_stash_dir = app_dir.join(".spin");
361+
let last_build_profile_file = app_stash_dir.join(LAST_BUILD_PROFILE_FILE);
362+
if !last_build_profile_file.exists() {
363+
return Ok(None);
364+
}
365+
366+
let last_build_str = std::fs::read_to_string(&last_build_profile_file)?;
367+
368+
if last_build_str == LAST_BUILD_ANON_VALUE {
369+
Ok(None)
370+
} else {
371+
Ok(Some(last_build_str))
372+
}
373+
}
374+
375+
/// Prints a warning to stderr if the given profile is not the same
376+
/// as the most recent build in the given application directory.
377+
pub fn warn_if_not_latest_build(manifest_path: &Path, profile: Option<&str>) {
378+
let Some(app_dir) = manifest_path.parent() else {
379+
return;
380+
};
381+
382+
let latest_build = match read_last_build_profile(app_dir) {
383+
Ok(profile) => profile,
384+
Err(e) => {
385+
tracing::warn!(
386+
"Failed to read last build profile: using anonymous profile. Error was {e:?}"
387+
);
388+
None
389+
}
390+
};
391+
392+
if profile != latest_build.as_deref() {
393+
let profile_opt = match profile {
394+
Some(p) => format!(" --profile {p}"),
395+
None => "".to_string(),
396+
};
397+
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}`.");
398+
}
399+
}
400+
319401
/// Specifies target environment checking behaviour
320402
pub enum TargetChecking {
321403
/// The build should check that all components are compatible with all target environments.
@@ -343,23 +425,23 @@ mod tests {
343425
#[tokio::test]
344426
async fn can_load_even_if_trigger_invalid() {
345427
let bad_trigger_file = test_data_root().join("bad_trigger.toml");
346-
build(&bad_trigger_file, &[], TargetChecking::Skip, None)
428+
build(&bad_trigger_file, None, &[], TargetChecking::Skip, None)
347429
.await
348430
.unwrap();
349431
}
350432

351433
#[tokio::test]
352434
async fn succeeds_if_target_env_matches() {
353435
let manifest_path = test_data_root().join("good_target_env.toml");
354-
build(&manifest_path, &[], TargetChecking::Check, None)
436+
build(&manifest_path, None, &[], TargetChecking::Check, None)
355437
.await
356438
.unwrap();
357439
}
358440

359441
#[tokio::test]
360442
async fn fails_if_target_env_does_not_match() {
361443
let manifest_path = test_data_root().join("bad_target_env.toml");
362-
let err = build(&manifest_path, &[], TargetChecking::Check, None)
444+
let err = build(&manifest_path, None, &[], TargetChecking::Check, None)
363445
.await
364446
.expect_err("should have failed")
365447
.to_string();
@@ -374,7 +456,8 @@ mod tests {
374456
#[tokio::test]
375457
async fn has_meaningful_error_if_target_env_does_not_match() {
376458
let manifest_file = test_data_root().join("bad_target_env.toml");
377-
let manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap();
459+
let mut manifest = spin_manifest::manifest_from_file(&manifest_file).unwrap();
460+
spin_manifest::normalize::normalize_manifest(&mut manifest, None).unwrap();
378461
let application = spin_environments::ApplicationToValidate::new(
379462
manifest.clone(),
380463
manifest_file.parent().unwrap(),

crates/build/src/manifest.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,16 @@ impl ManifestBuildInfo {
6666
/// given (v1 or v2) manifest path. If the manifest cannot be loaded, the
6767
/// function attempts fallback: if fallback succeeds, result is Ok but the load error
6868
/// is also returned via the second part of the return value tuple.
69-
pub async fn component_build_configs(manifest_file: impl AsRef<Path>) -> Result<ManifestBuildInfo> {
69+
pub async fn component_build_configs(
70+
manifest_file: impl AsRef<Path>,
71+
profile: Option<&str>,
72+
) -> Result<ManifestBuildInfo> {
7073
let manifest = spin_manifest::manifest_from_file(&manifest_file);
7174
match manifest {
7275
Ok(mut manifest) => {
73-
spin_manifest::normalize::normalize_manifest(&mut manifest)?;
76+
manifest.ensure_profile(profile)?;
77+
78+
spin_manifest::normalize::normalize_manifest(&mut manifest, profile)?;
7479
let components = build_configs_from_manifest(&manifest);
7580
let deployment_targets = deployment_targets_from_manifest(&manifest);
7681
Ok(ManifestBuildInfo::Loadable {

crates/factors-test/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,5 @@ pub async fn build_locked_app(manifest: &toml::Table) -> anyhow::Result<LockedAp
102102
let dir = tempfile::tempdir().context("failed creating tempdir")?;
103103
let path = dir.path().join("spin.toml");
104104
std::fs::write(&path, toml_str).context("failed writing manifest")?;
105-
spin_loader::from_file(&path, FilesMountStrategy::Direct, None).await
105+
spin_loader::from_file(&path, FilesMountStrategy::Direct, None, None).await
106106
}

crates/loader/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,21 @@ pub(crate) const MAX_FILE_LOADING_CONCURRENCY: usize = 16;
3535
pub async fn from_file(
3636
manifest_path: impl AsRef<Path>,
3737
files_mount_strategy: FilesMountStrategy,
38+
profile: Option<&str>,
3839
cache_root: Option<PathBuf>,
3940
) -> Result<LockedApp> {
4041
let path = manifest_path.as_ref();
4142
let app_root = parent_dir(path).context("manifest path has no parent directory")?;
42-
let loader = LocalLoader::new(&app_root, files_mount_strategy, cache_root).await?;
43+
let loader = LocalLoader::new(&app_root, files_mount_strategy, profile, cache_root).await?;
4344
loader.load_file(path).await
4445
}
4546

4647
/// Load a Spin locked app from a standalone Wasm file.
4748
pub async fn from_wasm_file(wasm_path: impl AsRef<Path>) -> Result<LockedApp> {
4849
let app_root = std::env::current_dir()?;
4950
let manifest = single_file_manifest(wasm_path)?;
50-
let loader = LocalLoader::new(&app_root, FilesMountStrategy::Direct, None).await?;
51-
loader.load_manifest(manifest).await
51+
let loader = LocalLoader::new(&app_root, FilesMountStrategy::Direct, None, None).await?;
52+
loader.load_manifest(manifest, None).await
5253
}
5354

5455
/// The strategy to use for mounting WASI files into a guest.

crates/loader/src/local.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ pub struct LocalLoader {
2626
files_mount_strategy: FilesMountStrategy,
2727
file_loading_permits: std::sync::Arc<Semaphore>,
2828
wasm_loader: WasmLoader,
29+
profile: Option<String>,
2930
}
3031

3132
impl LocalLoader {
3233
pub async fn new(
3334
app_root: &Path,
3435
files_mount_strategy: FilesMountStrategy,
36+
profile: Option<&str>,
3537
cache_root: Option<PathBuf>,
3638
) -> Result<Self> {
3739
let app_root = safe_canonicalize(app_root)
@@ -44,6 +46,7 @@ impl LocalLoader {
4446
// Limit concurrency to avoid hitting system resource limits
4547
file_loading_permits: file_loading_permits.clone(),
4648
wasm_loader: WasmLoader::new(app_root, cache_root, Some(file_loading_permits)).await?,
49+
profile: profile.map(|s| s.to_owned()),
4750
})
4851
}
4952

@@ -59,7 +62,7 @@ impl LocalLoader {
5962
)
6063
})?;
6164
let mut locked = self
62-
.load_manifest(manifest)
65+
.load_manifest(manifest, self.profile())
6366
.await
6467
.with_context(|| format!("Failed to load Spin app from {}", quoted_path(path)))?;
6568

@@ -68,12 +71,23 @@ impl LocalLoader {
6871
.metadata
6972
.insert("origin".into(), file_url(path)?.into());
7073

74+
// Set build profile metadata
75+
if let Some(profile) = self.profile.as_ref() {
76+
locked
77+
.metadata
78+
.insert("profile".into(), profile.as_str().into());
79+
}
80+
7181
Ok(locked)
7282
}
7383

7484
// Load the given manifest into a LockedApp, ready for execution.
75-
pub(crate) async fn load_manifest(&self, mut manifest: AppManifest) -> Result<LockedApp> {
76-
spin_manifest::normalize::normalize_manifest(&mut manifest)?;
85+
pub(crate) async fn load_manifest(
86+
&self,
87+
mut manifest: AppManifest,
88+
profile: Option<&str>,
89+
) -> Result<LockedApp> {
90+
spin_manifest::normalize::normalize_manifest(&mut manifest, profile)?;
7791

7892
manifest.validate_dependencies()?;
7993

@@ -538,6 +552,10 @@ impl LocalLoader {
538552
path: dest.into(),
539553
})
540554
}
555+
556+
fn profile(&self) -> Option<&str> {
557+
self.profile.as_deref()
558+
}
541559
}
542560

543561
fn explain_file_mount_source_error(e: anyhow::Error, src: &Path) -> anyhow::Error {
@@ -928,6 +946,7 @@ mod test {
928946
&app_root,
929947
FilesMountStrategy::Copy(wd.path().to_owned()),
930948
None,
949+
None,
931950
)
932951
.await?;
933952
let err = loader

crates/loader/tests/ui.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ fn run_test(input: &Path, normalizer: &mut Normalizer) -> Result<String, Failed>
5050
input,
5151
spin_loader::FilesMountStrategy::Copy(files_mount_root),
5252
None,
53+
None,
5354
)
5455
.await
5556
.map_err(|err| format!("{err:?}"))?;

crates/manifest/src/compat.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub fn v1_to_v2_app(manifest: v1::AppManifestV1) -> Result<v2::AppManifest, Erro
7373
allowed_http_hosts: Vec::new(),
7474
dependencies_inherit_configuration: false,
7575
dependencies: Default::default(),
76+
profile: Default::default(),
7677
},
7778
);
7879
triggers

crates/manifest/src/normalize.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ use anyhow::Context;
99
/// - Inline components in trigger configs are moved into top-level
1010
/// components and replaced with a reference.
1111
/// - Any triggers without an ID are assigned a generated ID.
12-
pub fn normalize_manifest(manifest: &mut AppManifest) -> anyhow::Result<()> {
12+
pub fn normalize_manifest(manifest: &mut AppManifest, profile: Option<&str>) -> anyhow::Result<()> {
1313
normalize_trigger_ids(manifest);
1414
normalize_inline_components(manifest);
15+
apply_profile_overrides(manifest, profile);
1516
normalize_dependency_component_refs(manifest)?;
1617
Ok(())
1718
}
@@ -107,6 +108,44 @@ fn normalize_trigger_ids(manifest: &mut AppManifest) {
107108
}
108109
}
109110

111+
fn apply_profile_overrides(manifest: &mut AppManifest, profile: Option<&str>) {
112+
let Some(profile) = profile else {
113+
return;
114+
};
115+
116+
for (_, component) in &mut manifest.components {
117+
let Some(overrides) = component.profile.get(profile) else {
118+
continue;
119+
};
120+
121+
if let Some(profile_build) = overrides.build.as_ref() {
122+
match component.build.as_mut() {
123+
None => {
124+
component.build = Some(crate::schema::v2::ComponentBuildConfig {
125+
command: profile_build.command.clone(),
126+
workdir: None,
127+
watch: vec![],
128+
})
129+
}
130+
Some(build) => {
131+
build.command = profile_build.command.clone();
132+
}
133+
}
134+
}
135+
136+
if let Some(source) = overrides.source.as_ref() {
137+
component.source = source.clone();
138+
}
139+
140+
component.environment.extend(overrides.environment.clone());
141+
142+
component
143+
.dependencies
144+
.inner
145+
.extend(overrides.dependencies.inner.clone());
146+
}
147+
}
148+
110149
use crate::schema::v2::{Component, ComponentDependency, ComponentSource};
111150

112151
fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Result<()> {
@@ -191,6 +230,7 @@ fn ensure_is_acceptable_dependency(
191230
tool: _,
192231
dependencies_inherit_configuration: _,
193232
dependencies,
233+
profile: _,
194234
} = component;
195235

196236
if !ai_models.is_empty() {
@@ -261,7 +301,7 @@ mod test {
261301
})
262302
.unwrap();
263303

264-
normalize_manifest(&mut manifest).unwrap();
304+
normalize_manifest(&mut manifest, None).unwrap();
265305

266306
let dep = manifest
267307
.components
@@ -301,7 +341,7 @@ mod test {
301341
})
302342
.unwrap();
303343

304-
normalize_manifest(&mut manifest).unwrap();
344+
normalize_manifest(&mut manifest, None).unwrap();
305345

306346
let dep = manifest
307347
.components
@@ -347,7 +387,7 @@ mod test {
347387
})
348388
.unwrap();
349389

350-
normalize_manifest(&mut manifest).unwrap();
390+
normalize_manifest(&mut manifest, None).unwrap();
351391

352392
let dep = manifest
353393
.components

0 commit comments

Comments
 (0)