Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d63cb417 | ||
|
|
77e2295e97 | ||
|
|
fb1e7b5dd9 | ||
|
|
8ebe0208a6 | ||
|
|
a859914b0b | ||
|
|
4894ad20df | ||
|
|
879b76a842 | ||
|
|
631fb7836b | ||
|
|
d29bf93be6 | ||
|
|
d9710c988a | ||
|
|
caacbb4bba | ||
|
|
9653d270e5 | ||
|
|
8a1beb5c87 | ||
|
|
7e15bf1a7b | ||
|
|
572f9e6aa4 | ||
|
|
56edad95c1 | ||
|
|
ca439a29ff | ||
|
|
27ca875194 | ||
|
|
be3b95bc21 | ||
|
|
cea17c06a7 | ||
|
|
ec6222e2a1 | ||
|
|
7d5cd85728 | ||
|
|
1a3634e046 | ||
|
|
e50a721dbc | ||
|
|
024f2d7a09 | ||
|
|
8400d0712f | ||
|
|
1518cfb13c | ||
|
|
549f4be093 | ||
|
|
4a68df520f | ||
|
|
134c1fb5a2 | ||
|
|
54a75ef9b0 | ||
|
|
7ef31ee699 | ||
|
|
22bd904ed9 | ||
|
|
ef82355bab | ||
|
|
78086705a0 | ||
|
|
804ed759c9 | ||
|
|
69eb3e6d6f |
11 changed files with 6278 additions and 33 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -482,6 +482,7 @@ dependencies = [
|
|||
"libc",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ serde = { version = "1", features = ["derive"] }
|
|||
ed25519-dalek = "2.2"
|
||||
toml = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -58,6 +58,64 @@ idunn status --command <id>
|
|||
the bindings, so `idunn up profile:full-gamecult` brings up everything bound
|
||||
into that profile.
|
||||
|
||||
### Steward orders
|
||||
|
||||
A second root unit from the same binary, `idunn-orders` (`idunn orders serve`),
|
||||
takes typed orders from one steward Mind over `/run/idunn-orders/steward.sock`.
|
||||
It has its own store, `orders.cc`, and its own binding, `steward.toml`; the
|
||||
survival daemon takes no order. There is still no command string:
|
||||
|
||||
```bash
|
||||
idunn order package-install <pkg>[=version]... --reason TEXT # apt-get update, install named packages, check them
|
||||
idunn order unit <start|stop|restart|reload> <unit> --reason TEXT
|
||||
idunn order deploy <service|profile:name> --reason TEXT # the same commit as `idunn up`
|
||||
idunn order show <id> # status and output tails
|
||||
idunn order list
|
||||
```
|
||||
|
||||
A unit order submits exactly one job, for the unit it names: every verb runs
|
||||
`systemctl <verb> --job-mode=ignore-requirements -- <unit>`, so systemd builds a
|
||||
transaction of one job. It does not start what the unit requires or restart what
|
||||
is part of it: order the dependency first, and order `postgresql@16-main.service`
|
||||
rather than the `postgresql.service` wrapper. Targets are not orderable. The job is one; what the unit does afterwards is
|
||||
not: systemd still runs full transactions of its own for `Restart=` after a
|
||||
failure, for a `StopWhenUnneeded=` dependency left without a user, and for a
|
||||
`Conflicts=` partner of a unit that fails, so an ordered stop of a unit
|
||||
that something restarts does not last, and a start of a `Restart=always`
|
||||
unit can report failure and be active seconds later. A unit
|
||||
order may act on any unit except Idunn's own (`idunn-*`) and the minds' own
|
||||
(`mind-self@*`, `mind-persona@*`), and the units that end or take the host
|
||||
(`systemd-suspend`, `systemd-hibernate`, `systemd-hybrid-sleep`,
|
||||
`systemd-suspend-then-hibernate`, `rescue`, `emergency`). That set is compiled
|
||||
into the binary and read from no configuration. An order is refused, recorded
|
||||
and posted when the ordered unit, or an alias it resolves from, is one of them;
|
||||
when it has an emergency action (`SuccessAction=`, `FailureAction=`,
|
||||
`StartLimitAction=`, `JobTimeoutAction=`) other than `none` or an `OnFailure=`
|
||||
or `OnSuccess=` unit (not for a reload); or, for a stop or restart, when another
|
||||
unit is `BoundBy=` it, because systemd stops that unit with it whatever the job
|
||||
mode; for a start or restart, when it has an `Upholds=` unit, which systemd
|
||||
starts in a full transaction whatever the job mode; or, for a stop, restart or
|
||||
reload, when it has a `ConsistsOf=` member, a wrapper such as
|
||||
`postgresql.service` whose verbs would exit 0 and touch nothing. A unit
|
||||
`systemctl show` cannot resolve fails the order. Slices, scopes,
|
||||
targets and globs are not units an order can name.
|
||||
|
||||
A package order installs the packages it names and nothing else: apt runs with
|
||||
`--no-remove`, each name is passed as an exact-name pattern so `.` and `+`
|
||||
match themselves, and a last step fails the order unless every named package
|
||||
is installed.
|
||||
|
||||
An order runs once, after the steward's uid is checked and the instance's
|
||||
brake check (`mind-control --instance <principal> check`) exits 0. The check
|
||||
that the order's principal and uid are the bound steward's runs again at every
|
||||
execution, so an order accepted before `steward.toml` changed is refused and
|
||||
recorded, not run. The socket belongs to the steward user alone (mode 0600);
|
||||
root may `show` and `list` orders but never issue one. An order that had
|
||||
started when the service died is recorded as lost and never run again. No order releases a brake. Acceptance and result are posted to
|
||||
`#operator` as a retried projection of `orders.cc`: a chat outage delays the
|
||||
posts and not the orders. A refusal names the field and a code, never the value
|
||||
it refused.
|
||||
|
||||
## Authority
|
||||
|
||||
**Owner.** Deployment admission, and the continuity of installed bodies.
|
||||
|
|
|
|||
47
deploy/idunn-orders.service
Normal file
47
deploy/idunn-orders.service
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[Unit]
|
||||
Description=Idunn steward orders: typed package, unit and deploy orders from the steward
|
||||
# Deliberately no After= or Requires= on any managed target, Synapse or
|
||||
# idunn-yggdrasil: orders are how an operator repairs those, so none of them
|
||||
# may be a condition of this unit starting. Posts are a retried projection and
|
||||
# wait for Synapse on their own.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Root, from the same binary as idunn-yggdrasil, with its own store and socket.
|
||||
# idunn-yggdrasil keeps its no-imperative-input invariant and its write set:
|
||||
# no order code runs in `idunn serve`.
|
||||
#
|
||||
# The unit name matches idunn-*, which is in the always-refused set, so no
|
||||
# order can stop or restart this service.
|
||||
User=root
|
||||
# No Group= and no UMask=: the service gives its socket to the steward user
|
||||
# (mode 0600) itself and sets Idunn's own umask for the files it writes, so
|
||||
# nothing here reaches control.cc. The steward user is the only peer the
|
||||
# service answers: it checks SO_PEERCRED on every connection.
|
||||
ExecStart=/usr/local/bin/idunn orders serve \
|
||||
--store /var/lib/gamecult/idunn-orders \
|
||||
--state-store /var/lib/gamecult/idunn/control.cc \
|
||||
--steward-binding /etc/gamecult/idunn/steward.toml \
|
||||
--socket /run/idunn-orders/steward.sock
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
RuntimeDirectory=idunn-orders
|
||||
RuntimeDirectoryMode=0755
|
||||
|
||||
# The Synapse access token of @idunn, copied from a root-only file, and read
|
||||
# through $CREDENTIALS_DIRECTORY; the service names it in steward.toml.
|
||||
LoadCredential=idunn-matrix-token:/etc/gamecult/idunn/matrix-token
|
||||
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
# Its own order store, and the control store a deploy order commits a command
|
||||
# to. Transient units started with systemd-run run in the manager's namespace,
|
||||
# so packages and units are not limited by this list.
|
||||
ReadWritePaths=/var/lib/gamecult/idunn-orders /var/lib/gamecult/idunn
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -199,3 +199,196 @@ on Yggdrasil: a 3.5 GiB tree, its staging copy, downloads and pip cache. It
|
|||
removes it at the end. Revision 2 runs it on the trees as shipped, with the 300
|
||||
terminfo links (T13). If a tree holds a link the T14 rule refuses, the job
|
||||
prints it and stops, and the link is reported to Self, not deleted.
|
||||
|
||||
## Cut `isolation-credential-evidence`
|
||||
|
||||
Spec `idunn-deploy-provenance:cut_spec:cut-cut-isolation-credential-evidence.r1`,
|
||||
at `112a7a5` (origin/main, live on Yggdrasil since 2026-10-24 11:29 UTC). It
|
||||
serves Vili's admission (`motion-pipeline:cut_report:cut-vili-admission.h4`,
|
||||
first deviation): every `idunn up vili` is refused before fencing.
|
||||
|
||||
### Body facts (probed read-only on Yggdrasil, 2026-10-24)
|
||||
|
||||
- I1. `idunn up vili` runs 6 and 7 failed with
|
||||
`systemd workload isolation properties differ from the Idunn contract`, the
|
||||
message of `observe_unit` (`src/drivers.rs:2975-3005`). Of its conjuncts,
|
||||
only one differs on the failed Vili unit: `LoadCredential`. Idunn expects
|
||||
`""` because Vili has no service credential besides the presence identity
|
||||
(`service_credential_names` empty); systemd shows `[unprintable]`. Every
|
||||
other conjunct matches, InaccessiblePaths included (one value across all
|
||||
five idunn-vili/odin units).
|
||||
- I2. systemd 257.13 `systemctl show -p LoadCredential` prints
|
||||
`LoadCredential=[unprintable]` for every unit, whatever the credentials:
|
||||
`ssh.service` and `systemd-journald.service` (not DynamicUser, zero
|
||||
LoadCredential), the four failed `idunn-vili-*` units (zero), and the Odin
|
||||
unit (one). The display form carries no information, so the conjunct was
|
||||
vacuous whenever credentials existed and always false when none did. Odin
|
||||
and StreamPixels passed only because each carries a regular credential.
|
||||
- I3. The exact value is readable: D-Bus
|
||||
`org.freedesktop.systemd1.Service.LoadCredential` (`busctl get-property`)
|
||||
answers `a(ss) 0` for the Vili units and ssh, `a(ss) 1
|
||||
"ODIN_TOPOLOGY_IDENTITY" "<path>"` for Odin.
|
||||
- I4. What the workload can actually read is the delivered directory. On the
|
||||
host, `/run/credentials/` holds a directory only for units with credentials
|
||||
(getty, journald, Odin, StreamPixels service and web). Inside Odin's MainPID
|
||||
root, `/run/credentials/<unit>/` holds exactly `ODIN_TOPOLOGY_IDENTITY`
|
||||
(`-r--r-----+ root root`). The presence identity is a parent-only `OpenFile`
|
||||
descriptor, never a credential.
|
||||
- I5. The rule arrived whole in `1fc0dad` (2026-09-03, "Rebuild Idunn
|
||||
deployment transaction authority"), with its twin clause in
|
||||
`validate_launch_observation` (`:3468-3472`, `:3513-3516`); no later commit
|
||||
touched it. No ruling or finding in any campaign concerns workload isolation
|
||||
verification; gamecult-chat's foreign-workloads cut touched
|
||||
`CREDENTIALS_DIRECTORY` reservation, not this check.
|
||||
- I6. `SystemdWorkloadObservation` is persisted by previous Idunns in both
|
||||
MessagePack encodings, compact (positional) included
|
||||
(`persisted_systemd_observations_decode_as_the_systemd_variant`,
|
||||
`:9915-9930`), so deleting the `load_credential` field shifts every later
|
||||
slot of a live incumbent's record. The field is also in the drift list
|
||||
(`:2462`) that `stop` (`:4064-4070`) uses to refuse stopping a changed
|
||||
workload: changing what the field holds without leaving that list would make
|
||||
the next Idunn refuse to stop the live Odin and StreamPixels incumbents.
|
||||
- I7. Fixtures: the verbatim 257.13 `systemctl show` output of base's 27
|
||||
properties for a Vili unit (presence identity only) and the Odin unit
|
||||
(presence identity plus one service credential), captured 2026-10-24 into
|
||||
this Imagination's scratchpad `fixtures/`.
|
||||
- I8. The new rule holds for every live systemd incumbent: inside each
|
||||
MainPID root the delivered entries equal its binding's `secret_files` keys
|
||||
minus `GAMECULT_RUNTIME_PRESENCE_IDENTITY` (Odin 1, StreamPixels service 11,
|
||||
StreamPixels web 1; key names compared, no values read). An Idunn restart onto
|
||||
this cut re-observes them (`:3955-3970`) and passes.
|
||||
|
||||
### Decision
|
||||
|
||||
A property systemd cannot display does not belong in the comparison. The
|
||||
invariant it reached for, that the workload holds exactly the credentials Idunn
|
||||
delivered, is observed where it is decided: the entries of
|
||||
`/run/credentials/<unit>` in the MainPID's root equal Idunn's service
|
||||
credential names, absent directory equal to none. That covers
|
||||
`LoadCredential`, `SetCredential`, `ImportCredential` and drop-ins alike, and
|
||||
needs no new program. The D-Bus read (I3) is exact but sees only one of those
|
||||
properties and adds a second systemd client and parser; rejected.
|
||||
|
||||
### Authority
|
||||
|
||||
Owner: `observe_unit` decides the credential rule, from the delivered
|
||||
directory. `LoadCredential`'s display form is no longer read or compared; the
|
||||
`load_credential` field is a retired wire slot, written empty, out of the drift
|
||||
list, kept only so persisted records decode. Forbidden writers: the
|
||||
`observe_unit` conjunct and the `validate_launch_observation` clause. Dropping
|
||||
the slot waits on the next schema revision of the observation.
|
||||
|
||||
### Deploy
|
||||
|
||||
Idunn's body changes only through gamecult-ops
|
||||
`scripts/install-idunn-yggdrasil-release.sh` (stage `idunn` only; unit and
|
||||
bindings unchanged). After it, Vili's Hands reruns from step 4 of
|
||||
`motion-pipeline:cut_report:cut-vili-admission.h4`'s undone list.
|
||||
|
||||
## Cut `runner-failure-evidence`
|
||||
|
||||
Spec `idunn-deploy-provenance:cut_spec:cut-runner-failure-evidence.r1`, at
|
||||
`804ed75` (origin/main, live on Yggdrasil). It serves the StreamPixels-area
|
||||
Self: `idunn up vili` (command `up-4866467c-ee98-4a2b-b4a9-a008d7c44acd`,
|
||||
transaction `tx-a04b68c7-c742-4d7d-8220-8aa08b9febae`, 2026-10-25 00:50-00:55
|
||||
UTC) failed in Sealing with `Docker runner exited with exit status: 1: ` and
|
||||
nobody could tell which recipe step failed.
|
||||
|
||||
### Body facts (source read at `804ed75`; host read-only, 2026-10-25)
|
||||
|
||||
- R1. The runner is `docker run --rm` (`src/drivers.rs:2088-2090`), launched by
|
||||
`DockerRunnerDriver::docker` (`:2173-2195`) with stdin null and
|
||||
`Command::output()`: stdout and stderr are each buffered whole in the
|
||||
daemon's memory, unbounded. On a non-zero exit the error is
|
||||
`"Docker runner exited with {status}: {stderr}"`; stdout is dropped. The
|
||||
container is gone the moment it exits, so `docker ps -a` and `docker logs`
|
||||
hold nothing.
|
||||
- R2. The empty message: the failing process wrote nothing to stderr, and
|
||||
stdout, the only stream that carried its report, is discarded at
|
||||
`:2182-2193`. The same interpolation echoes whatever a step does write to
|
||||
stderr into `last_error`, `pre_fencing_abort.error` and
|
||||
`completion.error` (`bounded_detail`, 2048 bytes), and from there into
|
||||
`idunn up`'s `deployment failed:` line and `idunn status`.
|
||||
- R3. No step identity reaches the error. `materialize` (`:2368-2391`) loops
|
||||
over `declaration.steps` and calls `run_in_workspace` with argv only;
|
||||
`step.id` is used only in the environment precondition. External inputs
|
||||
(`curl` in the runner, `:2247-2258`) fail through the same helper with the
|
||||
same anonymous message.
|
||||
- R4. Which step failed on 2026-10-25, inferred from the failed transaction's
|
||||
staging root, which survives (926 MiB,
|
||||
`/var/lib/gamecult/idunn/staging/tx-a04b68c7-.../`): in `.runner-node-build`,
|
||||
`node_modules/.package-lock.json` was written at 00:51:24 (so
|
||||
`install-vili-dependencies`, `npm ci`, finished), nothing outside
|
||||
`node_modules` is newer, and `release/vili` (the next step's output) is
|
||||
absent; the python workspace holds only fetched inputs. The failing step was
|
||||
`test-daemon` (`node --test ... scripts/test-vili-daemon.mjs`), whose test
|
||||
reporter writes to stdout: exactly the stream R1 discards. Inference from
|
||||
residue, not from output.
|
||||
- R5. The failure path: `resume_one_transaction` (`src/control_plane.rs:6291-6320`)
|
||||
sends a pre-fence error to `begin_pre_fencing_abort` (`:9916-9935`), which
|
||||
persists `bounded_detail(format!("{error:#}"))`. Nothing reaches the journal
|
||||
on this path: the idunn-yggdrasil journal for 00:45-01:10 holds only
|
||||
provider-exclusion lines.
|
||||
- R6. Stores. `control.cc` (root 0640) holds commands, transactions
|
||||
(`idunn.deployment_transaction.v4`, keys 0-36), generations, supervision and
|
||||
expiry requests; `ControlSnapshot::read` refuses any other type
|
||||
(`:3420`). Records must re-encode byte-exactly (`decode_record`,
|
||||
`:3717-3727`), so a new transaction field is a schema bump and a v4 lift
|
||||
(`:4100-4110`). Terminal transactions move verbatim to `history.cc`
|
||||
(`:4366-4396`; 39.8 MB on the host), which status reads for `--command`.
|
||||
`incidents.cc` lives in the published projection directory and by its own
|
||||
doc (`src/incident.rs:1-11`) carries no free text and is never `control.cc`,
|
||||
because a type added there stops continuity for every target on a rollback.
|
||||
- R7. The daemon runs as root (`User=root`). Runner secrets reach the
|
||||
container as read-only file mounts named by env (`:2041-2047`), so any text
|
||||
a step derives from them can land in its output.
|
||||
- R8. Staging residue: 475 transaction roots, 55 GiB under
|
||||
`/var/lib/gamecult/idunn/staging`; a failed Sealing leaves its workspaces
|
||||
because `materialize` returns before `remove_tree_inside`.
|
||||
|
||||
### Decision
|
||||
|
||||
The failure becomes a typed value at the runner, `RunnerStepFailure`: the
|
||||
subject (recipe step or external input, by id), the runner id, the exit code or
|
||||
signal, and the last 8 KiB of stdout and of stderr with each stream's total
|
||||
byte count. Its `Display` carries the subject and status, never output.
|
||||
Capture streams both pipes through bounded tails, so a verbose build no longer
|
||||
buffers whole in the daemon.
|
||||
|
||||
The tail lives in a new private store, `runner-evidence.cc`, beside
|
||||
`control.cc` (mode 0600), one record per transaction
|
||||
(`idunn.runner_failure.v1`), newest 32 kept by the writer. Not in the
|
||||
transaction record: that is a v5 bump whose rollback makes every older Idunn
|
||||
refuse its control store (R6), the scar `incident.rs` already names. Not in
|
||||
`incidents.cc`: published, and free text is barred there. Not in the journal:
|
||||
journal readers are a wider set than root, so the journal line names the
|
||||
transaction, the subject, the status and the store, and no output.
|
||||
|
||||
No redaction. Matching secret bytes would make the daemon read every secret,
|
||||
and still misses any encoding, prefix or transform of one; a claimed redaction
|
||||
that leaks is worse than a stated rule. The rule instead is the reader set: the
|
||||
store's only readers (root) can already read every secret the runner was
|
||||
given.
|
||||
|
||||
Keeping the failed container (dropping `--rm`) is worse: its output then lives
|
||||
in Docker's json-file log, unbounded, untyped, outside Idunn's state and
|
||||
readable by the docker group; containers accumulate with no owner to reap them;
|
||||
`docker system prune` erases the evidence; and nothing ties a container to the
|
||||
transaction in `idunn status`.
|
||||
|
||||
### Authority
|
||||
|
||||
Owner: the runner decides what failed and keeps the bounded tail; the engine's
|
||||
pre-fence failure path is the one writer of `runner-evidence.cc`.
|
||||
`last_error`, `pre_fencing_abort.error` and `completion.error` keep only the
|
||||
failure's `Display`. Forbidden writers: the `stderr` interpolation in
|
||||
`DockerRunnerDriver::docker`. Retention is by count inside the writer, so the
|
||||
store needs no external owner; staging roots (R8) stay with
|
||||
`ghostlight-verse:follow_up:idunn-generation-retention`.
|
||||
|
||||
### Deploy
|
||||
|
||||
Idunn's body changes only through gamecult-ops
|
||||
`scripts/install-idunn-yggdrasil-release.sh` (stage `idunn` only; unit and
|
||||
bindings unchanged). The next failed Sealing then shows its step and tail on
|
||||
`sudo idunn status --command <id>`.
|
||||
|
|
|
|||
|
|
@ -42,11 +42,14 @@ use crate::drivers::{
|
|||
ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FrozenSourceReceipt,
|
||||
GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver,
|
||||
LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, SourcePort,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort,
|
||||
SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort,
|
||||
WriteLeasePort,
|
||||
};
|
||||
use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents};
|
||||
use crate::runner_evidence::{
|
||||
self, Kept, RunnerFailureRecord, runner_evidence_store_path, unreadable_store_path,
|
||||
};
|
||||
use crate::host_actuator::{
|
||||
HostActuatorAccess, HostActuatorHub, HostActuatorRunnerDriver, HostActuatorWorkloadDriver,
|
||||
HostUnobservable, IdunnHostActuatorIdentity, SharedHostActuatorHub, spawn_hub_service,
|
||||
|
|
@ -2931,6 +2934,10 @@ enum Command {
|
|||
recipe: PathBuf,
|
||||
binding: Option<PathBuf>,
|
||||
},
|
||||
#[cfg(unix)]
|
||||
OrdersServe(crate::steward_orders::ServeOptions),
|
||||
#[cfg(unix)]
|
||||
Order(crate::steward_orders::OrderCommand),
|
||||
}
|
||||
|
||||
pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
||||
|
|
@ -2970,6 +2977,10 @@ pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
|||
requested_by,
|
||||
} => expire(&state_store, &command_id, &requested_by),
|
||||
Command::Validate { recipe, binding } => validate(&recipe, binding.as_deref()),
|
||||
#[cfg(unix)]
|
||||
Command::OrdersServe(options) => crate::steward_orders::serve(options),
|
||||
#[cfg(unix)]
|
||||
Command::Order(order) => crate::steward_orders::run_order(order),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3022,6 +3033,10 @@ fn parse(args: impl Iterator<Item = String>) -> Result<Command> {
|
|||
"cancel" => parse_cancel(args),
|
||||
"expire" => parse_expire(args),
|
||||
"validate" => parse_validate(args),
|
||||
#[cfg(unix)]
|
||||
"orders" => crate::steward_orders::parse_serve(args).map(Command::OrdersServe),
|
||||
#[cfg(unix)]
|
||||
"order" => crate::steward_orders::parse_order(args).map(Command::Order),
|
||||
"--help" | "-h" | "help" => bail!(usage()),
|
||||
_ => bail!("unknown Idunn command {command:?}\n\n{}", usage()),
|
||||
}
|
||||
|
|
@ -4727,20 +4742,21 @@ fn resolve_selector(
|
|||
Ok(targets)
|
||||
}
|
||||
|
||||
fn submit(
|
||||
/// The one commit primitive for a deployment command: `idunn up` and a steward
|
||||
/// deploy order both write theirs here. Returns the commit time.
|
||||
pub(crate) fn commit_deployment_command(
|
||||
store_path: &Path,
|
||||
command_id: &str,
|
||||
selector: &str,
|
||||
requested_by: &str,
|
||||
wait: bool,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<()> {
|
||||
) -> Result<u64> {
|
||||
if let Some(parent) = store_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let now = now_millis()?;
|
||||
let command = DeploymentCommand {
|
||||
schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(),
|
||||
command_id: format!("up-{}", Uuid::new_v4()),
|
||||
command_id: command_id.into(),
|
||||
kind: CommandKind::Deploy,
|
||||
selector: selector.into(),
|
||||
requested_by: requested_by.into(),
|
||||
|
|
@ -4758,7 +4774,33 @@ fn submit(
|
|||
)?,
|
||||
"deployment command id collided"
|
||||
);
|
||||
println!("{}", command.command_id);
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
/// A command's present state as `idunn status --command` derives it, for the
|
||||
/// steward order poster: `queued` and `running` are not terminal.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn command_state(
|
||||
state_store: &Path,
|
||||
command_id: &str,
|
||||
) -> Result<(&'static str, String)> {
|
||||
let snapshot = ControlSnapshot::read(state_store)?;
|
||||
let archived = read_history_transactions(state_store);
|
||||
let mut transactions = snapshot.transaction_for_command(command_id);
|
||||
transactions.extend(archived.iter().filter(|value| value.command_id == command_id));
|
||||
Ok(derived_command_status(&transactions))
|
||||
}
|
||||
|
||||
fn submit(
|
||||
store_path: &Path,
|
||||
selector: &str,
|
||||
requested_by: &str,
|
||||
wait: bool,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<()> {
|
||||
let command_id = format!("up-{}", Uuid::new_v4());
|
||||
let now = commit_deployment_command(store_path, &command_id, selector, requested_by)?;
|
||||
println!("{command_id}");
|
||||
if !wait {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -4769,10 +4811,10 @@ fn submit(
|
|||
snapshot
|
||||
.commands
|
||||
.iter()
|
||||
.any(|stored| stored.value.command_id == command.command_id),
|
||||
.any(|stored| stored.value.command_id == command_id),
|
||||
"submitted deployment command disappeared"
|
||||
);
|
||||
let transactions = snapshot.transaction_for_command(&command.command_id);
|
||||
let transactions = snapshot.transaction_for_command(&command_id);
|
||||
if !transactions.is_empty() && transactions.iter().all(|value| value.is_terminal()) {
|
||||
if let Some(error) =
|
||||
transactions
|
||||
|
|
@ -4797,7 +4839,7 @@ fn submit(
|
|||
}
|
||||
}
|
||||
|
||||
fn status(
|
||||
pub(crate) fn status(
|
||||
out: &mut impl Write,
|
||||
store_path: &Path,
|
||||
incident_store: &Path,
|
||||
|
|
@ -4877,6 +4919,13 @@ fn status(
|
|||
}
|
||||
if let Some(abort) = &transaction.pre_fencing_abort {
|
||||
writeln!(out, " pre-fencing abort {abort:?}")?;
|
||||
// The step that failed and its output tail live in their
|
||||
// own private store. An unreadable store costs its lines.
|
||||
runner_evidence::show_status(
|
||||
out,
|
||||
&runner_evidence_store_path(store_path),
|
||||
&transaction.transaction_id,
|
||||
)?;
|
||||
}
|
||||
if let Some(abort) = &transaction.post_fencing_abort {
|
||||
writeln!(out, " post-fencing abort {abort:?}")?;
|
||||
|
|
@ -5374,6 +5423,32 @@ impl Engine {
|
|||
IncidentStore::new(&self.options.incident_store)
|
||||
}
|
||||
|
||||
/// A failed runner step keeps its output tail in the runner evidence
|
||||
/// store and says so on the journal, naming no output. The store write is
|
||||
/// best effort: its failure is named in the line and never stops the
|
||||
/// abort that follows.
|
||||
fn keep_runner_failure(
|
||||
&self,
|
||||
transaction: &DeploymentTransaction,
|
||||
error: &anyhow::Error,
|
||||
) -> Option<String> {
|
||||
let failure = error.downcast_ref::<RunnerStepFailure>()?;
|
||||
let path = runner_evidence_store_path(&self.options.state_store);
|
||||
let recorded = now_millis().and_then(|now| {
|
||||
runner_evidence::record(
|
||||
&path,
|
||||
&RunnerFailureRecord::from_failure(
|
||||
&transaction.transaction_id,
|
||||
&transaction.command_id,
|
||||
&transaction.target,
|
||||
failure,
|
||||
now,
|
||||
),
|
||||
)
|
||||
});
|
||||
Some(runner_failure_journal_line(transaction, failure, &path, &recorded))
|
||||
}
|
||||
|
||||
/// Settle the outcome of one incident operation. A failure never leaves
|
||||
/// this function: it is traced once per episode, under the operation and
|
||||
/// the subject it was decided for (`*` for a pass over the whole store),
|
||||
|
|
@ -6315,6 +6390,9 @@ impl Engine {
|
|||
if latest.value.pre_fencing_abort.is_none()
|
||||
&& error.downcast_ref::<LockContended>().is_none()
|
||||
{
|
||||
if let Some(line) = self.keep_runner_failure(&latest.value, &error) {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
self.begin_pre_fencing_abort(latest, error)?;
|
||||
} else {
|
||||
self.record_resumable_error(latest, &error)?;
|
||||
|
|
@ -10553,7 +10631,13 @@ fn required<'a, T>(value: &'a Option<T>, label: &str) -> Result<&'a T> {
|
|||
value.as_ref().with_context(|| format!("{label} is absent"))
|
||||
}
|
||||
|
||||
fn now_millis() -> Result<u64> {
|
||||
/// The control store `idunn up` and `idunn serve` use unless told otherwise.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn default_state_store() -> PathBuf {
|
||||
RuntimeOptions::default().state_store
|
||||
}
|
||||
|
||||
pub(crate) fn now_millis() -> Result<u64> {
|
||||
Ok(SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock predates Unix epoch")?
|
||||
|
|
@ -10573,12 +10657,12 @@ fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
|
|||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn string_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
pub(crate) fn string_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||
}
|
||||
|
||||
fn path_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
pub(crate) fn path_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(string_value(args, name)?))
|
||||
}
|
||||
|
||||
|
|
@ -10594,7 +10678,7 @@ fn u64_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<u64>
|
|||
.with_context(|| format!("{name} requires a u64"))
|
||||
}
|
||||
|
||||
fn require_selector(value: &str) -> Result<()> {
|
||||
pub(crate) fn require_selector(value: &str) -> Result<()> {
|
||||
let value = value.strip_prefix("profile:").unwrap_or(value);
|
||||
require_id(value, "deployment selector")
|
||||
}
|
||||
|
|
@ -10641,6 +10725,43 @@ const DETAIL_EMPTY: &str = "(no detail)";
|
|||
/// only judge, and this output always satisfies it. NUL is dropped, empty
|
||||
/// input gets a fixed placeholder, and an over-long value keeps its head and
|
||||
/// its tail (where a build log's error is) cut on char boundaries.
|
||||
/// The journal line for a failed runner step: the transaction, the subject and
|
||||
/// status (the failure's `Display`), and where the evidence is. Never output.
|
||||
fn runner_failure_journal_line(
|
||||
transaction: &DeploymentTransaction,
|
||||
failure: &RunnerStepFailure,
|
||||
store_path: &Path,
|
||||
recorded: &Result<Kept>,
|
||||
) -> String {
|
||||
let head = format!(
|
||||
"Idunn transaction {} (command {}, target {}) {failure}",
|
||||
transaction.transaction_id, transaction.command_id, transaction.target
|
||||
);
|
||||
match recorded {
|
||||
Ok(kept) => {
|
||||
let aside = match kept {
|
||||
Kept::Stored => String::new(),
|
||||
Kept::SetAside { replaced } => format!(
|
||||
"; an undecodable evidence store was set aside as {}{}",
|
||||
unreadable_store_path(store_path).display(),
|
||||
match replaced {
|
||||
Some(last_written) => format!(
|
||||
", replacing an earlier set-aside store last written at unix millis {last_written}"
|
||||
),
|
||||
None => String::new(),
|
||||
}
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}; evidence in {}{aside}; read it with idunn status --command {}",
|
||||
store_path.display(),
|
||||
transaction.command_id
|
||||
)
|
||||
}
|
||||
Err(error) => format!("{head}; evidence was not kept: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_detail(value: &str) -> String {
|
||||
let value = value.replace(' | ||||