From 96866848f783cf4ddb9c6ec69804433f61c50a2b Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 23 Sep 2026 13:34:20 +0300 Subject: [PATCH] feat(contract): let the author declare which volumes survive a bake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bake drops a stack's volumes so a buyer's machine initialises them with its own credentials. Some must survive that: Ollama weights are gigabytes behind a 600s pull, Qdrant embeddings cost as much to recompute — preserving them is why the snapshot exists. Until now the exceptions were a `match` on the stack slug inside `bake_finalize.rs`. Four stacks were listed, floci and the rest were queued, and each one needed a code change and a rebuilt binary. The author knows which of their volumes are expensive and which hold credentials; the platform does not. So it goes in `config_contract`, beside the fields, reusing everything already built for them: the `mutability` vocabulary, and the path from stacker.yml through the submit body to `stack_template_version.config_contract` and on to `bake.rs`, which resolves it before finalize runs. config_contract: services: stackpilot-ollama: volumes: stackpilot_ollama: { mutability: fixed } `fixed` ships the content inside the image; `generated` — the default for anything undeclared — drops it. Erring that way costs a rebuild; the opposite default would hand the author's credentials to every buyer. `provided` and `editable` describe who types a value and are rejected: a volume holds state, not a value. The platform validates only the name, which is interpolated into a shell pattern. It deliberately does not second-guess the declaration. An earlier revision of this change did. It refused any volume whose service declares `generated` or `provided` fields, reasoning that such a service persists the secret. Measuring real containers killed that rule: a Postgres data directory holds `SCRAM-SHA-256$4096:…` and not the password in any searchable form; n8n keeps its own encryption key inside `database.sqlite`; a Qdrant volume holds only collections, because Qdrant reads its API key from the environment at every start. The secret is absent from all three — so neither the field-based rule nor a search of the volume's bytes tells the two that must reset from the one that must be kept. The difference is behavioural, and only the author can see it. Left in, the rule would have forced ai-knowledge-base to recompute its embeddings on every buyer's machine: the exact expense a snapshot avoids. FinalizeContext now carries the parsed contract rather than a flattened key set, since the kind-per-service structure is what the volume policy needs. Co-Authored-By: Claude Opus 5 --- docs/STACKER_YML_REFERENCE.md | 40 +++++ src/bin/bake.rs | 10 +- src/cli/config_parser.rs | 198 ++++++++++++++++++++++- src/helpers/bake_finalize.rs | 193 +++++++++++++++++++--- tests/features/bake_sanitization.feature | 35 ++++ tests/steps/bake_sanitization.rs | 95 +++++++++++ 6 files changed, 542 insertions(+), 29 deletions(-) diff --git a/docs/STACKER_YML_REFERENCE.md b/docs/STACKER_YML_REFERENCE.md index 788f4e30..ac7212b1 100644 --- a/docs/STACKER_YML_REFERENCE.md +++ b/docs/STACKER_YML_REFERENCE.md @@ -847,6 +847,46 @@ environments: --- +### Volume policy in `config_contract` + +A baked marketplace image is cloned for every buyer, and a volume that travels +inside it arrives identical for all of them. Declare which ones should: + +```yaml +config_contract: + services: + stackpilot-ollama: + volumes: + stackpilot_ollama: { mutability: fixed } +``` + +`fixed` — the content ships inside the image. `generated` — the volume is dropped +before the snapshot so the buyer's machine initialises it from scratch. **An +undeclared volume behaves as `generated`**: forgetting a declaration costs a +rebuild, whereas the opposite default would hand the author's credentials to +every buyer. + +`provided` and `editable` describe who types a *value*; a volume holds state and +has no value to type, so both are rejected. + +**Declare `fixed` only for volumes holding data the service does not derive from +a secret** — model weights, embeddings, a content cache. The distinction is not +whether the service *has* a secret but whether it *persists* something built from +one: + +| Service | Volume holds | Declare | +|---|---|---| +| Ollama | model weights | `fixed` | +| Qdrant | collections; the API key is read from the environment at every start | `fixed` | +| Postgres | the role password as `SCRAM-SHA-256$4096:…` | `generated` | +| n8n | its own encryption key inside `database.sqlite` | `generated` | + +Nothing distinguishes these automatically: the secret is not present verbatim in +any of the four volumes, so searching for it finds nothing in the safe and the +unsafe case alike. The author knows how their service treats the secret; the +platform cannot compute it. Get this wrong in the unsafe direction and every +buyer inherits the author's credential. + ## `volumes` *Optional* · `map` · Default: `{}` diff --git a/src/bin/bake.rs b/src/bin/bake.rs index 17558565..b33d2cc3 100644 --- a/src/bin/bake.rs +++ b/src/bin/bake.rs @@ -134,6 +134,14 @@ async fn main() -> Result<(), Box> { .map(stacker::helpers::bake_finalize::protected_keys_from_contract) .unwrap_or_default(); + // Parsed form: the finalize step needs the service a field belongs to, which + // the flat key set above has thrown away. An unparseable contract is treated + // as absent — `check_contract_usable` below then refuses the bake. + let parsed_contract: stacker::cli::config_parser::ConfigContract = config_contract + .clone() + .and_then(|c| serde_json::from_value(c).ok()) + .unwrap_or_default(); + // Refuse before touching the box: with no contract there is nothing to // sanitize, and publishing anyway is how the author's credentials reach // every buyer. @@ -157,7 +165,7 @@ async fn main() -> Result<(), Box> { private_key_pem, project_dir: project_dir.clone(), stack: stack.clone(), - protected_keys: protected_keys.clone(), + contract: parsed_contract.clone(), }; let outcome = stacker::helpers::bake_finalize::finalize_build_box(&ctx).await?; eprintln!( diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index f871ffa6..4b3a503a 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -1100,6 +1100,61 @@ impl FieldPolicy { } } +/// Declared policy for one `config_contract.services..volumes.` +/// entry — the second kind a service block can carry, after `fields`. +/// +/// A volume holds *state*, not a value, so only two of the four mutabilities +/// mean anything: +/// +/// * `fixed` — the author's content ships inside the image and is identical for +/// every buyer. Correct for expensive, credential-free content: model weights, +/// embeddings. +/// * `generated` — the volume is dropped before the snapshot, so the buyer's +/// machine initialises it from scratch with the buyer's own values. +/// +/// `provided` and `editable` describe who *types* a value; there is nothing to +/// type here, and accepting them would leave the bake guessing. They are +/// rejected at parse time. +/// +/// An undeclared volume behaves as `generated`. The error direction is +/// deliberate: forgetting a declaration costs a rebuild, while the opposite +/// default would hand the author's credentials to every buyer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct VolumePolicy { + pub mutability: Mutability, +} + +impl<'de> Deserialize<'de> for VolumePolicy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Raw { + mutability: Mutability, + } + + let raw = Raw::deserialize(deserializer)?; + match raw.mutability { + Mutability::Fixed | Mutability::Generated => Ok(VolumePolicy { + mutability: raw.mutability, + }), + other => Err(serde::de::Error::custom(format!( + "`mutability: {}` is not meaningful for a volume — a volume holds \ + state, not a value somebody types. Use `fixed` to ship the \ + author's content in the image, or `generated` to have the buyer's \ + machine create it from scratch.", + match other { + Mutability::Provided => "provided", + Mutability::Editable => "editable", + _ => unreachable!("fixed and generated are handled above"), + } + ))), + } + } +} + /// Per-service field policy declarations. /// /// Backed by a single `fields: HashMap`. Accepts and @@ -1110,6 +1165,9 @@ impl FieldPolicy { #[derive(Debug, Clone, Default, PartialEq)] pub struct TargetConfigContract { pub fields: HashMap, + /// Volumes this service owns, and whether each survives the bake. + /// Absent means every volume resets — see [`VolumePolicy`]. + pub volumes: HashMap, } impl TargetConfigContract { @@ -1139,7 +1197,11 @@ impl TargetConfigContract { .entry(key) .or_insert_with(|| FieldPolicy::fixed(false)); } - TargetConfigContract { fields } + // The legacy three-list shape predates volumes and never declared any. + TargetConfigContract { + fields, + volumes: HashMap::new(), + } } fn keys_where(&self, predicate: impl Fn(&FieldPolicy) -> bool) -> Vec { @@ -1183,6 +1245,17 @@ impl TargetConfigContract { pub fn editable_keys(&self) -> Vec { self.keys_where(|p| p.mutability == Mutability::Editable) } + + /// Volumes declared `mutability: fixed` — the ones whose content survives + /// into the image. Everything else, declared or not, is dropped before the + /// snapshot so the buyer's machine starts it clean. + pub fn fixed_volumes(&self) -> Vec { + self.volumes + .iter() + .filter(|(_, policy)| policy.mutability == Mutability::Fixed) + .map(|(name, _)| name.clone()) + .collect() + } } #[derive(Deserialize, Default)] @@ -1192,6 +1265,7 @@ struct RawTargetConfigContract { optional: Vec, secret: Vec, fields: HashMap, + volumes: HashMap, } impl<'de> Deserialize<'de> for TargetConfigContract { @@ -1221,7 +1295,10 @@ impl<'de> Deserialize<'de> for TargetConfigContract { .or_insert_with(|| FieldPolicy::fixed(false)); } - Ok(TargetConfigContract { fields }) + Ok(TargetConfigContract { + fields, + volumes: raw.volumes, + }) } } @@ -2609,6 +2686,123 @@ config_contract: assert!(format!("{err}").contains("derived_jwt")); } + // ── volume policy ────────────────────────────────────────────────────── + + /// A volume is the second kind a service block can declare, after `fields`. + /// `fixed` means the author's content ships in the image as-is; `generated` + /// means the buyer's machine creates it from scratch. + #[test] + fn volume_policy_parses_fixed_and_generated() { + let yaml = r#" +name: stackpilot +config_contract: + services: + stackpilot-ollama: + volumes: + stackpilot_ollama: + mutability: fixed + stackpilot-db: + fields: + POSTGRES_PASSWORD: + mutability: generated + type: alphanumeric + volumes: + stackpilot_pgdata: + mutability: generated +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + let ollama = &config.config_contract.services["stackpilot-ollama"]; + assert_eq!( + ollama.volumes["stackpilot_ollama"].mutability, + Mutability::Fixed + ); + + let db = &config.config_contract.services["stackpilot-db"]; + assert_eq!( + db.volumes["stackpilot_pgdata"].mutability, + Mutability::Generated + ); + // Fields and volumes coexist in one service block. + assert_eq!( + db.fields["POSTGRES_PASSWORD"].mutability, + Mutability::Generated + ); + } + + /// `fixed_volumes()` is what the bake asks for: the volumes that survive. + #[test] + fn fixed_volumes_lists_only_the_ones_that_survive() { + let yaml = r#" +name: kb +config_contract: + services: + worker: + volumes: + kb_ollama: { mutability: fixed } + kb_qdrant: { mutability: fixed } + kb_pgdata: { mutability: generated } +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + let mut kept = config.config_contract.services["worker"].fixed_volumes(); + kept.sort(); + assert_eq!(kept, vec!["kb_ollama".to_string(), "kb_qdrant".to_string()]); + } + + /// `provided` and `editable` describe who types a *value*; a volume has no + /// value to type. Accepting them would leave the bake guessing. + #[test] + fn volume_policy_rejects_mutabilities_that_make_no_sense_for_state() { + for mutability in ["provided", "editable"] { + let yaml = format!( + r#" +name: s +config_contract: + services: + app: + volumes: + app_data: + mutability: {mutability} +"# + ); + assert!( + StackerConfig::from_str(&yaml).is_err(), + "`mutability: {mutability}` is meaningless for a volume and must be rejected" + ); + } + } + + /// An undeclared volume resets. Losing rebuildable content costs time; + /// keeping a credential-bearing one leaks the author's secrets. + #[test] + fn a_service_with_no_volume_block_keeps_nothing() { + let yaml = r#" +name: s +config_contract: + services: + app: + fields: + SECRET_KEY: { mutability: generated, type: hex } +"#; + let config = StackerConfig::from_str(yaml).unwrap(); + assert!(config.config_contract.services["app"] + .fixed_volumes() + .is_empty()); + } + + /// The service block is a closed set of kinds — a typo must not be ignored. + #[test] + fn an_unknown_kind_in_a_service_block_is_rejected() { + let yaml = r#" +name: s +config_contract: + services: + app: + volumez: + app_data: { mutability: fixed } +"#; + assert!(StackerConfig::from_str(yaml).is_err()); + } + #[test] fn field_policy_unknown_mutability_is_rejected() { let yaml = r#" diff --git a/src/helpers/bake_finalize.rs b/src/helpers/bake_finalize.rs index a2a08c27..b7188400 100644 --- a/src/helpers/bake_finalize.rs +++ b/src/helpers/bake_finalize.rs @@ -25,21 +25,64 @@ use std::collections::BTreeSet; -/// Volumes whose content must survive the bake, keyed by stack slug. +/// Volumes the author declared `mutability: fixed` — the ones whose content +/// travels into the image. /// -/// Defaulting to *reset* is deliberate: wrongly resetting a volume costs a -/// rebuild of cheap state, while wrongly keeping one leaks the author's -/// credentials to every buyer. Only volumes that are expensive to rebuild -/// **and** carry no credentials belong here. +/// Everything else resets, declared or not. The error direction is deliberate: +/// forgetting a declaration costs a rebuild of cheap state, while keeping a +/// credential-bearing volume hands the author's secrets to every buyer. /// -/// `stackpilot`'s Ollama volume holds the pulled model weights — gigabytes, -/// with a 600s pull timeout in `scripts/download-model.sh`. Preserving it is -/// the entire economic point of baking that stack. -pub fn volumes_to_keep(stack: &str) -> &'static [&'static str] { - match stack { - "stackpilot" => &["ollama"], - _ => &[], +/// This used to be a `match` on the stack slug in this file. The author knows +/// which of their volumes are expensive and which hold credentials; the platform +/// does not, and a hardcoded list needed a code change and a rebuilt binary for +/// every new stack. +pub fn volumes_to_keep(contract: &crate::cli::config_parser::ConfigContract) -> Vec { + contract + .services + .values() + .flat_map(|service| service.fixed_volumes()) + .collect() +} + +/// Validate the author's volume declarations. +/// +/// Only what a machine can actually establish is checked here: that the name is +/// safe to interpolate into a shell `case` pattern. Whether a volume is *safe to +/// keep* is the author's call, and deliberately so. +/// +/// An earlier version refused any volume belonging to a service that declares +/// `generated` or `provided` fields, on the theory that such a service wrote the +/// secret into its own data. Measurement killed that rule: a Postgres data +/// directory holds `SCRAM-SHA-256$4096:…`, a hash — the password appears nowhere, +/// literally or base64-encoded; n8n keeps its own encryption key in +/// `database.sqlite`; and a Qdrant volume holds nothing but collections, because +/// Qdrant reads its API key from the environment on every start. Searching the +/// volume for the secret finds nothing in any of the three, so that cannot +/// distinguish them either. +/// +/// The real difference is behavioural — whether the service derives persistent +/// state from the secret — and it is not visible in the volume's bytes. The +/// author knows it; the platform cannot compute it. So the platform checks what +/// it can and trusts the author with the rest, the same way `config_contract` +/// trusts the author about which fields are sensitive. +pub fn check_volume_declarations( + contract: &crate::cli::config_parser::ConfigContract, +) -> Result<(), crate::helpers::bake::BakeError> { + for (service_name, service) in &contract.services { + for volume in service.fixed_volumes() { + if !is_plain_volume_name(&volume) { + return Err(crate::helpers::bake::BakeError::Finalize(format!( + "volume `{volume}` on service `{service_name}` contains characters \ + that are shell pattern syntax. It would match something other than \ + intended, and keeping a volume that should have been reset leaves \ + the author's credentials in the image. Use only letters, digits, \ + `_`, `-` and `.`." + ))); + } + } } + + Ok(()) } /// Values a service would lose when the buyer's machine replaces the env file. @@ -307,8 +350,10 @@ pub struct FinalizeContext { pub project_dir: String, /// Stack slug — selects the volume keep-list. pub stack: String, - /// Contract fields with `mutability: generated`/`provided`. - pub protected_keys: BTreeSet, + /// The author's field policy, parsed. Kept whole rather than flattened: the + /// volume check needs to know *which service* declares a protected field, + /// and flattening loses exactly that. + pub contract: crate::cli::config_parser::ConfigContract, } /// What the finalize step learned about the image it just sanitized. @@ -360,6 +405,11 @@ pub async fn finalize_build_box( } }; + // The flat set is still what the scrub and the parameterizer want. + let protected_keys = protected_keys_from_contract( + &serde_json::to_value(&ctx.contract).unwrap_or(serde_json::Value::Null), + ); + let compose_path = format!("{}/docker-compose.yml", ctx.project_dir); let env_path = format!("{}/.env", ctx.project_dir); @@ -367,6 +417,13 @@ pub async fn finalize_build_box( // already happened — see `recovery_advice`. let mut done: Vec = Vec::new(); + // Refuse a declaration that would ship the author's credentials, before + // anything on the box is touched. + if let Err(err) = check_volume_declarations(&ctx.contract) { + disconnect_ssh(session).await; + return Err(err); + } + let result = async { // 1. Read what the deploy left on the box. let compose = run(format!("cat {compose_path}")) @@ -379,7 +436,7 @@ pub async fn finalize_build_box( done.push(FinalizeStage::Read); let env_values = parse_env_pairs(&env_raw); - let lost = env_file_values_lost_on_clone(&compose, &env_values, &ctx.protected_keys); + let lost = env_file_values_lost_on_clone(&compose, &env_values, &protected_keys); if !lost.is_empty() { return Err(BakeError::Finalize(format!( "this compose reads values through `env_file:`, and {} of them are not \ @@ -391,7 +448,7 @@ pub async fn finalize_build_box( lost.join(", ") ))); } - if let Some(warning) = env_scan_warning(&env_values, &ctx.protected_keys) { + if let Some(warning) = env_scan_warning(&env_values, &protected_keys) { eprintln!("WARNING: {warning}"); } @@ -401,7 +458,9 @@ pub async fn finalize_build_box( // a cleared .env, and any `${VAR}` outside an environment block // (`image: ${REGISTRY}/app:${TAG}`) would then resolve empty and fail // the teardown — with the files already modified and no way back. - for cmd in volume_reset_commands(&ctx.project_dir, volumes_to_keep(&ctx.stack))? { + let keep = volumes_to_keep(&ctx.contract); + let keep_refs: Vec<&str> = keep.iter().map(String::as_str).collect(); + for cmd in volume_reset_commands(&ctx.project_dir, &keep_refs)? { run(cmd).await.map_err(|e| fail("volume reset", e))?; } done.push(FinalizeStage::Teardown); @@ -412,7 +471,7 @@ pub async fn finalize_build_box( let sanitized = crate::cli::generator::compose::parameterize_embedded_secret_values( &compose, &env_values, - &ctx.protected_keys, + &protected_keys, ) .map_err(|conflict| BakeError::Finalize(conflict.to_string()))?; @@ -425,7 +484,7 @@ pub async fn finalize_build_box( crate::cli::generator::compose::resolve_non_contract_references( &sanitized, &env_values, - &ctx.protected_keys, + &protected_keys, ); if !unresolved.is_empty() { let names: Vec<&str> = unresolved.iter().map(|r| r.name.as_str()).collect(); @@ -450,13 +509,13 @@ pub async fn finalize_build_box( // the contract, not from the text of the file. A reference only counts // as required when something is expected to supply it. let required_env_keys = - crate::cli::generator::compose::required_env_keys(&sanitized, &ctx.protected_keys); + crate::cli::generator::compose::required_env_keys(&sanitized, &protected_keys); // 6. Clear the author's secrets in the co-located .env. The buyer's box // overwrites this file wholesale from /etc/stacker/env at boot, so // the blanked values are never read. if !env_raw.trim().is_empty() { - let scrubbed = scrub_env_file(&env_raw, &ctx.protected_keys); + let scrubbed = scrub_env_file(&env_raw, &protected_keys); write_remote_file(&run, &env_path, &scrubbed) .await .map_err(|e| fail("write .env", e))?; @@ -682,6 +741,86 @@ mod tests { keys.iter().map(|k| k.to_string()).collect() } + fn contract(yaml: serde_json::Value) -> crate::cli::config_parser::ConfigContract { + serde_json::from_value(yaml).expect("contract parses") + } + + /// The author declares which volumes survive; the platform stops hardcoding + /// a list per stack slug. + #[test] + fn kept_volumes_come_from_the_contract() { + let c = contract(serde_json::json!({ + "services": { + "ollama": { "volumes": { "app_ollama": { "mutability": "fixed" } } }, + "db": { "volumes": { "app_pgdata": { "mutability": "generated" } } } + } + })); + let mut kept = volumes_to_keep(&c); + kept.sort(); + assert_eq!(kept, vec!["app_ollama".to_string()]); + } + + #[test] + fn a_contract_declaring_no_volume_keeps_nothing() { + let c = contract(serde_json::json!({ + "services": { "app": { "fields": { "SECRET_KEY": { "mutability": "generated", "type": "hex" } } } } + })); + assert!(volumes_to_keep(&c).is_empty()); + } + + /// The author decides which volumes are safe to keep, including on services + /// that regenerate secrets — because a machine cannot tell the difference. + /// + /// Measured on real containers: a Postgres data directory stores + /// `SCRAM-SHA-256$4096:...`, so the password is not in the volume in any + /// searchable form; n8n keeps its own encryption key inside + /// `database.sqlite`; a Qdrant volume holds only collections, because Qdrant + /// reads its API key from the environment at every start. All three look + /// identical to any automated check — yet the first two must be reset and the + /// third must be kept. The difference is behavioural, not observable. + /// + /// An earlier revision refused a kept volume whenever its service declared a + /// protected field. That rule would have forced `ai-knowledge-base` to + /// recompute its embeddings on every buyer's machine — precisely the expense + /// the snapshot exists to avoid. + #[test] + fn a_volume_of_a_service_with_generated_fields_is_the_authors_call() { + let c = contract(serde_json::json!({ + "services": { + "qdrant": { + "fields": { "QDRANT__SERVICE__API_KEY": { "mutability": "generated", "type": "alphanumeric" } }, + "volumes": { "kb_qdrant_data": { "mutability": "fixed" } } + } + } + })); + assert!( + check_volume_declarations(&c).is_ok(), + "Qdrant reads its key from the environment; its volume holds only vectors" + ); + assert_eq!(volumes_to_keep(&c), vec!["kb_qdrant_data".to_string()]); + } + + #[test] + fn a_service_without_protected_fields_may_keep_its_volume() { + let c = contract(serde_json::json!({ + "services": { + "ollama": { "volumes": { "app_ollama": { "mutability": "fixed" } } }, + "web": { "fields": { "LOG_LEVEL": { "mutability": "editable" } } } + } + })); + assert!(check_volume_declarations(&c).is_ok()); + } + + /// Names now arrive from an author rather than a constant, so the shell + /// pattern gate applies to them. + #[test] + fn an_author_supplied_name_with_shell_syntax_is_refused() { + let c = contract(serde_json::json!({ + "services": { "app": { "volumes": { "oll*ama": { "mutability": "fixed" } } } } + })); + assert!(check_volume_declarations(&c).is_err()); + } + /// L1 — a keep entry is interpolated into a shell `case` pattern, where /// `|`, `)`, `*`, `?` and `[` are all syntax. An entry carrying one of them /// would silently match something else, or break the command outright. @@ -1150,11 +1289,13 @@ mod tests { assert!(protected_keys_from_contract(&serde_json::Value::Null).is_empty()); } + /// A contract that declares nothing keeps nothing — the same default the + /// stack-slug `match` used to give an unlisted stack, now without needing + /// the platform to know the stack at all. #[test] - fn stackpilot_keeps_only_its_model_volume() { - assert_eq!(volumes_to_keep("stackpilot"), &["ollama"]); - // An unknown stack defaults to resetting everything — leaking is worse - // than rebuilding. - assert!(volumes_to_keep("something-else").is_empty()); + fn a_contract_that_declares_nothing_keeps_nothing() { + let empty = crate::cli::config_parser::ConfigContract::default(); + assert!(volumes_to_keep(&empty).is_empty()); + assert!(check_volume_declarations(&empty).is_ok()); } } diff --git a/tests/features/bake_sanitization.feature b/tests/features/bake_sanitization.feature index d53b3830..f1bb7443 100644 --- a/tests/features/bake_sanitization.feature +++ b/tests/features/bake_sanitization.feature @@ -329,3 +329,38 @@ Feature: Bake-time sanitization of a build box When whole-value keys are parameterized Then the parameterized compose contains "- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" And the parameterized compose contains "- POSTGRES_USER=stackpilot" + + Rule: the author declares which volumes survive, and the platform checks it + + Scenario: A volume declared fixed survives the bake + Given the contract declares volume "app_ollama" on service "ollama" as fixed + When the kept volumes are collected + Then "app_ollama" is kept + + Scenario: An undeclared volume is reset + Given the contract declares nothing + When the kept volumes are collected + Then nothing is kept + + # Measured on real containers: Postgres stores the password as a SCRAM hash, + # n8n keeps its encryption key inside database.sqlite, and a Qdrant volume + # holds only collections because the API key is read from the environment at + # every start. The secret is absent from all three, so no automated check can + # separate the volume that must be reset from the one that must be kept. The + # author knows; the platform does not. + Scenario: Keeping a volume of a service that regenerates a secret is the author's call + Given the contract declares volume "kb_qdrant_data" on service "qdrant" as fixed + And service "qdrant" regenerates "QDRANT__SERVICE__API_KEY" + When the declaration is checked + Then the declaration is accepted + + Scenario: A volume of a service without per-buyer secrets is allowed + Given the contract declares volume "app_ollama" on service "ollama" as fixed + When the declaration is checked + Then the declaration is accepted + + Scenario: A name carrying shell syntax is refused + Given the contract declares volume "oll*ama" on service "ollama" as fixed + When the declaration is checked + Then the declaration is refused + And the refusal names "oll*ama" diff --git a/tests/steps/bake_sanitization.rs b/tests/steps/bake_sanitization.rs index 9be0705c..10fb2d2d 100644 --- a/tests/steps/bake_sanitization.rs +++ b/tests/steps/bake_sanitization.rs @@ -224,6 +224,9 @@ pub struct BakeWorld { pub stages: Vec, pub advice: String, pub lost: Vec, + pub declared_volumes: Vec<(String, String)>, + pub declared_fields: Vec<(String, String)>, + pub kept: Vec, } // ─── references that survive into the baked image ──────────────── @@ -601,3 +604,95 @@ async fn then_not_bare_substring(world: &mut StepWorld, name: String) { world.bake.volume_commands ); } + +// ─── author-declared volume policy ─────────────────────────────── + +#[given(regex = r#"^the contract declares volume "([^"]*)" on service "([^"]*)" as fixed$"#)] +async fn given_fixed_volume(world: &mut StepWorld, volume: String, service: String) { + world.bake.declared_volumes.push((service, volume)); +} + +#[given(regex = r#"^service "([^"]*)" regenerates "([^"]*)"$"#)] +async fn given_service_regenerates(world: &mut StepWorld, service: String, field: String) { + world.bake.declared_fields.push((service, field)); +} + +fn build_contract(world: &StepWorld) -> stacker::cli::config_parser::ConfigContract { + let mut services = serde_json::Map::new(); + for (service, volume) in &world.bake.declared_volumes { + let entry = services + .entry(service.clone()) + .or_insert_with(|| serde_json::json!({})); + entry["volumes"][volume] = serde_json::json!({ "mutability": "fixed" }); + } + for (service, field) in &world.bake.declared_fields { + let entry = services + .entry(service.clone()) + .or_insert_with(|| serde_json::json!({})); + entry["fields"][field] = + serde_json::json!({ "mutability": "generated", "type": "alphanumeric" }); + } + serde_json::from_value(serde_json::json!({ "services": services })).expect("contract parses") +} + +#[when(regex = r#"^the kept volumes are collected$"#)] +async fn when_collect_kept(world: &mut StepWorld) { + let contract = build_contract(world); + world.bake.kept = stacker::helpers::bake_finalize::volumes_to_keep(&contract); +} + +#[when(regex = r#"^the declaration is checked$"#)] +async fn when_check_declaration(world: &mut StepWorld) { + let contract = build_contract(world); + world.bake.refusal = stacker::helpers::bake_finalize::check_volume_declarations(&contract) + .err() + .map(|e| e.to_string()); +} + +#[then(regex = r#"^"([^"]*)" is kept$"#)] +async fn then_volume_kept(world: &mut StepWorld, name: String) { + assert!( + world.bake.kept.contains(&name), + "expected `{name}` among {:?}", + world.bake.kept + ); +} + +#[then(regex = r#"^nothing is kept$"#)] +async fn then_nothing_kept(world: &mut StepWorld) { + assert!( + world.bake.kept.is_empty(), + "unexpected: {:?}", + world.bake.kept + ); +} + +#[then(regex = r#"^the declaration is refused$"#)] +async fn then_declaration_refused(world: &mut StepWorld) { + assert!( + world.bake.refusal.is_some(), + "a volume holding the author's credentials must not be shipped" + ); +} + +#[then(regex = r#"^the declaration is accepted$"#)] +async fn then_declaration_accepted(world: &mut StepWorld) { + assert!( + world.bake.refusal.is_none(), + "unexpected refusal: {:?}", + world.bake.refusal + ); +} + +#[then(regex = r#"^the refusal names "([^"]*)"$"#)] +async fn then_refusal_names(world: &mut StepWorld, needle: String) { + let message = world + .bake + .refusal + .as_ref() + .expect("there should be a refusal"); + assert!( + message.contains(&needle), + "expected `{needle}` in: {message}" + ); +}