diff --git a/docs/migration.md b/docs/migration.md index 5c89d2c..8e96e71 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -41,23 +41,45 @@ Migrate in dependency order, and migrate the authority's own dependencies last: resident to produce frames nothing rendered. The only reference to `gjallar.overview` in the estate is one Odin test file. Stopped and disabled; 420 MB under `/srv/gjallar` is reclaimable. -1. **`heimdall`**, **`repixelizer`**, **`streampixels`** — first, now. They - restart via `docker compose`, so they are the first real test of a - compose-shaped workload under the systemd-transient driver. -2. **`ghostlight`** — **blocked, not merely later.** A world-elaboration and +1. **`heimdall`** — first. It is the only target already shaped like a release: + `node /srv/heimdall/app/current/dist/src/index.js`, with a `current` symlink + over a built `dist/`. That is what `release_root` and the workload driver + expect, so it is the shortest distance between what runs and what a recipe + describes. +2. **`repixelizer`** — second. Unit is `repixelizer-gui` (the target name and + the unit name differ). Runs `/srv/repixelizer/.venv/bin/python` against a + source checkout at `/srv/repixelizer/app`, so the recipe needs a real build + step producing an artifact rather than a checkout to point at. +3. **`streampixels`** — third, and the hardest of the three despite looking + simple. It is **two units** under one target name, `streampixels-service` and + `streampixels-web`, and the service one runs + `node node_modules/tsx/dist/cli.mjs apps/service/src/index.ts` — raw + TypeScript transpiled at startup, from a checkout, with no build artifact to + seal. Idunn's model is sealed exact source *and* artifacts; this target has + to grow a build before it can have a recipe. + + **Their continuity is already broken, and has been.** The legacy actuator + restarts all three with + `docker compose -f /srv/compose/yggdrasil-apps.yaml restart `, and that + file **does not exist** — `/srv/compose` holds only `odin`, `voidbot` and + `voidbot-retrieval`. The path is guarded by `require_root_owned_regular_path`, + so every restart of these three has been failing at the guard. They are + running only because nothing has asked them to restart. This is the + difference between a supervised service and a service that happens to be up. +4. **`ghostlight`** — **blocked, not merely later.** A world-elaboration and ontology rebuild is running in it right now: commits landed 2026-09-05 across 178 branches, with work parked on `codex/ghostlight-dungeon-mvp`. Adding `deployment/idunn/recipe.toml` to that tree collides with live work. Migrate it when the rebuild lands, and coordinate rather than assuming. -3. **`bifrost-persona-feedback`** — the only target that carries signed-release +5. **`bifrost-persona-feedback`** — the only target that carries signed-release authority. Migrate it *after* at least three ref-head targets work, because it is the one that exercises `selection = "signed-release"`. -4. **`epiphany`** and **`epiphany-capstone-17`** — two targets sharing a +6. **`epiphany`** and **`epiphany-capstone-17`** — two targets sharing a repository. Prove the binding-per-target model here. -5. **`voidbot`** — deployed, live retrieval path, 5.7 GB of state. Not early. -6. **`odin`** — last of the targets. Idunn reads Odin's topology to gate +7. **`voidbot`** — deployed, live retrieval path, 5.7 GB of state. Not early. +8. **`odin`** — last of the targets. Idunn reads Odin's topology to gate promotion, so migrating Odin changes the thing that gates the migrations. -7. **Idunn itself** — the installed unit and binary. See *Cutting over the +9. **Idunn itself** — the installed unit and binary. See *Cutting over the authority* below. ## Per target diff --git a/src/control_plane.rs b/src/control_plane.rs index 1f8361b..a667e19 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -1382,6 +1382,10 @@ enum Command { state_store: PathBuf, command_id: Option, }, + Validate { + recipe: PathBuf, + binding: Option, + }, } pub fn run(args: impl Iterator) -> Result<()> { @@ -1404,9 +1408,41 @@ pub fn run(args: impl Iterator) -> Result<()> { state_store, command_id, } => status(&state_store, command_id.as_deref()), + Command::Validate { recipe, binding } => validate(&recipe, binding.as_deref()), } } +/// Offline admission check for authoring. Reads a recipe, optionally reads a +/// binding, and runs the same `admit` the daemon runs at load. It opens no +/// store, contacts no host, and actuates nothing, so it is safe to run against +/// a candidate binding before installing it. +fn validate(recipe_path: &Path, binding_path: Option<&Path>) -> Result<()> { + let recipe_text = fs::read_to_string(recipe_path) + .with_context(|| format!("reading recipe {}", recipe_path.display()))?; + let recipe = crate::deployment::TargetDeclaration::parse(&recipe_text) + .with_context(|| format!("validating recipe {}", recipe_path.display()))?; + println!("recipe ok: {} declares target {}", recipe_path.display(), recipe.target); + + let Some(binding_path) = binding_path else { + return Ok(()); + }; + let binding_text = fs::read_to_string(binding_path) + .with_context(|| format!("reading operator binding {}", binding_path.display()))?; + let binding = OperatorBinding::parse(&binding_text) + .with_context(|| format!("validating operator binding {}", binding_path.display()))?; + ensure!( + binding.target == recipe.target, + "operator binding targets {} but the recipe declares {}", + binding.target, + recipe.target + ); + binding + .admit(&recipe) + .with_context(|| format!("admitting {} against its recipe", binding.target))?; + println!("binding ok: {} admits target {}", binding_path.display(), binding.target); + Ok(()) +} + fn parse(args: impl Iterator) -> Result { let mut args = args.peekable(); let command = args.next().ok_or_else(|| anyhow!(usage()))?; @@ -1414,6 +1450,7 @@ fn parse(args: impl Iterator) -> Result { "serve" => parse_serve(args), "up" => parse_up(args), "status" => parse_status(args), + "validate" => parse_validate(args), "--help" | "-h" | "help" => bail!(usage()), _ => bail!("unknown Idunn command {command:?}\n\n{}", usage()), } @@ -1521,6 +1558,21 @@ fn parse_status(mut args: impl Iterator) -> Result { }) } +fn parse_validate(mut args: impl Iterator) -> Result { + let mut recipe = None; + let mut binding = None; + while let Some(argument) = args.next() { + match argument.as_str() { + "--recipe" => recipe = Some(path_value(&mut args, &argument)?), + "--binding" => binding = Some(path_value(&mut args, &argument)?), + "--help" | "-h" => bail!(usage()), + _ => bail!("unknown Idunn validate option {argument:?}"), + } + } + let recipe = recipe.ok_or_else(|| anyhow!("validate requires --recipe"))?; + Ok(Command::Validate { recipe, binding }) +} + #[derive(Clone)] struct Stored { envelope: CultCacheEnvelope, @@ -5248,7 +5300,8 @@ fn usage() -> &'static str { "Idunn deployment, admission, and continuity control plane\n\n\ idunn serve [runtime options]\n\ idunn up [--state-store PATH] [--no-wait]\n\ - idunn status [--state-store PATH] [--command ID]\n\n\ + idunn status [--state-store PATH] [--command ID]\n\ + idunn validate --recipe PATH [--binding PATH]\n\n\ Recipes describe capability and process requirements. Idunn seals exact\n\ source and artifacts, admits one incarnation, and delegates execution to\n\ systemd and routing mechanics to the configured proxy." @@ -5380,6 +5433,40 @@ mod tests { } } + #[test] + fn validate_is_offline_and_declarative() { + // It must accept a recipe alone, accept a recipe plus a binding, and + // refuse to be handed anything executable. It opens no store and + // actuates nothing, which is what makes it safe to run on a candidate + // binding before that binding is installed. + let parsed = parse( + ["validate", "--recipe", "/tmp/recipe.toml"] + .into_iter() + .map(str::to_owned), + ) + .unwrap(); + assert!(matches!( + parsed, + Command::Validate { binding: None, .. } + )); + + assert!( + parse( + ["validate", "--recipe", "/r.toml", "--binding", "/b.toml"] + .into_iter() + .map(str::to_owned) + ) + .is_ok() + ); + assert!(parse(["validate"].into_iter().map(str::to_owned)).is_err()); + for rejected in [ + vec!["validate", "--recipe", "/r.toml", "--deploy-command", "sh -c bad"], + vec!["validate", "--recipe", "/r.toml", "--state-store", "/s.cc"], + ] { + assert!(parse(rejected.into_iter().map(str::to_owned)).is_err()); + } + } + #[test] fn cli_exposes_only_declarative_commands() { for arguments in [