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}" + ); +}