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:
parent
f6b97fdc62
commit
c8c1f400e9
2 changed files with 119 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
Loading…
Reference in a new issue