Add idunn validate, so a binding can be checked before it is installed

The migration procedure told operators to dry-run a target, and no dry-run
existed. `up` needs a running daemon and a state store, so the only way to
find out whether a binding was well-formed was to install it and see
whether `serve` refused to start -- on the host, with the authority down.

`idunn validate --recipe PATH [--binding PATH]` runs the same
TargetDeclaration::parse and OperatorBinding::admit the daemon runs at
load. It opens no store, contacts no host, and actuates nothing.

Owner: the existing control-plane CLI, no new module or target. Consumer:
whoever authors a binding, starting with this migration. Invariant: a
binding installed on the host admits against its target's recipe. It
replaces the install-and-see loop rather than adding a layer.

Verified against the real estate: Odin, CodexConnector and Ghostlight
recipes all parse. Negative paths checked too, because a validator that
only ever passes is worse than none -- an unknown recipe field (the
privilege-escalation shape), a missing --recipe, and an unreadable file
all fail with the reason.

A test asserts validate stays declarative and refuses --deploy-command,
matching the guarantee cli_exposes_only_declarative_commands makes for the
rest of the surface. 64 passed / 18 failed, against a 63/18 baseline: one
added pass, no new failures, and the 18 remain the known Windows
absolute-path cases. cargo check for x86_64-unknown-linux-gnu is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-06 00:27:36 +02:00
commit c8c1f400e9
2 changed files with 119 additions and 10 deletions

View file

@ -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 <name>`, 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

View file

@ -1382,6 +1382,10 @@ enum Command {
state_store: PathBuf,
command_id: Option<String>,
},
Validate {
recipe: PathBuf,
binding: Option<PathBuf>,
},
}
pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
@ -1404,9 +1408,41 @@ pub fn run(args: impl Iterator<Item = String>) -> 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<Item = String>) -> Result<Command> {
let mut args = args.peekable();
let command = args.next().ok_or_else(|| anyhow!(usage()))?;
@ -1414,6 +1450,7 @@ fn parse(args: impl Iterator<Item = String>) -> Result<Command> {
"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<Item = String>) -> Result<Command> {
})
}
fn parse_validate(mut args: impl Iterator<Item = String>) -> Result<Command> {
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<T> {
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 <service|profile:name> [--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 [