Skip to main content

ignition_core/actions/
rig.rs

1//! Rig lifecycle actions (04-01, RIG-01): `up` / `down` / `status` —
2//! serde models OUT, no printing (the TUI rides this layer in Phase 6).
3//! Extended by 04-02 (reset/logs), 04-03 (trial), 04-04
4//! (snapshot/restore, RIG-04).
5//!
6//! Every action takes [`&dyn ComposeRunner`] (the Task-1 seam) so the
7//! full decision tree is unit-testable without docker. `rig_up`
8//! additionally takes an OPTIONAL gateway probe: the dispatch derives
9//! the rig's gateway URL ([`gateway_url_from`]) and builds a
10//! HEADER-LESS client pointed at it — the commissioned wait probes
11//! `/StatusPing` on the RIG's own port, never the profile's gateway.
12//!
13//! ## Uncommissioned is DATA, not failure (research Pattern 4)
14//!
15//! A fresh-volume rig terminally reports `"up, uncommissioned"` — exit
16//! 0 with the wizard URL inside `warnings` (the version-command
17//! degradation precedent). There is NO headless commissioning (verified:
18//! no commissioning endpoints in 83-api); the wait deadline only covers
19//! STARTING→RUNNING on an already-commissioned volume.
20//!
21//! ## The wait reuses poll.rs VERBATIM (locked)
22//!
23//! [`crate::poll`] is THE wait engine — its retry set is LOCKED and
24//! untouched. Probe translation (research Pattern 4): `RUNNING` →
25//! Done; other states → Pending; `GatewayNotCommissioned` → the probe
26//! ITSELF catches it and returns Pending with the wizard hint (never
27//! aborts); Network/GatewayRestarting propagate for poll's native
28//! retry; Auth can't fire (the probe is header-less).
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::time::Duration;
34
35use serde::Serialize;
36
37use crate::client::GatewayApi;
38use crate::config::ModuleDeclaration;
39use crate::error::CoreError;
40use crate::module::ModuleSpec;
41use crate::poll::{self, PollConfig, PollState};
42use crate::rig::compose::{
43    ComposeRunner, check_output, compose_version, docker_ps_publish_args, down_args, logs_args,
44    parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, reset_preview,
45    up_args, volume_ls_args,
46};
47use crate::rig::modules::{
48    ModuleProvisioning, OrphanedModule, ProvisionedModule, existing_override,
49};
50use crate::rig::{RigPlan, port_preflight};
51
52/// Default wait budget for BOTH `up --wait-timeout` and the
53/// commissioned probe deadline (research Pitfall 3: healthchecks block
54/// `--wait`, and image pulls add minutes).
55pub const DEFAULT_WAIT_TIMEOUT_S: u64 = 300;
56
57/// The state a fully-ready gateway reports on `/StatusPing`.
58const RUNNING: &str = "RUNNING";
59
60/// Gateway target ports (the documented heuristic inputs).
61const GATEWAY_HTTP_TARGET: u16 = 8088;
62const GATEWAY_HTTPS_TARGET: u16 = 443;
63
64/// `ign rig up` output model (all keys always present).
65#[derive(Debug, Serialize)]
66pub struct RigUpResult {
67    /// Compose project name — the identity truth.
68    pub rig: String,
69    /// Compose project name (same truth; kept distinct from `rig` for
70    /// future alias-vs-project divergence).
71    pub project: String,
72    /// `"running"` | `"uncommissioned"`.
73    pub state: String,
74    /// The derived gateway URL (what the commissioned wait probed);
75    /// `null` when no 8088/443 mapping exists.
76    pub gateway_url: Option<String>,
77    /// Data-level warnings (uncommissioned wizard hint, skipped-wait
78    /// note) — exit 0 carries them here, never on stderr.
79    pub warnings: Vec<String>,
80    /// Modules provisioned this call (Phase 16) — ALWAYS present, empty
81    /// for a rig with no `[rigs.NAME.modules.*]` declared (SC-1: an
82    /// unprovisioned rig's result shape gains only this always-empty
83    /// field, never a behavior change).
84    pub provisioned_modules: Vec<ProvisionedModule>,
85    /// Modules `ign` provisioned LAST time that this rig no longer
86    /// declares (Phase 19) — ALWAYS present, empty for a rig that
87    /// provisioned nothing or whose modules are unchanged. This is a
88    /// REPORT: `rig up` drops the mount and never removes the module
89    /// from the gateway (D-19-01); each entry's `remove_with` names the
90    /// verb that does.
91    pub orphaned_modules: Vec<OrphanedModule>,
92}
93
94/// `ign rig down` output model.
95#[derive(Debug, Serialize)]
96pub struct RigDownResult {
97    /// Compose project name.
98    pub rig: String,
99    /// Compose project name.
100    pub project: String,
101    /// Always `"down"` on success.
102    pub state: String,
103}
104
105/// `ign rig reset` output model (04-02, all keys always).
106#[derive(Debug, Serialize)]
107pub struct RigResetResult {
108    /// Compose project name — the identity truth.
109    pub rig: String,
110    /// Compose project name.
111    pub project: String,
112    /// The volume names reset removed (the preview, reported as it
113    /// acted — what `-v` took from THIS project only).
114    pub removed_volumes: Vec<String>,
115    /// `"running"` | `"uncommissioned"` (a fresh volume usually boots
116    /// into the wizard — data, exit 0).
117    pub state: String,
118    /// Data-level warnings (uncommissioned wizard hint, skipped-wait).
119    pub warnings: Vec<String>,
120    /// Modules provisioned this call (Phase 16, Task 2) — the SAME
121    /// always-present convention as [`RigUpResult::provisioned_modules`]:
122    /// empty for a rig with no `[rigs.NAME.modules.*]` declared and no
123    /// `--with-module` flag (SC-1).
124    pub provisioned_modules: Vec<ProvisionedModule>,
125    /// Modules `ign` provisioned last time that this rig no longer
126    /// declares (Phase 19) — the SAME always-present, report-only
127    /// convention as [`RigUpResult::orphaned_modules`].
128    pub orphaned_modules: Vec<OrphanedModule>,
129}
130
131/// One published-port row in status output (allowlist only).
132#[derive(Debug, Serialize)]
133pub struct StatusPublisher {
134    /// Host port.
135    pub published_port: Option<u16>,
136    /// Container port.
137    pub target_port: Option<u16>,
138    /// `tcp`/`udp`.
139    pub protocol: Option<String>,
140}
141
142/// One service row in status output (allowlist only).
143#[derive(Debug, Serialize)]
144pub struct StatusService {
145    /// Service name.
146    pub name: String,
147    /// `running` / `exited` / …
148    pub state: String,
149    /// `healthy` / `starting` / `null`.
150    pub health: Option<String>,
151    /// Last exit code.
152    pub exit_code: Option<i64>,
153    /// Published ports.
154    pub publishers: Vec<StatusPublisher>,
155}
156
157/// `ign rig status` output model — an ALLOWLIST, never a compose
158/// config/inspect passthrough (the resolved config contains
159/// `GATEWAY_ADMIN_PASSWORD` etc.; research anti-pattern).
160#[derive(Debug, Serialize)]
161pub struct RigStatusResult {
162    /// Compose project name.
163    pub rig: String,
164    /// Compose project name.
165    pub project: String,
166    /// The compose file the rig resolved from.
167    pub compose_file: String,
168    /// One row per RUNNING-OR-EXITED compose service (empty when the
169    /// rig is down — state is data, exit 0).
170    pub services: Vec<StatusService>,
171    /// Named volumes `reset` would remove (project-labeled).
172    pub volumes: Vec<String>,
173    /// True when NO docker container currently publishes any of the
174    /// rig's host ports (a running rig holds its own ports → false).
175    pub ports_free: bool,
176}
177
178/// Derive the rig's gateway URL from the resolved port mappings — the
179/// DOCUMENTED HEURISTIC: the first mapping targeting the gateway's http
180/// port (8088) wins, then its https port (443), else nothing. `data`
181/// carries what was derived (`gateway_url`), so agents can see the
182/// heuristic's answer.
183pub fn gateway_url_from(plan: &RigPlan) -> Option<String> {
184    if let Some(mapping) = plan
185        .port_mappings
186        .iter()
187        .find(|mapping| mapping.target == GATEWAY_HTTP_TARGET)
188    {
189        return Some(format!("http://localhost:{}", mapping.published));
190    }
191    if let Some(mapping) = plan
192        .port_mappings
193        .iter()
194        .find(|mapping| mapping.target == GATEWAY_HTTPS_TARGET)
195    {
196        return Some(format!("https://localhost:{}", mapping.published));
197    }
198    None
199}
200
201/// `ign rig up`: version gate → port pre-flight → `up -d --wait` →
202/// commissioned wait (poll.rs) with uncommissioned-as-data semantics.
203///
204/// `gateway` is the optional probe client the dispatch builds from
205/// [`gateway_url_from`] — when absent (or when no gateway port is
206/// derivable) the wait is skipped with a data-level warning and the
207/// compose `--wait` result stands on its own.
208///
209/// `provisioning` (Phase 16) carries the override file (if any) and the
210/// per-module outcome the caller already resolved via
211/// [`crate::rig::modules::provision_modules`] — `rig_up` never fetches;
212/// it only threads `provisioning.override_files()` into [`up_args`]. A
213/// `&ModuleProvisioning::default()` (no override, no modules) reproduces
214/// today's behavior byte-for-byte (SC-1).
215pub async fn rig_up(
216    runner: &dyn ComposeRunner,
217    plan: &RigPlan,
218    wait_timeout_s: u64,
219    gateway: Option<&dyn GatewayApi>,
220    provisioning: &ModuleProvisioning,
221) -> Result<RigUpResult, CoreError> {
222    // 1. Fail fast on a missing/too-old compose (exit 7 + install hint).
223    compose_version(runner).await?;
224
225    // 2. Port pre-flight — cross-project occupants abort BEFORE any
226    //    container is touched, with attribution (first conflict named;
227    //    pre-flight collected them all).
228    if let Some(conflict) = port_preflight(runner, plan).await?.first() {
229        return Err(CoreError::Rig(format!(
230            "port {} in use by {} — stop it or change the rig's published port",
231            conflict.port, conflict.attribution
232        )));
233    }
234
235    // 3. The up itself — the override (if any) rides as the SECOND -f.
236    let output = runner
237        .run(&up_args(
238            plan,
239            wait_timeout_s,
240            provisioning.override_files(),
241        ))
242        .await;
243    check_output(&output, "docker compose up")?;
244
245    let gateway_url = gateway_url_from(plan);
246    let mut warnings = Vec::new();
247    let state = match (gateway, &gateway_url) {
248        (Some(api), Some(url)) => {
249            commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
250        }
251        _ => {
252            warnings.push(
253                "no gateway port mapping (target 8088/443) found — skipped the \
254                 commissioned wait"
255                    .to_string(),
256            );
257            "running".to_string()
258        }
259    };
260
261    Ok(RigUpResult {
262        rig: plan.name.clone(),
263        project: plan.name.clone(),
264        state,
265        gateway_url,
266        warnings,
267        provisioned_modules: provisioning.modules.clone(),
268        orphaned_modules: provisioning.orphaned_modules.clone(),
269    })
270}
271
272/// The commissioned wait (research Pattern 4): poll the rig's own
273/// `/StatusPing` (header-less — auth can never block readiness) until
274/// RUNNING. Deadline expiry with a terminal uncommissioned observation
275/// degrades to SUCCESS-as-data (`state = "uncommissioned"` + wizard
276/// hint in `warnings`); expiry while merely STARTING is a Rig error.
277async fn commissioned_wait(
278    api: &dyn GatewayApi,
279    url: &str,
280    wait_timeout_s: u64,
281    warnings: &mut Vec<String>,
282) -> Result<String, CoreError> {
283    let cfg = PollConfig {
284        subject: format!("rig gateway RUNNING (GET {url}/StatusPing)"),
285        interval: Duration::from_secs(2),
286        deadline: Duration::from_secs(wait_timeout_s),
287        ..PollConfig::default()
288    };
289    // The poll-owned state: a borrowed Mutex (the 02-05 HRTB shape;
290    // Mutex not Cell so the probe future is Send — the 06-02 TUI spawns
291    // waits) that remembers a terminal-uncommissioned observation.
292    let mut uncommissioned = Mutex::new(false);
293    let url_owned = url.to_string();
294    let outcome = poll::poll(cfg, &mut uncommissioned, |uncommissioned| {
295        Box::pin(async {
296            match api.status_ping().await {
297                Ok(ping) if ping.state == RUNNING => Ok(PollState::<()>::Done(())),
298                Ok(ping) => Ok(PollState::Pending(Some(ping.state))),
299                // Probe-side translation (locked): never abort on the
300                // wizard redirect — remember it, keep waiting.
301                Err(CoreError::GatewayNotCommissioned { .. }) => {
302                    *uncommissioned.get_mut().expect("commissioned flag") = true;
303                    Ok(PollState::Pending(Some(format!(
304                        "gateway uncommissioned — open {url_owned}/welcome"
305                    ))))
306                }
307                // Network/GatewayRestarting: poll's native retry set.
308                Err(other) => Err(other),
309            }
310        })
311    })
312    .await;
313    match outcome {
314        Ok(()) => Ok("running".to_string()),
315        // The DEADLINE error only (the locked source:None marker from
316        // 02-04) degrades to data when the terminal observation was
317        // uncommissioned — any other error class stays an error.
318        Err(CoreError::Network { source: None, .. })
319            if *uncommissioned.lock().expect("commissioned flag") =>
320        {
321            warnings.push(format!(
322                "gateway uncommissioned — open {url}/welcome in a browser and complete \
323                 the commissioning wizard (no headless commissioning exists)"
324            ));
325            Ok("uncommissioned".to_string())
326        }
327        Err(other) => Err(CoreError::Rig(format!(
328            "gateway did not reach RUNNING within {wait_timeout_s}s — {other}"
329        ))),
330    }
331}
332
333/// `ign rig down`: version gate → `down --remove-orphans` (volumes
334/// KEPT — the `-v` teardown half belongs to `rig reset`, 04-02).
335///
336/// `rig down` never provisions (Phase 16, Task 3) — it derives its
337/// override from what [`existing_override`] finds on disk, which is the
338/// whole truth about what the last `up` used. An undeclared rig (or one
339/// whose override was already deleted) finds nothing and reproduces the
340/// pre-Phase-16 vector exactly (SC-1).
341pub async fn rig_down(
342    runner: &dyn ComposeRunner,
343    plan: &RigPlan,
344) -> Result<RigDownResult, CoreError> {
345    compose_version(runner).await?;
346    let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
347    let output = runner.run(&down_args(plan, false, &override_files)).await;
348    check_output(&output, "docker compose down")?;
349    Ok(RigDownResult {
350        rig: plan.name.clone(),
351        project: plan.name.clone(),
352        state: "down".to_string(),
353    })
354}
355
356/// `ign rig reset` (04-02, RIG-01): the guarded teardown + bring-up
357/// cycle — NO stale project/trial state survives. The CLI guard
358/// (`--yes`, exit 2 before ANY resolution) lives in the dispatch (the
359/// sessions-terminate/project-delete layering); this action is the
360/// decision-complete cycle behind it:
361///
362/// 1. `reset_preview` — the project's volume names, captured for the
363///    result data (agents see what reset removes before/as it acts);
364/// 2. version gate (fail fast on missing/too-old compose);
365/// 3. `down -v --remove-orphans` — the LOCKED teardown (research:
366///    `down && up` without `-v` is the classic stale-state
367///    anti-pattern; anonymous strays and renamed-service orphans die
368///    via `--remove-orphans`);
369/// 4. `port_preflight` — AFTER teardown, BEFORE the up half: teardown
370///    frees OUR ports first, then fresh eyes catch another rig that
371///    grabbed a freed port mid-cycle;
372/// 5. `up -d --wait` (the [`rig_up`] invocation verbatim);
373/// 6. commissioned wait — [`commissioned_wait`], the ONE shared fn
374///    (the 04-01 probe reused verbatim; `poll.rs` untouched).
375///
376/// `provisioning` (Phase 16) is resolved ONCE by the caller, before the
377/// teardown, so the down and up halves of the cycle see one consistent
378/// override file set (SC-3's mechanism half — a stale override never
379/// changes mid-cycle). A `&ModuleProvisioning::default()` reproduces
380/// today's behavior byte-for-byte (SC-1).
381pub async fn rig_reset(
382    runner: &dyn ComposeRunner,
383    plan: &RigPlan,
384    wait_timeout_s: u64,
385    gateway: Option<&dyn GatewayApi>,
386    provisioning: &ModuleProvisioning,
387) -> Result<RigResetResult, CoreError> {
388    // 1. The preview — data for the result, reported as it acts.
389    let removed_volumes = reset_preview(runner, plan).await?;
390
391    // 2. Fail fast on a missing/too-old compose.
392    compose_version(runner).await?;
393
394    // 3. Teardown: down -v --remove-orphans (volumes die here). The SAME
395    //    override file set as the up half below — resolved once by the
396    //    caller before this cycle started (SC-3's mechanism half).
397    let output = runner
398        .run(&down_args(plan, true, provisioning.override_files()))
399        .await;
400    check_output(&output, "docker compose down")?;
401
402    // 4. Port pre-flight with fresh eyes — between the halves.
403    if let Some(conflict) = port_preflight(runner, plan).await?.first() {
404        return Err(CoreError::Rig(format!(
405            "port {} in use by {} — stop it or change the rig's published port \
406             (the rig is torn down; re-run `rig up` once the port frees)",
407            conflict.port, conflict.attribution
408        )));
409    }
410
411    // 5. The up half — the SAME override file set as the teardown.
412    let output = runner
413        .run(&up_args(
414            plan,
415            wait_timeout_s,
416            provisioning.override_files(),
417        ))
418        .await;
419    check_output(&output, "docker compose up")?;
420
421    // 6. Commissioned wait — the shared fn (uncommissioned-as-data).
422    let gateway_url = gateway_url_from(plan);
423    let mut warnings = Vec::new();
424    let state = match (gateway, &gateway_url) {
425        (Some(api), Some(url)) => {
426            commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
427        }
428        _ => {
429            warnings.push(
430                "no gateway port mapping (target 8088/443) found — skipped the \
431                 commissioned wait"
432                    .to_string(),
433            );
434            "running".to_string()
435        }
436    };
437
438    Ok(RigResetResult {
439        rig: plan.name.clone(),
440        project: plan.name.clone(),
441        removed_volumes,
442        state,
443        warnings,
444        provisioned_modules: provisioning.modules.clone(),
445        orphaned_modules: provisioning.orphaned_modules.clone(),
446    })
447}
448
449/// `ign rig logs` output model (04-02, RIG-02): only the count — the
450/// lines themselves already streamed through the sink (the third
451/// sanctioned stdout exception; the dispatch owns the printing, the
452/// `logs -f` precedent).
453#[derive(Debug, Serialize)]
454pub struct RigLogsResult {
455    /// Lines delivered to the sink.
456    pub streamed: usize,
457}
458
459/// The banners cross-check block of [`TrialStatusResult`] (all keys
460/// always; a failed banners fetch degrades to nulls + a warning —
461/// the trial endpoint is the primary truth).
462#[derive(Debug, Serialize)]
463pub struct TrialBanners {
464    /// The trial banner's `severity` verbatim (`"info"` / `"warning"`);
465    /// `null` when no trial banner or the fetch failed.
466    pub severity: Option<String>,
467    /// The trial banner's `expireTime` in epoch **MILLISECONDS** —
468    /// `null` when expired/unknown (Pitfall 7).
469    pub expire_time_ms: Option<i64>,
470    /// The Pitfall-7 cross-check: `severity == "info"` AND
471    /// `expireTime > now_ms`. NEVER the primary active signal —
472    /// [`TrialStatusResult::expired`] is.
473    pub active: bool,
474}
475
476/// `ign rig trial status` output model (04-03, RIG-02): the trial
477/// endpoint re-exposed under unit-explicit keys (the two-layer naming
478/// LOCK) + the banners cross-check. All keys always present.
479#[derive(Debug, Serialize)]
480pub struct TrialStatusResult {
481    /// `licenseMode` verbatim (`"Trial"` / …).
482    pub license_mode: String,
483    /// `trialState` verbatim (`AllInDemo` / `SomeInDemo` /
484    /// `NoneInDemo`).
485    pub trial_state: String,
486    /// `trialSecondsLeft` — epoch **SECONDS** (the `_s` suffix is the
487    /// unit contract).
488    pub trial_remaining_s: i64,
489    /// The primary expiry truth (never derived from banners).
490    pub expired: bool,
491    /// Emergency-license flag.
492    pub emergency: bool,
493    /// `emergencySecondsLeft` — epoch **SECONDS**.
494    pub emergency_remaining_s: i64,
495    /// Development-license flag.
496    pub development: bool,
497    /// The banners cross-check block.
498    pub banners: TrialBanners,
499    /// Data-level warnings (banners fetch failed, …) — exit 0 carries
500    /// them here, never on stderr.
501    pub warnings: Vec<String>,
502}
503
504/// Wall-clock epoch milliseconds (the banners `expireTime` unit).
505fn epoch_ms_now() -> i64 {
506    std::time::SystemTime::now()
507        .duration_since(std::time::UNIX_EPOCH)
508        .expect("system clock is after the unix epoch")
509        .as_millis() as i64
510}
511
512/// `ign rig trial status` (04-03, RIG-02): the trial endpoint is the
513/// PRIMARY truth; the trial banner (`type: "trial"`) is the
514/// cross-check, its `active` flag computed per Pitfall 7
515/// (`severity=="info" && expireTime>now_ms` — never the reverse
516/// derivation). A failed banners fetch degrades to nulls + a warning
517/// (the trial endpoint already answered; the cross-check is
518/// advisory). `gateway` is a client pointed at the RIG's URL — these
519/// endpoints answer unauthenticated (live-verified both rigs), so a
520/// fresh rig with no token reports its trial state fine.
521pub async fn trial_status(gateway: &dyn GatewayApi) -> Result<TrialStatusResult, CoreError> {
522    let wire = gateway.trial_status_wire().await?;
523    let mut warnings = Vec::new();
524    let banners = match gateway.banners().await {
525        Ok(set) => {
526            let trial_banner = set.banners.iter().find(|banner| banner.r#type == "trial");
527            match trial_banner {
528                Some(banner) => {
529                    let active = banner.data.severity == "info"
530                        && banner
531                            .data
532                            .expire_time_ms
533                            .is_some_and(|ms| ms > epoch_ms_now());
534                    TrialBanners {
535                        severity: Some(banner.data.severity.clone()),
536                        expire_time_ms: banner.data.expire_time_ms,
537                        active,
538                    }
539                }
540                None => TrialBanners {
541                    severity: None,
542                    expire_time_ms: None,
543                    active: false,
544                },
545            }
546        }
547        Err(err) => {
548            warnings.push(format!(
549                "banners cross-check unavailable ({}); the trial endpoint's \
550                 expired flag is the truth",
551                err
552            ));
553            TrialBanners {
554                severity: None,
555                expire_time_ms: None,
556                active: false,
557            }
558        }
559    };
560    Ok(TrialStatusResult {
561        license_mode: wire.license_mode,
562        trial_state: wire.trial_state,
563        trial_remaining_s: wire.trial_seconds_left,
564        expired: wire.expired,
565        emergency: wire.emergency,
566        emergency_remaining_s: wire.emergency_seconds_left,
567        development: wire.development,
568        banners,
569        warnings,
570    })
571}
572
573/// `ign rig trial reset` output model (04-03, RIG-03): the ladder's
574/// outcome — which rung landed, and the before/after flip (the flip
575/// is REQUIRED for success; a bare 2xx never suffices).
576#[derive(Debug, Serialize)]
577pub struct TrialResetResult {
578    /// The rig's gateway URL the ladder ran against.
579    pub rig_url: String,
580    /// `"token"` (tier 0 — token-auth POST through the client
581    /// pipeline) | `"login"` (tier 1 — the native OIDC session+CSRF
582    /// flow).
583    pub mechanism: String,
584    /// The pre-reset `expired` flag (always true on the success path —
585    /// the action refuses non-expired trials up front).
586    pub expired_before: bool,
587    /// The post-reset `expired` flag (false — verified by READ-BACK,
588    /// never trusted from the POST alone).
589    pub expired_after: bool,
590    /// The fresh countdown in epoch **SECONDS** (≈7200 = a full new
591    /// trial window).
592    pub trial_remaining_s: i64,
593}
594
595/// The DOWN-rig pre-flight for gateway-addressing rig verbs (trial
596/// reset) — live-witnessed 2026-10-11: fired at a DOWN rig whose
597/// port a FOREIGN gateway squatted, the ladder AUTHENTICATED against
598/// the squatter (the rig family derives the gateway URL from the
599/// compose file, never from what actually answers). Refuses exit 7
600/// (`rig_error`) with the honest target state: no running service,
601/// no knock. Rides [`rig_status`]'s read (a down rig is Ok-data
602/// there — state is data; here the state REFUSES the verb).
603///
604/// The check keys on the plan's GATEWAY service
605/// ([`RigPlan::gateway_service`] — the 8088/443-publishing service),
606/// not ANY service: a running `db` beside a stopped gateway must not
607/// pass (review finding — that is the squatter-knock again, one
608/// service over). A plan with no gateway service skips the check:
609/// the verb's own no-gateway-port refusal names that state better.
610pub async fn require_rig_up_for_verb(
611    runner: &dyn ComposeRunner,
612    plan: &RigPlan,
613    verb: &str,
614) -> Result<(), CoreError> {
615    let Some(gateway_service) = plan.gateway_service.as_deref() else {
616        return Ok(());
617    };
618    let status = rig_status(runner, plan).await?;
619    let gateway_running = status
620        .services
621        .iter()
622        .any(|service| service.name == gateway_service && service.state == "running");
623    if gateway_running {
624        Ok(())
625    } else {
626        Err(CoreError::Rig(format!(
627            "rig {:?} is DOWN — {verb} addresses the rig's gateway and its gateway \
628             service {gateway_service:?} is not running; `ign rig up` brings it back \
629             (if its ports report held, whatever squats on them is NOT the rig)",
630            plan.name
631        )))
632    }
633}
634
635/// `ign rig trial reset` (04-03, RIG-03): the evidence-chosen LADDER —
636/// tier 0 (token-auth `POST /trial` through the existing client, one
637/// cheap call) falls through to tier 1 (the native OIDC login →
638/// session+CSRF POST, [`crate::client::idp`], live-verified
639/// end-to-end on 8.3.3 with the `expired:true → false` flip).
640///
641/// **State gate (live-discovered):** the gateway 403s resets on a
642/// NON-expired trial — verified from the browser page itself with the
643/// exact UI headers. The pre-check refuses those up front
644/// ([`CoreError::TrialNotExpired`]) so the refusal stays an honest
645/// target-state error instead of a misleading auth-shaped 403.
646///
647/// **DOWN-rig pre-flight (live-witnessed 2026-10-11):** fired at a
648/// DOWN rig whose port a FOREIGN gateway squatted, the ladder
649/// AUTHENTICATED against the squatter (token POST → 401 there) —
650/// the rig family derives the gateway URL from the compose file,
651/// not from what actually answers. [`require_rig_up_for_verb`] is
652/// the callers' shared refusal: no running service, no knock.
653///
654/// Success REQUIRES the read-back flip: after a 2xx the trial is
655/// re-fetched and `expired` must be false (mutations read back — the
656/// 03-01 find precedent; no trusting the POST's word alone).
657///
658/// `gateway` is a client pointed at the rig's URL (carrying the tier-0
659/// token when one resolved); `basic` is the tier-1 credential pair
660/// (`--user`/`IGNITION_USER` + `IGNITION_PASSWORD` — the secret
661/// chain's basic tail). At least one rung must have its credential.
662pub async fn trial_reset(
663    gateway: &dyn GatewayApi,
664    rig_url: &str,
665    token_available: bool,
666    basic: Option<(&str, &crate::config::Secret)>,
667) -> Result<TrialResetResult, CoreError> {
668    // 1. The state pre-check: the honest refusal for non-expired
669    //    trials (the live-discovered 403 state gate).
670    let before = gateway.trial_status_wire().await?;
671    if !before.expired {
672        return Err(CoreError::TrialNotExpired {
673            remaining_s: before.trial_seconds_left,
674            endpoint: Some(format!("{rig_url}/data/api/v1/trial")),
675        });
676    }
677
678    // 2. Tier 0 — token-auth POST (only when the dispatch resolved a
679    //    token credential into the client). ANY failure falls through
680    //    to tier 1 (the 403 state gate is pre-checked away; a failure
681    //    here means the token was refused — the login rung decides).
682    if token_available {
683        match gateway.trial_reset_wire().await {
684            Ok(_fresh) => {
685                let after = gateway.trial_status_wire().await?;
686                return finish(rig_url, "token", after);
687            }
688            Err(err) => {
689                tracing::warn!(
690                    error = %err,
691                    "trial-reset tier 0 (token-auth POST) failed — falling through to the login rung"
692                );
693            }
694        }
695    }
696
697    // 3. Tier 1 — the native OIDC session+CSRF flow (the
698    //    live-verified mechanism).
699    let Some((username, password)) = basic else {
700        // No token that worked AND no login pair: the HONEST tail
701        // (2026-10-11 — the old SecretUnavailable{profile: URL} tail
702        // rendered a URL in the profile slot and hinted at a nonsense
703        // keyring entry for it). Rig-class (exit 7): the failure is
704        // THIS operation's credential sourcing, and the message
705        // names both rungs — accurately: "the POST was refused" only
706        // when a POST was actually attempted (review finding).
707        let detail = if token_available {
708            "the token POST was refused and no login pair (IGNITION_USER + \
709             IGNITION_PASSWORD) is set"
710        } else {
711            "no API token (IGNITION_TOKEN) and no login pair (IGNITION_USER + \
712             IGNITION_PASSWORD) resolved"
713        };
714        return Err(CoreError::Rig(format!(
715            "trial reset at {rig_url}: {detail} — the reset needs one working \
716             credential for the rig's gateway"
717        )));
718    };
719    let flow = crate::client::idp::IdpLoginFlow::new(rig_url)?;
720    let (flow, session) = crate::client::idp::login(flow, username, password).await?;
721    crate::client::idp::trial_reset_via_session(&flow, &session).await?;
722    // The read-back flip through the normal pipeline (step 10).
723    let after = gateway.trial_status_wire().await?;
724    finish(rig_url, "login", after)
725}
726
727/// The shared success tail: the flip check + result assembly.
728fn finish(
729    rig_url: &str,
730    mechanism: &str,
731    after: crate::client::trial::TrialWire,
732) -> Result<TrialResetResult, CoreError> {
733    if after.expired {
734        return Err(CoreError::Internal(format!(
735            "trial reset was accepted but the read-back still reports expired \
736             ({}s left) — re-run `rig trial status` to see the gateway's answer",
737            after.trial_seconds_left
738        )));
739    }
740    Ok(TrialResetResult {
741        rig_url: rig_url.to_string(),
742        mechanism: mechanism.to_string(),
743        expired_before: true,
744        expired_after: after.expired,
745        trial_remaining_s: after.trial_seconds_left,
746    })
747}
748
749/// `ign rig logs` (04-02, RIG-02): compose log PASSTHROUGH — a raw
750/// line stream through `sink`, never an envelope-wrapped body.
751/// Compose log lines are not gateway JSON objects; wrapping would
752/// corrupt them, so `rig logs --json` is the SAME passthrough in
753/// every render mode (contrast `logs -f --json`, whose entries ARE
754/// gateway NDJSON — the second exception). Follow mode rides the
755/// runner's STREAMING shape (piped stdout forwarded as it arrives
756/// until EOF/child exit; Ctrl-C kills the foreground process group —
757/// README §Streaming, the `logs -f` precedent). Compose stderr
758/// (diagnostics) goes to OUR stderr via tracing::warn — never the
759/// data sink.
760pub async fn rig_logs(
761    runner: &dyn ComposeRunner,
762    plan: &RigPlan,
763    tail: u32,
764    follow: bool,
765    service: Option<&str>,
766    sink: &mut (dyn FnMut(String) + Send),
767) -> Result<RigLogsResult, CoreError> {
768    // `rig logs` never provisions (Phase 16, Task 3) — same disk-derived
769    // override discipline as `rig_down`/`rig_status`.
770    let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
771    let args = logs_args(plan, tail, follow, service, &override_files);
772    let mut streamed = 0usize;
773    let output = if follow {
774        // Follow: streamed through the runner's piped-stdout shape;
775        // the returned stdout is empty (the lines already delivered).
776        let mut forwarder = |line: &str| {
777            streamed += 1;
778            sink(line.to_string());
779        };
780        runner.run_streaming(&args, &mut forwarder).await
781    } else {
782        runner.run(&args).await
783    };
784    if !output.stderr.trim().is_empty() {
785        tracing::warn!(
786            source = "docker compose logs",
787            stderr = %output.stderr.trim(),
788            "compose diagnostics (stderr passthrough — never the data sink)"
789        );
790    }
791    // One-shot: the captured stdout splits line-wise into the sink.
792    let stdout = check_output(&output, "docker compose logs")?;
793    for line in stdout.lines() {
794        sink(line.to_string());
795        streamed += 1;
796    }
797    Ok(RigLogsResult { streamed })
798}
799
800// -------------------------------------------------------------------------
801// snapshot + restore (04-04, RIG-04) — repeatable state
802// -------------------------------------------------------------------------
803
804/// The restore wait FLOOR: the gateway RESTARTS after a restore
805/// (Pitfall 6), so the post-restore RUNNING wait never gets a shorter
806/// deadline than this — an explicit `--timeout 30` cannot buy an
807/// unknown-state mid-restart report (the RESTART_FLOOR precedent,
808/// restore edition).
809pub const RESTORE_WAIT_FLOOR_S: u64 = 300;
810
811/// The token-clobber warning (Pitfall 5, 83-api primary source):
812/// tokens stored under CORE config are "modified/cleared often by gwbk
813/// restores" — post-restore, stored profiles 401. It rides DATA
814/// (agents must see it), never stderr-only.
815pub const RESTORE_TOKEN_WARNING: &str = "API tokens may have been reset by restore \
816— re-provision via gateway UI, then ign doctor";
817
818/// The manifest's honesty notes — BOTH composition exclusions, verbatim
819/// (the roadmap criterion-4 scope-explicitity precedent from 03-02:
820/// the tag-export deferral rides the manifest itself so no reader can
821/// mistake it for a silent drop).
822const MANIFEST_NOTES: [&str; 2] = [
823    "trial clock state is NOT captured by gwbk (unknown behavior — reset \
824     separately via rig trial reset)",
825    "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
826     config via gateway data",
827];
828
829/// `ign rig snapshot` output model (04-04, RIG-04) — all keys always.
830#[derive(Debug, Serialize)]
831pub struct SnapshotResult {
832    /// The snapshot directory (as resolved: the `-o` override or the
833    /// `./ign-rig-snapshots/<rig>-<stamp>/` default).
834    pub dir: String,
835    /// The gwbk's size in bytes (chunk-counted by the streaming
836    /// download — never a buffered length).
837    pub gwbk_bytes: u64,
838    /// The projects exported, in list order.
839    pub projects: Vec<String>,
840    /// The manifest's path inside the dir.
841    pub manifest_path: String,
842}
843
844/// `ign rig restore` output model (04-04, RIG-04) — all keys always.
845#[derive(Debug, Serialize)]
846pub struct RestoreResult {
847    /// The gwbk restored from (as given).
848    pub restored_from: String,
849    /// The post-restore state WITNESSED by the RUNNING wait —
850    /// `"running"` | `"uncommissioned"` (a bare 2xx never suffices;
851    /// Pitfall 6).
852    pub state: String,
853    /// Data-level warnings — ALWAYS carries
854    /// [`RESTORE_TOKEN_WARNING`] (Pitfall 5) plus any wait warnings.
855    pub warnings: Vec<String>,
856}
857
858/// `ign rig module uninstall` output model (Phase 19, D-19-01/D-19-02)
859/// — all keys always present (the `RigUpResult` convention).
860#[derive(Debug, Serialize)]
861pub struct ModuleUninstallResult {
862    /// Compose project name — the identity truth.
863    pub rig: String,
864    /// The registry slug the caller named (`git`).
865    pub module_id: String,
866    /// The gateway's own id that was sent in the DELETE
867    /// ([`crate::module::ModuleSpec::gateway_module_id`]).
868    pub gateway_module_id: String,
869    /// `true` only on `Ok` — this call never returns any other value
870    /// (a denial is an `Err`, never a `false` here).
871    pub uninstalled: bool,
872}
873
874/// The still-declared refusal (Task 2): a registry id the rig's
875/// `[rigs.NAME.modules]` table STILL names would be undone by the very
876/// next `ign rig up` — the gateway itself already refuses this case
877/// live (fact 2: it answers `success:false` while the `.modl` is still
878/// bind-mounted), so this turns that confusing gateway denial into an
879/// actionable LOCAL one, exit 7 (`CoreError::Rig`), naming the rig, the
880/// module, the config table to edit, and `ign rig up` as the fix.
881///
882/// Shared by the CLI's pre-check (main.rs, run BEFORE any gateway
883/// client is built — the sessions-terminate/`preflight_with_module_
884/// flags` ordering precedent) and [`rig_module_uninstall`]'s own
885/// authoritative re-check below, so the two call sites can never drift
886/// apart on wording (the `preflight_with_module_flags`/
887/// `provision_modules` precedent).
888pub fn module_still_declared_error(id: &str, rig: &str) -> CoreError {
889    CoreError::Rig(format!(
890        "module {id:?} is still declared in [rigs.{rig}.modules.{id}] — \
891         uninstalling it now would be undone by the very next `ign rig up` \
892         (the gateway refuses to forget a module whose `.modl` is still \
893         mounted); remove that table entry, run `ign rig up` so the mount \
894         is dropped, then retry `ign rig module uninstall {id} --yes`"
895    ))
896}
897
898/// `ign rig module uninstall <ID> --yes` (Phase 19, D-19-01/D-19-02/
899/// D-19-04): the ONE gateway write this phase adds. ONE call, ONE id —
900/// `spec.gateway_module_id` is what rides the wire; `spec.id` (the
901/// registry slug) names the error without a reverse lookup. Success is
902/// the gateway's own `success:true` — never a bare HTTP 200 ([`crate::client::GatewayApi::uninstall_module`]'s
903/// denial-rides-200 contract).
904///
905/// `declared` is the rig's `[rigs.NAME.modules]` table (`RigPlan::
906/// modules`) — the AUTHORITATIVE re-check of the still-declared refusal
907/// (Task 2), a second belt behind the CLI's own pre-check: even a
908/// caller that reaches this function directly (bypassing main.rs'
909/// dispatch arm, as this crate's own tests do) can never issue the
910/// gateway write for a module the config still names.
911pub async fn rig_module_uninstall(
912    api: &dyn GatewayApi,
913    rig: &str,
914    spec: &'static ModuleSpec,
915    declared: &BTreeMap<String, ModuleDeclaration>,
916) -> Result<ModuleUninstallResult, CoreError> {
917    if declared.contains_key(spec.id) {
918        return Err(module_still_declared_error(spec.id, rig));
919    }
920    api.uninstall_module(spec.gateway_module_id, spec.id)
921        .await?;
922    Ok(ModuleUninstallResult {
923        rig: rig.to_string(),
924        module_id: spec.id.to_string(),
925        gateway_module_id: spec.gateway_module_id.to_string(),
926        uninstalled: true,
927    })
928}
929
930/// Days since 1970-01-01 → (year, month, day) — Howard Hinnant's
931/// `civil_from_days` (the CLI renderer's iso_utc algorithm, core
932/// edition — std-only, NO chrono: the research's dependency-free
933/// naming rule).
934fn civil_from_days(days: i64) -> (i64, u32, u32) {
935    let z = days + 719_468;
936    let era = z.div_euclid(146_097);
937    let doe = z.rem_euclid(146_097); // day of era [0, 146096]
938    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // year of era
939    let y = yoe + era * 400;
940    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
941    let mp = (5 * doy + 2) / 153; // month index [0, 11] from March
942    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // day of month [1, 31]
943    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // month [1, 12]
944    (if m <= 2 { y + 1 } else { y }, m, d)
945}
946
947/// Epoch seconds → the UTC `yyyyMMdd-HHmmss` directory-stamp segment.
948fn stamp_from_secs(secs: i64) -> String {
949    let days = secs.div_euclid(86_400);
950    let time_of_day = secs.rem_euclid(86_400);
951    let (year, month, day) = civil_from_days(days);
952    let hour = time_of_day / 3600;
953    let minute = (time_of_day % 3600) / 60;
954    let second = time_of_day % 60;
955    format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}{second:02}")
956}
957
958/// `ign rig snapshot` (04-04, RIG-04): repeatable state, composed
959/// HONESTLY —
960///
961/// 1. the directory: `-o` override, else
962///    `./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/` (std-only stamp,
963///    no new dependency);
964/// 2. the gwbk FIRST (`GET /backup?type=roaming`, streamed to
965///    `<rig>.gwbk` — the primary artifact);
966/// 3. per-project exports via the 03-02 machinery (`projects` list →
967///    `project_export_to_file` per name into `projects/<enc>.zip`;
968///    the percent-encoded name is INJECTIVE and filesystem-safe). The
969///    gwbk *should* already include projects (postman semantics,
970///    MEDIUM confidence — research flags this); the explicit exports
971///    are the honest redundancy that makes the manifest truthful
972///    either way;
973/// 4. `manifest.json` recording the composition — including BOTH
974///    exclusion notes verbatim (trial clock; tag-provider bulk export
975///    deferred to Phase 5). `ignition.version` degrades to `null`
976///    when gateway-info fails (the snapshot itself already
977///    succeeded — the manifest carries the gap visibly).
978///
979/// `gateway` is a client pointed at the RIG's URL (the trial-verb
980/// precedent). Any leg failing fails the snapshot — a partial
981/// snapshot is a corrupt state promise, never silent.
982pub async fn rig_snapshot(
983    gateway: &dyn GatewayApi,
984    rig_name: &str,
985    out_dir: Option<&Path>,
986) -> Result<SnapshotResult, CoreError> {
987    // 1. The directory (std-only stamp — chrono-free by design).
988    let epoch_s = std::time::SystemTime::now()
989        .duration_since(std::time::UNIX_EPOCH)
990        .expect("system clock is after the unix epoch")
991        .as_secs() as i64;
992    let dir: PathBuf = match out_dir {
993        Some(dir) => dir.to_path_buf(),
994        None => PathBuf::from("ign-rig-snapshots").join(format!(
995            "{}-{}",
996            rig_name,
997            stamp_from_secs(epoch_s)
998        )),
999    };
1000    tokio::fs::create_dir_all(&dir)
1001        .await
1002        .map_err(|err| CoreError::Internal(format!("cannot create {}: {err}", dir.display())))?;
1003
1004    // 2. The gwbk FIRST — the primary artifact, streamed to disk.
1005    //    Roaming explicitly (07-02 param-ized the type): the rig's
1006    //    snapshot stays the portable backup, byte-identical behavior.
1007    let gwbk_name = format!("{rig_name}.gwbk");
1008    let meta = gateway
1009        .backup_download(
1010            &dir.join(&gwbk_name),
1011            crate::client::backup::BackupType::Roaming,
1012        )
1013        .await?;
1014
1015    // 3. Per-project exports (the 03-02 machinery reused verbatim).
1016    let page = gateway
1017        .projects(&crate::client::query::ListQuery::default())
1018        .await?;
1019    let projects_dir = dir.join("projects");
1020    let mut exported: Vec<(String, String)> = Vec::new();
1021    for record in &page.items {
1022        if exported.is_empty() {
1023            tokio::fs::create_dir_all(&projects_dir)
1024                .await
1025                .map_err(|err| {
1026                    CoreError::Internal(format!("cannot create {}: {err}", projects_dir.display()))
1027                })?;
1028        }
1029        let file = format!(
1030            "projects/{}.zip",
1031            crate::client::projects::encode_segment(&record.name)
1032        );
1033        gateway
1034            .project_export_to_file(&record.name, &dir.join(&file))
1035            .await?;
1036        exported.push((record.name.clone(), file));
1037    }
1038
1039    // 4. The manifest — the honest composition contract.
1040    let version = gateway
1041        .gateway_info()
1042        .await
1043        .ok()
1044        .map(|info| info.ignition_version);
1045    let manifest = serde_json::json!({
1046        "rig": rig_name,
1047        "taken_at": epoch_s,
1048        "ignition": { "version": version },
1049        "gwbk": gwbk_name,
1050        "projects": exported
1051            .iter()
1052            .map(|(name, file)| serde_json::json!({ "name": name, "file": file }))
1053            .collect::<Vec<_>>(),
1054        "notes": MANIFEST_NOTES,
1055    });
1056    let manifest_path = dir.join("manifest.json");
1057    tokio::fs::write(
1058        &manifest_path,
1059        serde_json::to_vec_pretty(&manifest)
1060            .map_err(|err| CoreError::Internal(format!("manifest serialization failed: {err}")))?,
1061    )
1062    .await
1063    .map_err(|err| {
1064        CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
1065    })?;
1066
1067    Ok(SnapshotResult {
1068        dir: dir.display().to_string(),
1069        gwbk_bytes: meta.bytes,
1070        projects: exported.into_iter().map(|(name, _)| name).collect(),
1071        manifest_path: manifest_path.display().to_string(),
1072    })
1073}
1074
1075/// The restore wait deadline: the requested budget, floored at
1076/// [`RESTORE_WAIT_FLOOR_S`] (Pitfall 6 — a short explicit `--timeout`
1077/// cannot buy an unknown-state mid-restart report; the RESTART_FLOOR
1078/// precedent, restore edition).
1079fn restore_deadline(wait_timeout_s: u64) -> u64 {
1080    wait_timeout_s.max(RESTORE_WAIT_FLOOR_S)
1081}
1082
1083/// `ign rig restore` (04-04, RIG-04): the guarded inverse —
1084///
1085/// 1. file pre-checks (exists + non-empty + readable) →
1086///    [`CoreError::InvalidInput`] exit 2, the 03-03 lesson (no new
1087///    slug), BEFORE any network work;
1088/// 2. `backup_restore(gwbk)` — the raw octet-stream POST; a 2xx means
1089///    the restore was ACCEPTED, nothing more;
1090/// 3. the post-restore wait: the gateway RESTARTS after a restore
1091///    (Pitfall 6), so success is a WITNESSED StatusPing→RUNNING via
1092///    the 04-01 shared [`commissioned_wait`] — never a bare 2xx. The
1093///    deadline floors at [`RESTORE_WAIT_FLOOR_S`] (300 s): an
1094///    explicit short `--timeout` cannot buy an unknown-state report;
1095/// 4. the token-clobber warning rides DATA ([`RESTORE_TOKEN_WARNING`],
1096///    Pitfall 5) — agents must see it in every render mode.
1097///
1098/// `gateway` is a client pointed at the rig's URL; `rig_url` is that
1099/// URL (the [`trial_reset`] signature precedent — the wait's subject
1100/// message needs it).
1101pub async fn rig_restore(
1102    gateway: &dyn GatewayApi,
1103    rig_url: &str,
1104    gwbk: &Path,
1105    wait_timeout_s: u64,
1106) -> Result<RestoreResult, CoreError> {
1107    // 1. Pre-checks — usage-class refusals BEFORE any network work.
1108    //    `is_file()` (not a File::open probe): opening a directory
1109    //    SUCCEEDS on some platforms (macOS) — only the read fails,
1110    //    which would be mid-network. The regular-file check is the
1111    //    portable honest gate.
1112    let meta = std::fs::metadata(gwbk).map_err(|_| CoreError::InvalidInput {
1113        reason: format!("gwbk file {} not found", gwbk.display()),
1114    })?;
1115    if !meta.is_file() {
1116        return Err(CoreError::InvalidInput {
1117            reason: format!("gwbk file {} is not a regular file", gwbk.display()),
1118        });
1119    }
1120    if meta.len() == 0 {
1121        return Err(CoreError::InvalidInput {
1122            reason: format!("gwbk file {} is empty", gwbk.display()),
1123        });
1124    }
1125
1126    // 2. The POST — 2xx = accepted (the synchronous restore completed
1127    //    server-side by the time the answer arrives).
1128    gateway.backup_restore(gwbk).await?;
1129
1130    // 3. The witnessed RUNNING wait (the shared probe; deadline floored).
1131    let deadline_s = restore_deadline(wait_timeout_s);
1132    let mut warnings = Vec::new();
1133    let state = commissioned_wait(gateway, rig_url, deadline_s, &mut warnings).await?;
1134
1135    // 4. The token warning FIRST in data (Pitfall 5).
1136    warnings.insert(0, RESTORE_TOKEN_WARNING.to_string());
1137
1138    Ok(RestoreResult {
1139        restored_from: gwbk.display().to_string(),
1140        state,
1141        warnings,
1142    })
1143}
1144
1145/// `ign rig status`: version gate → `ps` LDJSON → `volume ls` →
1146/// port occupancy — serialized as an ALLOWLIST (services' state/health/
1147/// publishers, volume names, identity). Exit 0 even when the rig is
1148/// down: state is data.
1149pub async fn rig_status(
1150    runner: &dyn ComposeRunner,
1151    plan: &RigPlan,
1152) -> Result<RigStatusResult, CoreError> {
1153    compose_version(runner).await?;
1154
1155    // `rig status` never provisions (Phase 16, Task 3) — the override
1156    // it passes (if any) is whatever `existing_override` finds on disk.
1157    let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
1158    let ps = runner.run(&ps_args(plan, &override_files)).await;
1159    let rows = parse_ps_ldjson(check_output(&ps, "docker compose ps")?);
1160
1161    let volume_ls = runner.run_docker(&volume_ls_args(&plan.name)).await;
1162    let volumes = parse_volume_ls_ldjson(check_output(&volume_ls, "docker volume ls")?)
1163        .into_iter()
1164        .map(|entry| entry.name)
1165        .collect();
1166
1167    // Occupancy signal: any docker container (own project included —
1168    // a running rig holds its own ports) publishing a rig port.
1169    let mut ports_free = true;
1170    for port in &plan.host_ports {
1171        let output = runner.run_docker(&docker_ps_publish_args(*port)).await;
1172        let occupants = parse_docker_ps_ldjson(check_output(&output, "docker ps")?);
1173        if !occupants.is_empty() {
1174            ports_free = false;
1175        }
1176    }
1177
1178    let services = rows
1179        .into_iter()
1180        .map(|row| StatusService {
1181            name: if row.service.is_empty() {
1182                row.name
1183            } else {
1184                row.service
1185            },
1186            state: row.state,
1187            health: row.health,
1188            exit_code: row.exit_code,
1189            publishers: row
1190                .publishers
1191                .into_iter()
1192                .map(|publisher| StatusPublisher {
1193                    published_port: publisher.published_port,
1194                    target_port: publisher.target_port,
1195                    protocol: publisher.protocol,
1196                })
1197                .collect(),
1198        })
1199        .collect();
1200
1201    Ok(RigStatusResult {
1202        rig: plan.name.clone(),
1203        project: plan.name.clone(),
1204        compose_file: plan.compose_file.display().to_string(),
1205        services,
1206        volumes,
1207        ports_free,
1208    })
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use std::collections::VecDeque;
1214    use std::path::{Path, PathBuf};
1215    use std::sync::Mutex;
1216
1217    use super::{
1218        DEFAULT_WAIT_TIMEOUT_S, RigDownResult, RigResetResult, RigStatusResult, RigUpResult,
1219        gateway_url_from, require_rig_up_for_verb, rig_down, rig_logs, rig_reset, rig_restore,
1220        rig_snapshot, rig_status, rig_up, trial_reset, trial_status,
1221    };
1222    use crate::client::GatewayApi;
1223    use crate::config::ModuleDeclaration;
1224    use crate::error::CoreError;
1225    use crate::rig::RigPlan;
1226    use crate::rig::compose::{
1227        ComposeOutput, ComposeRunner, PortMapping, down_args, logs_args, up_args, volume_ls_args,
1228    };
1229    use crate::rig::modules::{ModuleProvisioning, provision_modules, undeclared_provisioning};
1230
1231    // ---------------------------------------------------------------------
1232    // Test doubles
1233    // ---------------------------------------------------------------------
1234
1235    /// Scripted runner: records every (program, args) call and serves
1236    /// queued outputs FIFO (same seam shape as the rig module's fake).
1237    #[derive(Default)]
1238    struct FakeRunner {
1239        calls: Mutex<Vec<(&'static str, Vec<String>)>>,
1240        outputs: Mutex<VecDeque<ComposeOutput>>,
1241    }
1242
1243    impl FakeRunner {
1244        fn with(outputs: Vec<ComposeOutput>) -> Self {
1245            Self {
1246                outputs: Mutex::new(outputs.into()),
1247                ..Self::default()
1248            }
1249        }
1250
1251        fn calls(&self) -> Vec<(&'static str, Vec<String>)> {
1252            self.calls.lock().unwrap().clone()
1253        }
1254    }
1255
1256    #[async_trait::async_trait]
1257    impl ComposeRunner for FakeRunner {
1258        async fn run(&self, args: &[String]) -> ComposeOutput {
1259            self.calls
1260                .lock()
1261                .unwrap()
1262                .push(("docker compose", args.to_vec()));
1263            self.outputs
1264                .lock()
1265                .unwrap()
1266                .pop_front()
1267                .expect("outputs exhausted")
1268        }
1269
1270        async fn run_docker(&self, args: &[String]) -> ComposeOutput {
1271            self.calls.lock().unwrap().push(("docker", args.to_vec()));
1272            self.outputs
1273                .lock()
1274                .unwrap()
1275                .pop_front()
1276                .expect("outputs exhausted")
1277        }
1278
1279        async fn run_streaming(
1280            &self,
1281            args: &[String],
1282            line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
1283        ) -> ComposeOutput {
1284            self.calls
1285                .lock()
1286                .unwrap()
1287                .push(("docker compose", args.to_vec()));
1288            let output = self
1289                .outputs
1290                .lock()
1291                .unwrap()
1292                .pop_front()
1293                .expect("outputs exhausted");
1294            // Preload contract: queued stdout lines replay to the sink
1295            // in order; the returned stdout is emptied (lines already
1296            // "streamed").
1297            for line in output.stdout.lines() {
1298                line_sink(line);
1299            }
1300            ComposeOutput {
1301                stdout: String::new(),
1302                stderr: output.stderr,
1303                code: output.code,
1304            }
1305        }
1306    }
1307
1308    fn ok(stdout: &str) -> ComposeOutput {
1309        ComposeOutput {
1310            stdout: stdout.to_string(),
1311            stderr: String::new(),
1312            code: 0,
1313        }
1314    }
1315
1316    fn version_ok() -> ComposeOutput {
1317        ok("Docker Compose version v5.1.2\n")
1318    }
1319
1320    /// An OWN-PROJECT docker-ps occupant: preflight treats it as
1321    /// recreate-safe AND — because the row set is non-empty — the
1322    /// advisory lsof pass never runs, keeping these tests deterministic
1323    /// on machines where a REAL rig publishes 9088/9443 (the fixture's
1324    /// ports — live-verification found the lsof fallback observing the
1325    /// host's own rig; the rig/mod.rs port-1 dodge, reconsidered).
1326    const OWN_OCCUPANT: &str =
1327        r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
1328
1329    /// The scripted pre-flight answers for a two-port gw_plan(): both
1330    /// ports held by THIS project (a recreate — the honest shape for
1331    /// up/reset against an already-running rig).
1332    fn free_ports_for_own_project() -> Vec<ComposeOutput> {
1333        vec![ok(OWN_OCCUPANT), ok(OWN_OCCUPANT)]
1334    }
1335
1336    /// The up cycle's queue: version → preflight × 2 (own project) →
1337    /// the up itself.
1338    fn up_cycle_outputs() -> Vec<ComposeOutput> {
1339        let mut outputs = vec![version_ok()];
1340        outputs.extend(free_ports_for_own_project());
1341        outputs.push(ok(""));
1342        outputs
1343    }
1344
1345    /// A one-service rig publishing the gateway ports (the
1346    /// gateway_url_from inputs).
1347    fn gw_plan() -> RigPlan {
1348        RigPlan {
1349            name: "fixture-rig".into(),
1350            config_name: None,
1351            compose_file: "/rigs/docker/compose.yml".into(),
1352            project_dir: "/rigs/docker".into(),
1353            services: vec!["ignition".into()],
1354            host_ports: vec![9088, 9443],
1355            port_mappings: vec![
1356                PortMapping {
1357                    target: 8088,
1358                    published: 9088,
1359                },
1360                PortMapping {
1361                    target: 443,
1362                    published: 9443,
1363                },
1364            ],
1365            volumes: vec!["gw-data".into()],
1366            gateway_service: Some("ignition".into()),
1367            modules: std::collections::BTreeMap::new(),
1368        }
1369    }
1370
1371    /// A wiremock StatusPing responder serving one canned state.
1372    async fn status_ping_server(state: &str) -> wiremock::MockServer {
1373        let server = wiremock::MockServer::start().await;
1374        wiremock::Mock::given(wiremock::matchers::method("GET"))
1375            .and(wiremock::matchers::path("/StatusPing"))
1376            .respond_with(
1377                wiremock::ResponseTemplate::new(200)
1378                    .set_body_json(serde_json::json!({ "state": state })),
1379            )
1380            .expect(1..)
1381            .mount(&server)
1382            .await;
1383        server
1384    }
1385
1386    /// A wiremock StatusPing responder that ALWAYS 302s to /welcome —
1387    /// the uncommissioned gateway shape (classify maps it to
1388    /// GatewayNotCommissioned; the probe must translate to Pending).
1389    async fn uncommissioned_server() -> wiremock::MockServer {
1390        let server = wiremock::MockServer::start().await;
1391        wiremock::Mock::given(wiremock::matchers::method("GET"))
1392            .and(wiremock::matchers::path("/StatusPing"))
1393            .respond_with(
1394                wiremock::ResponseTemplate::new(302).insert_header("Location", "/welcome"),
1395            )
1396            .expect(1..)
1397            .mount(&server)
1398            .await;
1399        server
1400    }
1401
1402    // ---------------------------------------------------------------------
1403    // gateway_url_from
1404    // ---------------------------------------------------------------------
1405
1406    #[test]
1407    fn gateway_url_prefers_http_8088_then_https_443() {
1408        assert_eq!(
1409            gateway_url_from(&gw_plan()),
1410            Some("http://localhost:9088".to_string()),
1411            "the 8088 mapping wins even though 443 is also present"
1412        );
1413        let https_only = RigPlan {
1414            port_mappings: vec![PortMapping {
1415                target: 443,
1416                published: 9443,
1417            }],
1418            host_ports: vec![9443],
1419            ..gw_plan()
1420        };
1421        assert_eq!(
1422            gateway_url_from(&https_only),
1423            Some("https://localhost:9443".to_string())
1424        );
1425        let no_gateway = RigPlan {
1426            port_mappings: vec![PortMapping {
1427                target: 22,
1428                published: 9022,
1429            }],
1430            host_ports: vec![9022],
1431            ..gw_plan()
1432        };
1433        assert_eq!(gateway_url_from(&no_gateway), None);
1434    }
1435
1436    #[test]
1437    fn default_wait_timeout_is_300() {
1438        assert_eq!(DEFAULT_WAIT_TIMEOUT_S, 300, "research Pitfall 3 headroom");
1439    }
1440
1441    // ---------------------------------------------------------------------
1442    // rig_up
1443    // ---------------------------------------------------------------------
1444
1445    /// Success path: version → free-port preflight → up (exact args) →
1446    /// probe sees RUNNING immediately → state running, no warnings.
1447    #[tokio::test]
1448    async fn up_success_probes_to_running() {
1449        let server = status_ping_server("RUNNING").await;
1450        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1451
1452        let runner = FakeRunner::with(up_cycle_outputs());
1453        let result = rig_up(
1454            &runner,
1455            &gw_plan(),
1456            300,
1457            Some(&api),
1458            &ModuleProvisioning::default(),
1459        )
1460        .await
1461        .expect("up succeeds");
1462        assert_eq!(result.rig, "fixture-rig");
1463        assert_eq!(result.state, "running");
1464        assert!(result.warnings.is_empty());
1465        assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1466
1467        let calls = runner.calls();
1468        assert_eq!(calls[0], ("docker compose", vec!["version".to_string()]));
1469        // The pre-flight ran per host port via PLAIN docker.
1470        assert_eq!(calls[1].0, "docker");
1471        assert_eq!(calls[2].0, "docker");
1472        assert_eq!(
1473            calls[3],
1474            ("docker compose", up_args(&gw_plan(), 300, &[])),
1475            "up rides the LOCKED arg shape"
1476        );
1477    }
1478
1479    /// D-11 / plan Task 1 item 3: with a declared module already
1480    /// provisioned (an override file `ign` wrote or found this run),
1481    /// `rig_up` must issue exactly `-p <name> -f <base> -f <override>`
1482    /// then the UNCHANGED `up` tail — override SECOND, never first. The
1483    /// vector is hardcoded here (not re-derived by calling `up_args`)
1484    /// so this is an independent proof that `rig_up` actually threads
1485    /// `provisioning.override_files()` through, not merely that
1486    /// `up_args` itself is correct (that's `up_args_appends_override_
1487    /// after_base_file` in `rig/compose.rs`).
1488    #[tokio::test]
1489    async fn up_wires_override_file_as_second_f_argument() {
1490        let override_path = PathBuf::from("/rigs/docker/compose.ign-modules.yml");
1491        let provisioning = ModuleProvisioning {
1492            override_file: Some(override_path.clone()),
1493            modules: Vec::new(),
1494            orphaned_modules: Vec::new(),
1495        };
1496
1497        let runner = FakeRunner::with(up_cycle_outputs());
1498        let result = rig_up(&runner, &gw_plan(), 300, None, &provisioning)
1499            .await
1500            .expect("up succeeds with a provisioned override");
1501        assert!(result.provisioned_modules.is_empty());
1502
1503        let calls = runner.calls();
1504        assert_eq!(
1505            calls[3],
1506            (
1507                "docker compose",
1508                vec![
1509                    "-p".to_string(),
1510                    "fixture-rig".to_string(),
1511                    "-f".to_string(),
1512                    "/rigs/docker/compose.yml".to_string(),
1513                    "-f".to_string(),
1514                    "/rigs/docker/compose.ign-modules.yml".to_string(),
1515                    "up".to_string(),
1516                    "-d".to_string(),
1517                    "--wait".to_string(),
1518                    "--wait-timeout".to_string(),
1519                    "300".to_string(),
1520                    "--remove-orphans".to_string(),
1521                ]
1522            ),
1523            "override is the SECOND -f, base file first, up tail unchanged"
1524        );
1525    }
1526
1527    /// The uncommissioned degradation: the probe only ever sees the
1528    /// wizard redirect; the deadline expires; the result is SUCCESS as
1529    /// data (state uncommissioned, wizard hint inside warnings).
1530    #[tokio::test]
1531    async fn up_uncommissioned_is_data_not_failure() {
1532        let server = uncommissioned_server().await;
1533        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1534
1535        let runner = FakeRunner::with(up_cycle_outputs());
1536        let result = rig_up(
1537            &runner,
1538            &gw_plan(),
1539            1,
1540            Some(&api),
1541            &ModuleProvisioning::default(),
1542        )
1543        .await
1544        .expect("uncommissioned is exit-0 data");
1545        assert_eq!(result.state, "uncommissioned");
1546        assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1547        assert!(
1548            result
1549                .warnings
1550                .iter()
1551                .any(|warning| warning.contains("http://localhost:9088/welcome")),
1552            "wizard URL in warnings: {:?}",
1553            result.warnings
1554        );
1555    }
1556
1557    /// STARTING at deadline is a REAL failure (Rig error), not data.
1558    #[tokio::test]
1559    async fn up_still_starting_at_deadline_is_rig_error() {
1560        let server = status_ping_server("STARTING").await;
1561        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1562
1563        let runner = FakeRunner::with(up_cycle_outputs());
1564        let err = rig_up(
1565            &runner,
1566            &gw_plan(),
1567            1,
1568            Some(&api),
1569            &ModuleProvisioning::default(),
1570        )
1571        .await
1572        .expect_err("still-STARTING deadline errors");
1573        assert!(matches!(err, CoreError::Rig(_)));
1574        assert_eq!(err.exit_code(), 7);
1575        let message = err.to_string();
1576        assert!(message.contains("did not reach RUNNING"), "{message}");
1577        assert!(
1578            message.contains("STARTING"),
1579            "last observation named: {message}"
1580        );
1581    }
1582
1583    /// Port conflict aborts BEFORE the up, with attribution.
1584    #[tokio::test]
1585    async fn up_port_conflict_aborts_with_attribution() {
1586        let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1587        let runner = FakeRunner::with(vec![version_ok(), ok(occupant), ok(occupant)]);
1588
1589        let err = rig_up(
1590            &runner,
1591            &gw_plan(),
1592            300,
1593            None,
1594            &ModuleProvisioning::default(),
1595        )
1596        .await
1597        .expect_err("cross-project occupant aborts");
1598        let message = err.to_string();
1599        assert!(
1600            message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1601            "{message}"
1602        );
1603        // The up NEVER ran — the last recorded call is the pre-flight.
1604        let calls = runner.calls();
1605        assert_eq!(
1606            calls.len(),
1607            3,
1608            "version + two port checks only, no up: {calls:?}"
1609        );
1610    }
1611
1612    /// No probe client → the wait is skipped with a data-level warning
1613    /// and compose's own --wait stands as the readiness signal.
1614    #[tokio::test]
1615    async fn up_without_probe_skips_wait_with_warning() {
1616        let runner = FakeRunner::with(up_cycle_outputs());
1617        let result = rig_up(
1618            &runner,
1619            &gw_plan(),
1620            300,
1621            None,
1622            &ModuleProvisioning::default(),
1623        )
1624        .await
1625        .expect("up succeeds without a probe");
1626        assert_eq!(result.state, "running");
1627        assert!(
1628            result
1629                .warnings
1630                .iter()
1631                .any(|warning| warning.contains("skipped the commissioned wait")),
1632            "{:?}",
1633            result.warnings
1634        );
1635    }
1636
1637    /// Compose missing → version gate fails fast with the install hint.
1638    #[tokio::test]
1639    async fn up_missing_compose_fails_fast() {
1640        let missing = ComposeOutput {
1641            stdout: String::new(),
1642            stderr: "docker: command not found".into(),
1643            code: 127,
1644        };
1645        let runner = FakeRunner::with(vec![missing]);
1646        let err = rig_up(
1647            &runner,
1648            &gw_plan(),
1649            300,
1650            None,
1651            &ModuleProvisioning::default(),
1652        )
1653        .await
1654        .expect_err("no docker errors");
1655        let message = err.to_string();
1656        assert!(
1657            message.contains("docker compose is unavailable"),
1658            "{message}"
1659        );
1660        assert!(message.contains("not supported"), "{message}");
1661    }
1662
1663    // ---------------------------------------------------------------------
1664    // rig_down
1665    // ---------------------------------------------------------------------
1666
1667    #[tokio::test]
1668    async fn down_runs_exact_args_and_reports_down() {
1669        let runner = FakeRunner::with(vec![version_ok(), ok("")]);
1670        let result = rig_down(&runner, &gw_plan()).await.expect("down succeeds");
1671        assert_eq!(
1672            serde_json::to_value(&result).unwrap(),
1673            serde_json::json!({
1674                "rig": "fixture-rig",
1675                "project": "fixture-rig",
1676                "state": "down",
1677            }),
1678            "RigDownResult shape (all keys always)"
1679        );
1680        let calls = runner.calls();
1681        assert_eq!(
1682            calls[1],
1683            ("docker compose", down_args(&gw_plan(), false, &[]))
1684        );
1685    }
1686
1687    #[tokio::test]
1688    async fn down_failure_carries_stderr_tail() {
1689        let runner = FakeRunner::with(vec![
1690            version_ok(),
1691            ComposeOutput {
1692                stdout: String::new(),
1693                stderr: "error while removing network: active endpoints".into(),
1694                code: 1,
1695            },
1696        ]);
1697        let err = rig_down(&runner, &gw_plan())
1698            .await
1699            .expect_err("down failure errors");
1700        let message = err.to_string();
1701        assert!(
1702            message.contains("docker compose down failed (exit 1)"),
1703            "{message}"
1704        );
1705        assert!(message.contains("active endpoints"), "{message}");
1706    }
1707
1708    // ---------------------------------------------------------------------
1709    // rig_reset — the guarded teardown + bring-up cycle (04-02)
1710    // ---------------------------------------------------------------------
1711
1712    /// volume ls rows: one of ours, one foreign-prefixed (the label
1713    /// filter is server-side; the name-prefix filter is defense in
1714    /// depth — the preview pin).
1715    const RESET_VOLUME_STDOUT: &str = concat!(
1716        r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
1717        "\n",
1718        r#"{"Name":"other-rig_gw-data","Labels":{"com.docker.compose.project":"other-rig"}}"#,
1719        "\n",
1720    );
1721
1722    /// The full scripted cycle for a gw_plan() rig: preview (docker
1723    /// volume ls) → version → down -v → preflight × 2 ports (own
1724    /// project — recreate) → up.
1725    fn reset_cycle_outputs() -> Vec<ComposeOutput> {
1726        let mut outputs = vec![ok(RESET_VOLUME_STDOUT), version_ok(), ok("")];
1727        outputs.extend(free_ports_for_own_project());
1728        outputs.push(ok(""));
1729        outputs
1730    }
1731
1732    /// The happy cycle: preview content pinned (label-filtered names,
1733    /// foreign prefix dropped), teardown LOCKED shape pinned on the
1734    /// call log (`-v --remove-orphans`, explicit `-p`), pre-flight
1735    /// BETWEEN the halves, up on the rig_up shape, probe → RUNNING.
1736    #[tokio::test]
1737    async fn reset_previews_tears_down_with_v_then_brings_up() {
1738        let server = status_ping_server("RUNNING").await;
1739        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1740
1741        let runner = FakeRunner::with(reset_cycle_outputs());
1742        let result = rig_reset(
1743            &runner,
1744            &gw_plan(),
1745            300,
1746            Some(&api),
1747            &ModuleProvisioning::default(),
1748        )
1749        .await
1750        .expect("reset succeeds");
1751        assert_eq!(result.rig, "fixture-rig");
1752        assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1753        assert_eq!(result.state, "running");
1754        assert!(result.warnings.is_empty());
1755
1756        // The call log pins the WHOLE cycle: program shapes AND order.
1757        let calls = runner.calls();
1758        assert_eq!(calls.len(), 6, "exactly the six scripted calls: {calls:?}");
1759        assert_eq!(
1760            calls[0],
1761            ("docker", volume_ls_args("fixture-rig")),
1762            "preview rides the plain-docker volume ls shape"
1763        );
1764        assert_eq!(calls[1], ("docker compose", vec!["version".to_string()]));
1765        // The LOCKED teardown — the REQUEST shape, not just the
1766        // response (the Phase-2/3 wiremock discipline, runner edition).
1767        assert_eq!(
1768            calls[2],
1769            (
1770                "docker compose",
1771                vec![
1772                    "-p".to_string(),
1773                    "fixture-rig".to_string(),
1774                    "-f".to_string(),
1775                    "/rigs/docker/compose.yml".to_string(),
1776                    "down".to_string(),
1777                    "--remove-orphans".to_string(),
1778                    "-v".to_string(),
1779                ],
1780            ),
1781            "down -v --remove-orphans via the runner seam"
1782        );
1783        // Pre-flight AFTER the teardown, BEFORE the up (fresh eyes).
1784        assert_eq!(calls[3].0, "docker");
1785        assert_eq!(calls[4].0, "docker");
1786        assert_eq!(calls[5], ("docker compose", up_args(&gw_plan(), 300, &[])));
1787    }
1788
1789    /// A fresh volume terminally reports the wizard redirect →
1790    /// uncommissioned is DATA (exit 0) — the same degradation as up.
1791    #[tokio::test]
1792    async fn reset_uncommissioned_fresh_volume_is_data() {
1793        let server = uncommissioned_server().await;
1794        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1795
1796        let runner = FakeRunner::with(reset_cycle_outputs());
1797        let result = rig_reset(
1798            &runner,
1799            &gw_plan(),
1800            1,
1801            Some(&api),
1802            &ModuleProvisioning::default(),
1803        )
1804        .await
1805        .expect("uncommissioned reset is exit-0 data");
1806        assert_eq!(result.state, "uncommissioned");
1807        assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1808        assert!(
1809            result
1810                .warnings
1811                .iter()
1812                .any(|warning| warning.contains("http://localhost:9088/welcome")),
1813            "wizard URL in warnings: {:?}",
1814            result.warnings
1815        );
1816    }
1817
1818    /// Port re-grabbed mid-cycle (another rig took a freed port between
1819    /// the halves): Rig error with attribution, and the up NEVER ran.
1820    #[tokio::test]
1821    async fn reset_port_regrabbed_midcycle_errors_and_never_ups() {
1822        let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1823        let runner = FakeRunner::with(vec![
1824            ok(""), // volume ls: nothing to remove
1825            version_ok(),
1826            ok(""),           // down -v
1827            ok(occupant),     // preflight 9088: re-grabbed mid-cycle
1828            ok(OWN_OCCUPANT), // preflight 9443: own project
1829        ]);
1830        let err = rig_reset(
1831            &runner,
1832            &gw_plan(),
1833            300,
1834            None,
1835            &ModuleProvisioning::default(),
1836        )
1837        .await
1838        .expect_err("mid-cycle port grab aborts before the up half");
1839        assert!(matches!(err, CoreError::Rig(_)));
1840        assert_eq!(err.exit_code(), 7);
1841        let message = err.to_string();
1842        assert!(
1843            message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1844            "{message}"
1845        );
1846        assert!(
1847            message.contains("torn down"),
1848            "the hint names the torn-down state: {message}"
1849        );
1850        // The up NEVER ran — the last recorded call is the pre-flight.
1851        let calls = runner.calls();
1852        assert_eq!(calls.len(), 5, "no up call: {calls:?}");
1853        assert_eq!(calls.last().expect("calls exist").0, "docker");
1854    }
1855
1856    /// Teardown failure carries compose's stderr tail.
1857    #[tokio::test]
1858    async fn reset_down_failure_carries_stderr_tail() {
1859        let runner = FakeRunner::with(vec![
1860            ok(""),
1861            version_ok(),
1862            ComposeOutput {
1863                stdout: String::new(),
1864                stderr: "cannot remove volume: in use".into(),
1865                code: 1,
1866            },
1867        ]);
1868        let err = rig_reset(
1869            &runner,
1870            &gw_plan(),
1871            300,
1872            None,
1873            &ModuleProvisioning::default(),
1874        )
1875        .await
1876        .expect_err("down -v failure errors");
1877        let message = err.to_string();
1878        assert!(
1879            message.contains("docker compose down failed (exit 1)"),
1880            "{message}"
1881        );
1882        assert!(message.contains("in use"), "{message}");
1883    }
1884
1885    // ---------------------------------------------------------------------
1886    // rig_logs — passthrough streaming (04-02)
1887    // ---------------------------------------------------------------------
1888
1889    /// Raw compose log lines (color codes stripped by fixtures; the
1890    /// sink must receive them VERBATIM — no envelope, no reformat).
1891    const LOGS_STDOUT: &str = concat!(
1892        "ignition-1  | 22:01:01.001 INFO   Gateway - starting\n",
1893        "ignition-1  | 22:01:02.002 INFO   Gateway - RUNNING\n",
1894    );
1895
1896    /// One-shot: the captured stdout splits line-wise into the sink,
1897    /// verbatim, via the plain `run` seam (exact args pinned).
1898    #[tokio::test]
1899    async fn logs_one_shot_sinks_lines_verbatim() {
1900        let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1901        let mut received: Vec<String> = Vec::new();
1902        let result = rig_logs(&runner, &gw_plan(), 200, false, None, &mut |line| {
1903            received.push(line)
1904        })
1905        .await
1906        .expect("logs succeeds");
1907        assert_eq!(result.streamed, 2);
1908        assert_eq!(
1909            received,
1910            vec![
1911                "ignition-1  | 22:01:01.001 INFO   Gateway - starting",
1912                "ignition-1  | 22:01:02.002 INFO   Gateway - RUNNING",
1913            ],
1914            "lines pass through verbatim — no envelope wrapping ever"
1915        );
1916        let calls = runner.calls();
1917        assert_eq!(
1918            calls,
1919            vec![(
1920                "docker compose",
1921                logs_args(&gw_plan(), 200, false, None, &[])
1922            )]
1923        );
1924    }
1925
1926    /// Follow: rides the STREAMING seam (the fake replays its
1927    /// preloaded stdout through the sink), service filter pinned.
1928    #[tokio::test]
1929    async fn logs_follow_streams_via_the_streaming_seam() {
1930        let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1931        let mut received: Vec<String> = Vec::new();
1932        let result = rig_logs(
1933            &runner,
1934            &gw_plan(),
1935            50,
1936            true,
1937            Some("ignition"),
1938            &mut |line| received.push(line),
1939        )
1940        .await
1941        .expect("follow logs succeeds");
1942        assert_eq!(result.streamed, 2, "streamed lines counted in follow mode");
1943        assert_eq!(received.len(), 2);
1944        let calls = runner.calls();
1945        assert_eq!(
1946            calls,
1947            vec![(
1948                "docker compose",
1949                logs_args(&gw_plan(), 50, true, Some("ignition"), &[])
1950            )]
1951        );
1952    }
1953
1954    /// Failure: exit-mapped error with compose's stderr tail; the
1955    /// diagnostics NEVER ride the data sink.
1956    #[tokio::test]
1957    async fn logs_failure_carries_stderr_tail_never_sink() {
1958        let runner = FakeRunner::with(vec![ComposeOutput {
1959            stdout: String::new(),
1960            stderr: "no such service: nosvc".into(),
1961            code: 1,
1962        }]);
1963        let mut received: Vec<String> = Vec::new();
1964        let err = rig_logs(
1965            &runner,
1966            &gw_plan(),
1967            200,
1968            false,
1969            Some("nosvc"),
1970            &mut |line| received.push(line),
1971        )
1972        .await
1973        .expect_err("unknown service errors");
1974        let message = err.to_string();
1975        assert!(
1976            message.contains("docker compose logs failed (exit 1)"),
1977            "{message}"
1978        );
1979        assert!(message.contains("no such service"), "{message}");
1980        assert!(received.is_empty(), "diagnostics never ride the data sink");
1981    }
1982
1983    // ---------------------------------------------------------------------
1984    // trial_status — the trial endpoint + banners cross-check (04-03)
1985    // ---------------------------------------------------------------------
1986
1987    /// Mount the EXPIRED live captures (ign-research 8.3.6): trial
1988    /// AllInDemo/0s/expired + banners warning/null.
1989    async fn expired_trial_server() -> wiremock::MockServer {
1990        let server = wiremock::MockServer::start().await;
1991        wiremock::Mock::given(wiremock::matchers::method("GET"))
1992            .and(wiremock::matchers::path("/data/api/v1/trial"))
1993            .respond_with(
1994                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1995                    "licenseMode": "Trial", "trialState": "AllInDemo",
1996                    "trialSecondsLeft": 0, "expired": true,
1997                    "emergency": false, "emergencySecondsLeft": 0,
1998                    "development": false, "developmentSecondsLeft": 0
1999                })),
2000            )
2001            .mount(&server)
2002            .await;
2003        wiremock::Mock::given(wiremock::matchers::method("GET"))
2004            .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2005            .respond_with(
2006                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2007                    "banners": [{
2008                        "order": 0, "type": "trial",
2009                        "data": { "severity": "warning", "expireTime": null,
2010                                  "toolTips": [], "actions": [] }
2011                    }]
2012                })),
2013            )
2014            .mount(&server)
2015            .await;
2016        server
2017    }
2018
2019    /// The expired rig's exact output shape (all keys always; the
2020    /// banners cross-check rides severity/expire_time_ms/active).
2021    #[tokio::test]
2022    async fn trial_status_expired_shape_is_exact() {
2023        let server = expired_trial_server().await;
2024        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2025        let result = trial_status(&api).await.expect("expired status parses");
2026        assert_eq!(
2027            serde_json::to_value(&result).unwrap(),
2028            serde_json::json!({
2029                "license_mode": "Trial",
2030                "trial_state": "AllInDemo",
2031                "trial_remaining_s": 0,
2032                "expired": true,
2033                "emergency": false,
2034                "emergency_remaining_s": 0,
2035                "development": false,
2036                "banners": {
2037                    "severity": "warning",
2038                    "expire_time_ms": null,
2039                    "active": false
2040                },
2041                "warnings": []
2042            }),
2043            "EXACT shape comparison — the unit-explicit keys + the \
2044             banners cross-check block, no unknown keys"
2045        );
2046    }
2047
2048    /// The ACTIVE cross-check: severity info + a far-FUTURE epoch-ms
2049    /// expireTime → active true; the SAME severity with a far-PAST
2050    /// expireTime → active false (the Pitfall-7 pin: expiry time is
2051    /// part of the active derivation, severity alone never is).
2052    #[tokio::test]
2053    async fn trial_status_banner_active_requires_future_expire_time() {
2054        for (expire_time, active) in [(9_999_999_999_999_999i64, true), (1i64, false)] {
2055            let server = wiremock::MockServer::start().await;
2056            wiremock::Mock::given(wiremock::matchers::method("GET"))
2057                .and(wiremock::matchers::path("/data/api/v1/trial"))
2058                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2059                    serde_json::json!({
2060                        "licenseMode": "Trial", "trialState": "AllInDemo",
2061                        "trialSecondsLeft": 6590, "expired": false,
2062                        "emergency": false, "emergencySecondsLeft": 0,
2063                        "development": false, "developmentSecondsLeft": 0
2064                    }),
2065                ))
2066                .mount(&server)
2067                .await;
2068            wiremock::Mock::given(wiremock::matchers::method("GET"))
2069                .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2070                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2071                    serde_json::json!({
2072                        "banners": [{
2073                            "order": 5, "type": "trial",
2074                            "data": { "severity": "info",
2075                                      "expireTime": expire_time,
2076                                      "toolTips": [], "actions": [] }
2077                        }]
2078                    }),
2079                ))
2080                .mount(&server)
2081                .await;
2082            let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2083            let result = trial_status(&api).await.expect("active status parses");
2084            assert!(!result.expired, "primary truth from the trial endpoint");
2085            assert_eq!(result.trial_remaining_s, 6590);
2086            assert_eq!(
2087                result.banners.severity.as_deref(),
2088                Some("info"),
2089                "the trial banner surfaced (8.3.3 serves order 5 — not an index)"
2090            );
2091            assert_eq!(
2092                result.banners.active, active,
2093                "info severity + expireTime {expire_time} → active {active} (Pitfall 7)"
2094            );
2095        }
2096    }
2097
2098    /// A failed banners fetch degrades to nulls + a data-level
2099    /// warning — the trial endpoint's expired flag stays the truth.
2100    #[tokio::test]
2101    async fn trial_status_banners_failure_degrades_with_warning() {
2102        let server = wiremock::MockServer::start().await;
2103        wiremock::Mock::given(wiremock::matchers::method("GET"))
2104            .and(wiremock::matchers::path("/data/api/v1/trial"))
2105            .respond_with(
2106                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2107                    "licenseMode": "Trial", "trialState": "AllInDemo",
2108                    "trialSecondsLeft": 0, "expired": true,
2109                    "emergency": false, "emergencySecondsLeft": 0,
2110                    "development": false, "developmentSecondsLeft": 0
2111                })),
2112            )
2113            .mount(&server)
2114            .await;
2115        wiremock::Mock::given(wiremock::matchers::method("GET"))
2116            .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2117            .respond_with(wiremock::ResponseTemplate::new(500))
2118            .mount(&server)
2119            .await;
2120        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2121        let result = trial_status(&api).await.expect("primary endpoint answered");
2122        assert!(
2123            result.expired,
2124            "primary truth survives the cross-check failure"
2125        );
2126        assert_eq!(result.banners.severity, None);
2127        assert_eq!(result.banners.expire_time_ms, None);
2128        assert!(!result.banners.active);
2129        assert!(
2130            result
2131                .warnings
2132                .iter()
2133                .any(|warning| warning.contains("banners cross-check unavailable")),
2134            "the degradation is visible data: {:?}",
2135            result.warnings
2136        );
2137    }
2138
2139    // ---------------------------------------------------------------------
2140    // trial_reset — the ladder (04-03)
2141    // ---------------------------------------------------------------------
2142
2143    /// The trial JSON body for a given state.
2144    fn trial_body(expired: bool, seconds_left: i64) -> serde_json::Value {
2145        serde_json::json!({
2146            "licenseMode": "Trial", "trialState": "AllInDemo",
2147            "trialSecondsLeft": seconds_left, "expired": expired,
2148            "emergency": false, "emergencySecondsLeft": 0,
2149            "development": false, "developmentSecondsLeft": 0
2150        })
2151    }
2152
2153    /// A stateful trial GET/POST script: GET answers EXPIRED until the
2154    /// successful reset POST lands, then FRESH forever (the flip the
2155    /// read-back verifies). `post_status` controls the POST's answer
2156    /// (200 = reset lands and flips the flag; anything else refuses
2157    /// WITHOUT flipping — the state-gate/credential-refusal shapes).
2158    #[derive(Clone)]
2159    struct TrialFlipScript {
2160        reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2161        post_status: u16,
2162    }
2163
2164    impl wiremock::Respond for TrialFlipScript {
2165        fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
2166            if request.method.as_str() == "POST" {
2167                if self.post_status == 200 {
2168                    self.reset_done
2169                        .store(true, std::sync::atomic::Ordering::SeqCst);
2170                    return wiremock::ResponseTemplate::new(200)
2171                        .set_body_json(trial_body(false, 7199));
2172                }
2173                return wiremock::ResponseTemplate::new(self.post_status);
2174            }
2175            let expired = !self.reset_done.load(std::sync::atomic::Ordering::SeqCst);
2176            wiremock::ResponseTemplate::new(200)
2177                .set_body_json(trial_body(expired, if expired { 0 } else { 7199 }))
2178        }
2179    }
2180
2181    /// Mount GET + POST /trial on one script (the read-back sees the
2182    /// flip when the POST succeeded).
2183    async fn trial_reset_server(post_status: u16) -> (wiremock::MockServer, TrialFlipScript) {
2184        let server = wiremock::MockServer::start().await;
2185        let script = TrialFlipScript {
2186            reset_done: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2187            post_status,
2188        };
2189        wiremock::Mock::given(wiremock::matchers::method("GET"))
2190            .and(wiremock::matchers::path("/data/api/v1/trial"))
2191            .respond_with(script.clone())
2192            .mount(&server)
2193            .await;
2194        wiremock::Mock::given(wiremock::matchers::method("POST"))
2195            .and(wiremock::matchers::path("/data/api/v1/trial"))
2196            .respond_with(script.clone())
2197            .mount(&server)
2198            .await;
2199        (server, script)
2200    }
2201
2202    /// The pre-check refusal: an ACTIVE trial errors TrialNotExpired
2203    /// (exit 6) naming the seconds left — the live-discovered state
2204    /// gate surfaced honestly, and the POST NEVER fires.
2205    #[tokio::test]
2206    async fn trial_reset_refuses_active_trial_up_front() {
2207        let (server, script) = trial_reset_server(200).await;
2208        // Force the "active" starting state (the script's flag starts
2209        // un-flipped = expired; flip it up front for this test).
2210        script
2211            .reset_done
2212            .store(true, std::sync::atomic::Ordering::SeqCst);
2213        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2214        let err = trial_reset(&api, &server.uri(), false, None)
2215            .await
2216            .expect_err("an active trial is refused before any POST");
2217        assert!(matches!(err, CoreError::TrialNotExpired { .. }), "{err}");
2218        assert_eq!(err.exit_code(), 6);
2219        assert_eq!(err.code(), "trial_not_expired");
2220        let message = err.to_string();
2221        assert!(
2222            message.contains("7199s left"),
2223            "the message names the countdown: {message}"
2224        );
2225    }
2226
2227    /// Tier 0 lands: token-auth POST through the client pipeline, the
2228    /// read-back flip verified, mechanism "token".
2229    #[tokio::test]
2230    async fn trial_reset_tier0_lands_with_read_back_flip() {
2231        let (server, _script) = trial_reset_server(200).await;
2232        let credential = crate::config::Credential::Token(crate::config::Secret::new(
2233            "spike:tokengeneratedlive",
2234        ));
2235        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2236        let result = trial_reset(&api, &server.uri(), true, None)
2237            .await
2238            .expect("tier 0 resets the expired trial");
2239        assert_eq!(
2240            serde_json::to_value(&result).unwrap(),
2241            serde_json::json!({
2242                "rig_url": server.uri(),
2243                "mechanism": "token",
2244                "expired_before": true,
2245                "expired_after": false,
2246                "trial_remaining_s": 7199
2247            }),
2248            "EXACT shape — which rung landed + the before/after flip"
2249        );
2250    }
2251
2252    /// Tier 0 refused (401) with NO login pair: the HONEST tail —
2253    /// a rig-class error (exit 7) naming the URL, the refused rung,
2254    /// and the missing login pair (2026-10-11: the old
2255    /// SecretUnavailable{profile: URL} tail rendered a URL in the
2256    /// profile slot and hinted at a nonsense keyring entry).
2257    #[tokio::test]
2258    async fn trial_reset_token_refused_without_login_errors() {
2259        let (server, _script) = trial_reset_server(401).await;
2260        let credential =
2261            crate::config::Credential::Token(crate::config::Secret::new("spike:wrongtoken"));
2262        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2263        let err = trial_reset(&api, &server.uri(), true, None)
2264            .await
2265            .expect_err("the refused token rung has no fallback");
2266        assert!(matches!(err, CoreError::Rig(_)), "{err}");
2267        assert_eq!(err.exit_code(), 7);
2268        assert_eq!(err.code(), "rig_error");
2269        assert!(
2270            err.to_string().contains("IGNITION_USER")
2271                && err.to_string().contains("IGNITION_PASSWORD")
2272                && err.to_string().contains(&server.uri()),
2273            "the tail names the URL and both rungs: {err}"
2274        );
2275    }
2276
2277    /// The DOWN-rig pre-flight: no RUNNING service → exit 7
2278    /// `rig_error` naming the rig and `ign rig up` (a verb whose
2279    /// subject does not exist cannot knock on a port squatter).
2280    #[tokio::test]
2281    async fn require_rig_up_refuses_a_down_rig() {
2282        let runner = FakeRunner::with(vec![version_ok(), ok(""), ok(""), ok(""), ok("")]);
2283        let err = require_rig_up_for_verb(&runner, &gw_plan(), "rig trial reset")
2284            .await
2285            .expect_err("a down rig refuses");
2286        assert!(matches!(err, CoreError::Rig(_)), "{err}");
2287        assert_eq!(err.exit_code(), 7);
2288        assert!(
2289            err.to_string().contains("ign rig up") && err.to_string().contains("DOWN"),
2290            "names the state and the way back: {err}"
2291        );
2292    }
2293
2294    /// The pre-flight passes when ANY service runs (the gateway may
2295    /// still be starting — the verb's own ladder reports that
2296    /// honestly; the pre-flight only stops the squatter-knock).
2297    #[tokio::test]
2298    async fn require_rig_up_passes_a_running_rig() {
2299        let occupant = r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
2300        let runner = FakeRunner::with(vec![
2301            version_ok(),
2302            ok(PS_STDOUT),
2303            ok(VOLUME_STDOUT),
2304            ok(occupant),
2305            ok(occupant),
2306        ]);
2307        require_rig_up_for_verb(&runner, &gw_plan(), "rig trial reset")
2308            .await
2309            .expect("a running rig passes");
2310    }
2311
2312    /// The minimal tier-1 dance mount (the full request-chain pins
2313    /// live in tests/trial_contract.rs; this is the ACTION-level proof
2314    /// that the ladder wires the flow at the rig URL). The session
2315    /// POST rides PRIORITY 1 so wiremock checks it before the
2316    /// script's catch-all (stable order would otherwise let the
2317    /// earlier-mounted plain-POST mock steal the CSRF-carrying
2318    /// request), and its landing flips the same read-back flag.
2319    async fn login_dance_server() -> (wiremock::MockServer, TrialFlipScript) {
2320        let (server, script) = trial_reset_server(401).await;
2321        wiremock::Mock::given(wiremock::matchers::method("GET"))
2322            .and(wiremock::matchers::path("/data/app/login"))
2323            .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2324                "Location",
2325                "/idp/default/oidc/auth?app=gateway&state=st&nonce=nc",
2326            ))
2327            .mount(&server)
2328            .await;
2329        wiremock::Mock::given(wiremock::matchers::method("GET"))
2330            .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2331            .and(wiremock::matchers::query_param_is_missing("token"))
2332            .respond_with(
2333                wiremock::ResponseTemplate::new(302)
2334                    .insert_header("Location", "/idp/default/authn/login?app=gateway&token=TT0"),
2335            )
2336            .mount(&server)
2337            .await;
2338        for (body_token, answer) in [
2339            (
2340                "TT0",
2341                r#"{"complete":false,"nextChallenge":[{"type":"basic"}],"token":"TT1"}"#,
2342            ),
2343            ("TT2", r#"{"complete":true,"token":"TT3"}"#),
2344        ] {
2345            wiremock::Mock::given(wiremock::matchers::method("POST"))
2346                .and(wiremock::matchers::path(
2347                    "/idp/default/authn/next-challenge",
2348                ))
2349                .and(wiremock::matchers::body_json(
2350                    serde_json::json!({ "token": body_token }),
2351                ))
2352                .respond_with(
2353                    wiremock::ResponseTemplate::new(200)
2354                        .set_body_string(answer)
2355                        .insert_header("Content-Type", "application/json"),
2356                )
2357                .mount(&server)
2358                .await;
2359        }
2360        wiremock::Mock::given(wiremock::matchers::method("POST"))
2361            .and(wiremock::matchers::path(
2362                "/idp/default/authn/submit-challenge/basic",
2363            ))
2364            .respond_with(
2365                wiremock::ResponseTemplate::new(200)
2366                    .set_body_string(r#"{"success":true,"token":"TT2"}"#)
2367                    .insert_header("Content-Type", "application/json"),
2368            )
2369            .mount(&server)
2370            .await;
2371        wiremock::Mock::given(wiremock::matchers::method("GET"))
2372            .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2373            .and(wiremock::matchers::query_param("token", "TT3"))
2374            .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2375                "Location",
2376                "/data/federate/callback/internal?code=c&state=st",
2377            ))
2378            .mount(&server)
2379            .await;
2380        wiremock::Mock::given(wiremock::matchers::method("GET"))
2381            .and(wiremock::matchers::path("/data/federate/callback/internal"))
2382            .respond_with(
2383                wiremock::ResponseTemplate::new(302)
2384                    .insert_header("Location", "/app")
2385                    .append_header("Set-Cookie", "webui-sid-1=sess; Path=/; HttpOnly"),
2386            )
2387            .mount(&server)
2388            .await;
2389        wiremock::Mock::given(wiremock::matchers::method("GET"))
2390            .and(wiremock::matchers::path("/data/app/session"))
2391            .respond_with(
2392                wiremock::ResponseTemplate::new(200)
2393                    .set_body_string(r#"{"userPayload":{},"csrfToken":"csrf1"}"#)
2394                    .insert_header("Content-Type", "application/json"),
2395            )
2396            .mount(&server)
2397            .await;
2398        wiremock::Mock::given(wiremock::matchers::method("POST"))
2399            .and(wiremock::matchers::path("/data/api/v1/trial"))
2400            .and(wiremock::matchers::header("X-CSRF-Token", "csrf1"))
2401            .respond_with(SessionResetFlip {
2402                reset_done: script.reset_done.clone(),
2403            })
2404            .with_priority(1)
2405            .mount(&server)
2406            .await;
2407        (server, script)
2408    }
2409
2410    /// The session-POST responder: flips the shared read-back flag and
2411    /// answers the fresh trial.
2412    struct SessionResetFlip {
2413        reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2414    }
2415
2416    impl wiremock::Respond for SessionResetFlip {
2417        fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
2418            self.reset_done
2419                .store(true, std::sync::atomic::Ordering::SeqCst);
2420            wiremock::ResponseTemplate::new(200).set_body_json(trial_body(false, 7199))
2421        }
2422    }
2423
2424    /// The full ladder: the token rung 401s → the login rung runs the
2425    /// dance at the RIG URL → reset → read-back flip; mechanism
2426    /// "login".
2427    #[tokio::test]
2428    async fn trial_reset_falls_through_to_the_login_rung() {
2429        let (server, _script) = login_dance_server().await;
2430        let credential =
2431            crate::config::Credential::Token(crate::config::Secret::new("spike:rejectedtoken"));
2432        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2433        let password = crate::config::Secret::new("rig-password");
2434        let result = trial_reset(&api, &server.uri(), true, Some(("admin", &password)))
2435            .await
2436            .expect("the login rung carries the reset");
2437        assert_eq!(result.mechanism, "login");
2438        assert!(result.expired_before);
2439        assert!(!result.expired_after);
2440        assert_eq!(result.trial_remaining_s, 7199);
2441    }
2442
2443    /// Tier 1 alone (no token at all): the dance + flip, mechanism
2444    /// "login".
2445    #[tokio::test]
2446    async fn trial_reset_login_rung_alone() {
2447        let (server, _script) = login_dance_server().await;
2448        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2449        let password = crate::config::Secret::new("rig-password");
2450        let result = trial_reset(&api, &server.uri(), false, Some(("admin", &password)))
2451            .await
2452            .expect("login-only reset works");
2453        assert_eq!(result.mechanism, "login");
2454        assert!(!result.expired_after);
2455    }
2456
2457    // ---------------------------------------------------------------------
2458    // 19-02: `rig up` REPORTS an orphaned module and never uninstalls it
2459    // (SC-3, D-19-01). Every gateway in these tests is a [`SnapshotRig`]
2460    // whose `uninstall_module` panics, so the guard fires on the CALL.
2461    // ---------------------------------------------------------------------
2462
2463    /// `gw_plan()` rooted in a real directory, so the override file the
2464    /// orphan diff reads is a real file on real disk.
2465    fn orphan_plan(dir: &Path) -> RigPlan {
2466        RigPlan {
2467            compose_file: dir.join("compose.yml"),
2468            project_dir: dir.to_path_buf(),
2469            ..gw_plan()
2470        }
2471    }
2472
2473    /// Seed a REAL generated two-mount override (`git` +
2474    /// `project-scan-endpoint`) through `write_override`. NEVER a
2475    /// hand-written stub: the Phase-16 stale-override test seeds the
2476    /// literal `"# left by a previous run\n"`, which parses zero
2477    /// `target:` lines — an orphan test copying that seed would pass
2478    /// while proving nothing.
2479    fn seed_two_mount_override(plan: &RigPlan) -> PathBuf {
2480        use crate::module::fetch::ArtifactSource;
2481        use crate::module::{GIT_MODULE, PROJECT_SCAN_ENDPOINT};
2482        use crate::rig::modules::{MountedModule, write_override};
2483
2484        let mounts = [
2485            MountedModule {
2486                spec: &GIT_MODULE,
2487                version: "2.3.4".to_string(),
2488                source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
2489                source_kind: ArtifactSource::Cache,
2490            },
2491            MountedModule {
2492                spec: &PROJECT_SCAN_ENDPOINT,
2493                version: "1.0.0".to_string(),
2494                source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
2495                source_kind: ArtifactSource::Cache,
2496            },
2497        ];
2498        write_override(plan, "ignition", &mounts)
2499            .expect("seed a real two-mount override")
2500            .expect("mounts were non-empty")
2501    }
2502
2503    /// Seed a cache hit exactly where the fetcher would have written it.
2504    fn seed_cache_entry(root: &Path, id: &str, version: &str, contents: &[u8]) {
2505        use sha2::{Digest, Sha256};
2506        let dir = root.join("modules").join(id);
2507        std::fs::create_dir_all(&dir).expect("cache dir");
2508        let digest = format!("{:x}", Sha256::digest(contents));
2509        std::fs::write(dir.join(format!("{version}-{digest}.modl")), contents)
2510            .expect("seed cached artifact");
2511    }
2512
2513    fn declared(entries: &[(&str, &str)]) -> std::collections::BTreeMap<String, ModuleDeclaration> {
2514        entries
2515            .iter()
2516            .map(|(id, version)| {
2517                (
2518                    id.to_string(),
2519                    ModuleDeclaration {
2520                        version: version.to_string(),
2521                    },
2522                )
2523            })
2524            .collect()
2525    }
2526
2527    fn orphan_ids(provisioning: &ModuleProvisioning) -> Vec<&str> {
2528        provisioning
2529            .orphaned_modules
2530            .iter()
2531            .map(|o| o.id.as_str())
2532            .collect()
2533    }
2534
2535    /// The fake itself must fail loudly — otherwise every test below
2536    /// that leans on it would pass vacuously.
2537    #[tokio::test]
2538    #[should_panic(expected = "rig up must never uninstall a module")]
2539    async fn the_uninstall_tripwire_actually_panics() {
2540        let rig = SnapshotRig::default();
2541        let _ = rig
2542            .uninstall_module("com.axone_io.ignition.git", "git")
2543            .await;
2544    }
2545
2546    /// CHANGED path: the rig undeclared everything. The orphans are
2547    /// reported, the stale override is gone, and `rig_up` completes
2548    /// against a gateway that panics if it is asked to uninstall.
2549    #[tokio::test]
2550    async fn up_after_undeclaring_reports_orphans_and_issues_no_uninstall() {
2551        let dir = tempfile::tempdir().expect("tempdir");
2552        let plan = orphan_plan(dir.path());
2553        let override_path = seed_two_mount_override(&plan);
2554
2555        let provisioning = undeclared_provisioning(&plan).expect("undeclared provisioning");
2556        assert_eq!(
2557            orphan_ids(&provisioning),
2558            vec!["git", "project-scan-endpoint"],
2559            "both undeclared modules are reported"
2560        );
2561        assert!(!override_path.exists(), "the stale override is deleted");
2562
2563        let rig = SnapshotRig::default();
2564        let runner = FakeRunner::with(up_cycle_outputs());
2565        let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2566            .await
2567            .expect("rig up completes without touching the gateway's module list");
2568        assert_eq!(result.state, "running");
2569        // The report survives the trip through `rig_up` into the RESULT
2570        // the `--json` envelope serialises — asserting only on
2571        // `ModuleProvisioning` would pass with the field never copied.
2572        assert_eq!(
2573            result
2574                .orphaned_modules
2575                .iter()
2576                .map(|o| o.id.as_str())
2577                .collect::<Vec<_>>(),
2578            vec!["git", "project-scan-endpoint"],
2579            "rig_up copies the orphan report into RigUpResult"
2580        );
2581        let json = serde_json::to_value(&result).expect("serialise");
2582        assert_eq!(json["orphaned_modules"][0]["id"], "git");
2583        assert_eq!(
2584            json["orphaned_modules"][0]["remove_with"],
2585            "ign rig module uninstall git --yes"
2586        );
2587        assert!(
2588            !rig.calls()
2589                .iter()
2590                .any(|c| c.starts_with("uninstall_module")),
2591            "no uninstall was attempted: {:?}",
2592            rig.calls()
2593        );
2594    }
2595
2596    /// PARTIAL change: `git` stays declared, `project-scan-endpoint` is
2597    /// dropped. Exactly the dropped one is reported and `rig_up` still
2598    /// never uninstalls it.
2599    #[tokio::test]
2600    async fn up_after_dropping_one_module_reports_only_that_one_and_issues_no_uninstall() {
2601        let dir = tempfile::tempdir().expect("tempdir");
2602        let plan = orphan_plan(dir.path());
2603        seed_two_mount_override(&plan);
2604
2605        let cache = tempfile::tempdir().expect("cache");
2606        seed_cache_entry(cache.path(), "git", "2.3.4", b"git payload");
2607        let feed = crate::module::fetch::ModuleFeed::for_base(
2608            url::Url::parse("http://127.0.0.1:1").expect("literal URL"),
2609        )
2610        .expect("feed");
2611
2612        let provisioning = provision_modules(
2613            &feed,
2614            &plan,
2615            &declared(&[("git", "2.3.4")]),
2616            cache.path(),
2617            crate::module::fetch::FetchPolicy::CacheFirst,
2618        )
2619        .await
2620        .expect("provisioning with git still declared");
2621        assert_eq!(orphan_ids(&provisioning), vec!["project-scan-endpoint"]);
2622
2623        let rig = SnapshotRig::default();
2624        let runner = FakeRunner::with(up_cycle_outputs());
2625        let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2626            .await
2627            .expect("rig up completes");
2628        assert_eq!(
2629            result
2630                .orphaned_modules
2631                .iter()
2632                .map(|o| o.id.as_str())
2633                .collect::<Vec<_>>(),
2634            vec!["project-scan-endpoint"],
2635            "RigUpResult carries exactly the dropped module"
2636        );
2637        assert!(
2638            !rig.calls()
2639                .iter()
2640                .any(|c| c.starts_with("uninstall_module"))
2641        );
2642    }
2643
2644    /// UNCHANGED path (SC-3 named literally): a real override on disk
2645    /// and every module still declared. Nothing is orphaned and, again,
2646    /// no uninstall — the "quiet routine `rig up`" case.
2647    #[tokio::test]
2648    async fn up_with_no_module_changes_reports_no_orphans_and_issues_no_uninstall() {
2649        let dir = tempfile::tempdir().expect("tempdir");
2650        let plan = orphan_plan(dir.path());
2651        let override_path = seed_two_mount_override(&plan);
2652
2653        let cache = tempfile::tempdir().expect("cache");
2654        seed_cache_entry(cache.path(), "git", "2.3.4", b"git payload");
2655        seed_cache_entry(
2656            cache.path(),
2657            "project-scan-endpoint",
2658            "1.0.0",
2659            b"scan payload",
2660        );
2661        let feed = crate::module::fetch::ModuleFeed::for_base(
2662            url::Url::parse("http://127.0.0.1:1").expect("literal URL"),
2663        )
2664        .expect("feed");
2665
2666        let provisioning = provision_modules(
2667            &feed,
2668            &plan,
2669            &declared(&[("git", "2.3.4"), ("project-scan-endpoint", "1.0.0")]),
2670            cache.path(),
2671            crate::module::fetch::FetchPolicy::CacheFirst,
2672        )
2673        .await
2674        .expect("provisioning with both modules still declared");
2675        assert!(
2676            provisioning.orphaned_modules.is_empty(),
2677            "nothing changed, nothing is orphaned: {:?}",
2678            provisioning.orphaned_modules
2679        );
2680        assert!(override_path.exists(), "the override was regenerated");
2681
2682        let rig = SnapshotRig::default();
2683        let runner = FakeRunner::with(up_cycle_outputs());
2684        let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2685            .await
2686            .expect("rig up completes");
2687        assert!(
2688            result.orphaned_modules.is_empty(),
2689            "an unchanged rig reports nothing on the result either"
2690        );
2691        assert_eq!(
2692            serde_json::to_value(&result).expect("serialise")["orphaned_modules"],
2693            serde_json::json!([]),
2694            "the key is present and empty, never absent"
2695        );
2696        assert!(
2697            !rig.calls()
2698                .iter()
2699                .any(|c| c.starts_with("uninstall_module"))
2700        );
2701    }
2702
2703    /// `rig reset` carries the SAME report: a reset is a teardown plus an
2704    /// up, so a user who undeclared a module and resets (not ups) must
2705    /// still be told. Driven through the real `rig_reset` with a gateway
2706    /// whose `uninstall_module` panics.
2707    #[tokio::test]
2708    async fn reset_after_undeclaring_reports_orphans_and_issues_no_uninstall() {
2709        let dir = tempfile::tempdir().expect("tempdir");
2710        let plan = orphan_plan(dir.path());
2711        let override_path = seed_two_mount_override(&plan);
2712
2713        let provisioning = undeclared_provisioning(&plan).expect("undeclared provisioning");
2714        assert!(!override_path.exists(), "the stale override is deleted");
2715
2716        let rig = SnapshotRig::default();
2717        let runner = FakeRunner::with(reset_cycle_outputs());
2718        let result = rig_reset(&runner, &plan, 300, Some(&rig), &provisioning)
2719            .await
2720            .expect("reset completes");
2721        assert_eq!(
2722            result
2723                .orphaned_modules
2724                .iter()
2725                .map(|o| o.id.as_str())
2726                .collect::<Vec<_>>(),
2727            vec!["git", "project-scan-endpoint"],
2728            "rig_reset copies the orphan report into RigResetResult"
2729        );
2730        assert!(
2731            !rig.calls()
2732                .iter()
2733                .any(|c| c.starts_with("uninstall_module")),
2734            "no uninstall was attempted: {:?}",
2735            rig.calls()
2736        );
2737    }
2738
2739    // ---------------------------------------------------------------------
2740    // snapshot + restore (04-04) — composition + pre-checks + warning
2741    // ---------------------------------------------------------------------
2742
2743    /// Fake gateway scripting the snapshot/restore flow (the
2744    /// ProjectsRig precedent): records every capability call by name,
2745    /// writes REAL bytes for the gwbk download and each project
2746    /// export, and serves a configurable StatusPing state.
2747    struct SnapshotRig {
2748        calls: Mutex<Vec<String>>,
2749        /// The project names `projects` lists.
2750        project_names: Vec<String>,
2751        /// `gateway_info`'s ignitionVersion.
2752        version: String,
2753        /// The StatusPing state to serve. Default (via the explicit
2754        /// `Default` impl below) is RUNNING — a derived default of `""`
2755        /// would never satisfy the wait and hang tests for the full
2756        /// 300 s restore floor.
2757        ping_state: &'static str,
2758        /// When set, status_ping ERRORS non-retryably (the wait's
2759        /// abort path — a real deadline-expiry test would floor at
2760        /// 300 s, so the immediate-abort shape proves the mapping).
2761        ping_fail: bool,
2762    }
2763
2764    impl Default for SnapshotRig {
2765        fn default() -> Self {
2766            Self {
2767                calls: Mutex::new(Vec::new()),
2768                project_names: Vec::new(),
2769                version: String::new(),
2770                ping_state: "RUNNING",
2771                ping_fail: false,
2772            }
2773        }
2774    }
2775
2776    impl SnapshotRig {
2777        fn calls(&self) -> Vec<String> {
2778            self.calls.lock().unwrap().clone()
2779        }
2780
2781        /// The fixture bytes every download/export writes.
2782        fn fixture_bytes() -> Vec<u8> {
2783            let mut bytes: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04];
2784            bytes.extend_from_slice(b"snapshot-fixture");
2785            bytes
2786        }
2787
2788        fn record(&self, call: String) {
2789            self.calls.lock().unwrap().push(call);
2790        }
2791
2792        fn serve_download(out: &Path, fixture_len: u64) -> crate::client::projects::ExportMeta {
2793            std::fs::write(out, Self::fixture_bytes()).expect("write fixture file");
2794            crate::client::projects::ExportMeta {
2795                filename: None,
2796                bytes: fixture_len,
2797                content_type: Some("application/octet-stream".into()),
2798            }
2799        }
2800    }
2801
2802    #[async_trait::async_trait]
2803    impl GatewayApi for SnapshotRig {
2804        async fn thread_dump(
2805            &self,
2806        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
2807            unreachable!("not part of this action")
2808        }
2809        async fn thread_deadlocks(
2810            &self,
2811        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
2812            unreachable!("not part of this action")
2813        }
2814        /// PANICS, on the call itself (D-19-01): no action driven
2815        /// through this fake may uninstall a module. `rig up`/`rig
2816        /// reset` only ever REPORT an orphan. A panic is a stronger
2817        /// tripwire than a counted request — a request that is merely
2818        /// built but never sent still fails here, and nothing can
2819        /// swallow it into an `Err`. The call is recorded first so the
2820        /// name shows in `calls()` if a caller catches the unwind.
2821        async fn uninstall_module(
2822            &self,
2823            gateway_module_id: &str,
2824            _module_id: &str,
2825        ) -> Result<(), CoreError> {
2826            self.record(format!("uninstall_module({gateway_module_id})"));
2827            panic!(
2828                "rig up must never uninstall a module (D-19-01): \
2829                 uninstall_module({gateway_module_id}) was called"
2830            );
2831        }
2832        async fn bundle_generate(
2833            &self,
2834        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2835            unreachable!("not part of this action")
2836        }
2837        async fn bundle_status(
2838            &self,
2839        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2840            unreachable!("not part of this action")
2841        }
2842        async fn bundle_download(
2843            &self,
2844            _out: &std::path::Path,
2845        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2846            unreachable!("not part of this action")
2847        }
2848        async fn tag_provider_list(
2849            &self,
2850            _query: &crate::client::query::ListQuery,
2851        ) -> Result<
2852            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
2853            CoreError,
2854        > {
2855            unreachable!("not part of this action")
2856        }
2857        async fn tag_provider_find(
2858            &self,
2859            _name: &str,
2860        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
2861            unreachable!("not part of this action")
2862        }
2863        async fn tag_provider_create(
2864            &self,
2865            _body: &[crate::client::tags::TagProviderCreate],
2866        ) -> Result<(), CoreError> {
2867            unreachable!("not part of this action")
2868        }
2869        async fn tag_provider_delete(
2870            &self,
2871            _name: &str,
2872            _signature: &str,
2873        ) -> Result<(), CoreError> {
2874            unreachable!("not part of this action")
2875        }
2876        async fn backup_download(
2877            &self,
2878            out: &Path,
2879            _backup_type: crate::client::backup::BackupType,
2880        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2881            self.record("backup_download".into());
2882            Ok(Self::serve_download(
2883                out,
2884                Self::fixture_bytes().len() as u64,
2885            ))
2886        }
2887        async fn backup_restore(&self, _gwbk: &Path) -> Result<(), CoreError> {
2888            self.record("backup_restore".into());
2889            Ok(())
2890        }
2891        async fn eam_task_history(
2892            &self,
2893            _limit: Option<u32>,
2894            _search: Option<&str>,
2895        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
2896        {
2897            unreachable!("not part of this action")
2898        }
2899        async fn eam_task_definitions(
2900            &self,
2901        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
2902        {
2903            unreachable!("not part of this action")
2904        }
2905        async fn eam_task_find(
2906            &self,
2907            _name: &str,
2908        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
2909            unreachable!("not part of this action")
2910        }
2911        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
2912            unreachable!("not part of this action")
2913        }
2914        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
2915            unreachable!("not part of this action")
2916        }
2917        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
2918            unreachable!("not part of this action")
2919        }
2920        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
2921            unreachable!("not part of this action")
2922        }
2923        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
2924            unreachable!("not part of this action")
2925        }
2926        async fn eam_tasks_scheduled(
2927            &self,
2928            _running: bool,
2929        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
2930            unreachable!("not part of this action")
2931        }
2932        async fn eam_task_modify(
2933            &self,
2934            _definition: &serde_json::Value,
2935        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
2936            unreachable!("not part of this action")
2937        }
2938        async fn eam_task_delete(
2939            &self,
2940            _name: &str,
2941            _signature: &str,
2942            _confirm: bool,
2943        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
2944            unreachable!("not part of this action")
2945        }
2946        async fn api_call(
2947            &self,
2948            _call: &crate::client::apicall::ApiCallRequest,
2949        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
2950            unreachable!("not part of this action")
2951        }
2952        async fn license_status(
2953            &self,
2954        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
2955            unreachable!("not part of this action")
2956        }
2957        async fn redundancy_status(
2958            &self,
2959        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
2960            unreachable!("not part of this action")
2961        }
2962        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
2963            unreachable!("not part of this action")
2964        }
2965        async fn projects(
2966            &self,
2967            _query: &crate::client::query::ListQuery,
2968        ) -> Result<
2969            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
2970            CoreError,
2971        > {
2972            self.record("projects".into());
2973            let items: Vec<crate::client::projects::ProjectRecord> = self
2974                .project_names
2975                .iter()
2976                .map(|name| crate::client::projects::ProjectRecord {
2977                    name: name.clone(),
2978                    title: None,
2979                    description: None,
2980                    enabled: true,
2981                    parent: None,
2982                    inheritable: None,
2983                    invalid_parent: None,
2984                    default_db: None,
2985                    tag_provider: None,
2986                    user_source: None,
2987                    extra: Default::default(),
2988                })
2989                .collect();
2990            let total = items.len() as i64;
2991            Ok(crate::client::query::ListEnvelope {
2992                items,
2993                metadata: crate::client::query::ListMetadata {
2994                    total,
2995                    matching: total,
2996                    limit: -1,
2997                    offset: 0,
2998                },
2999            })
3000        }
3001        async fn project_export_to_file(
3002            &self,
3003            name: &str,
3004            out: &Path,
3005        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
3006            self.record(format!("export:{name}"));
3007            Ok(Self::serve_download(
3008                out,
3009                Self::fixture_bytes().len() as u64,
3010            ))
3011        }
3012        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
3013            self.record("gateway_info".into());
3014            Ok(crate::client::version::GatewayInfo {
3015                name: None,
3016                redundancy_role: None,
3017                edition: None,
3018                ignition_version: self.version.clone(),
3019                jvm_version: None,
3020                license: None,
3021                endpoint: None,
3022            })
3023        }
3024        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
3025            if self.ping_fail {
3026                // A non-retryable probe error: poll aborts immediately
3027                // (its retry set is LOCKED) and the restore wait maps
3028                // it to a Rig error — the deadline-expiry variant is
3029                // the same mapping, pinned by the up-cycle tests.
3030                return Err(CoreError::Internal("probe fixture failure".into()));
3031            }
3032            Ok(crate::client::status::StatusPing {
3033                state: self.ping_state.to_string(),
3034            })
3035        }
3036
3037        // The unreachable chore (the ProjectsRig pattern).
3038        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
3039            unreachable!("not part of this action")
3040        }
3041        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
3042            unreachable!("not part of this action")
3043        }
3044        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
3045            unreachable!("not part of this action")
3046        }
3047        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
3048            unreachable!("not part of this action")
3049        }
3050        async fn modules(
3051            &self,
3052            _quarantined: bool,
3053            _query: &crate::client::query::ListQuery,
3054        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
3055        {
3056            unreachable!("not part of this action")
3057        }
3058        async fn metrics_current(
3059            &self,
3060        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
3061            unreachable!("not part of this action")
3062        }
3063        async fn metrics_historic(
3064            &self,
3065        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
3066            unreachable!("not part of this action")
3067        }
3068        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
3069            unreachable!("not part of this action")
3070        }
3071        async fn designers(
3072            &self,
3073            _query: &crate::client::query::ListQuery,
3074        ) -> Result<
3075            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
3076            CoreError,
3077        > {
3078            unreachable!("not part of this action")
3079        }
3080        async fn perspective_sessions(
3081            &self,
3082            _query: &crate::client::query::ListQuery,
3083        ) -> Result<
3084            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
3085            CoreError,
3086        > {
3087            unreachable!("not part of this action")
3088        }
3089        async fn vision_clients(
3090            &self,
3091            _query: &crate::client::query::ListQuery,
3092        ) -> Result<
3093            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
3094            CoreError,
3095        > {
3096            unreachable!("not part of this action")
3097        }
3098        async fn terminate_perspective_session(
3099            &self,
3100            _id: &str,
3101            _message: Option<&str>,
3102        ) -> Result<(), CoreError> {
3103            unreachable!("not part of this action")
3104        }
3105        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
3106            unreachable!("not part of this action")
3107        }
3108        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
3109            unreachable!("not part of this action")
3110        }
3111        async fn database_connections(
3112            &self,
3113        ) -> Result<
3114            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
3115            CoreError,
3116        > {
3117            unreachable!("not part of this action")
3118        }
3119        async fn opc_connections(
3120            &self,
3121        ) -> Result<
3122            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
3123            CoreError,
3124        > {
3125            unreachable!("not part of this action")
3126        }
3127        async fn logs(
3128            &self,
3129            _filter: &crate::client::logs::LogQuery,
3130        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
3131        {
3132            unreachable!("not part of this action")
3133        }
3134        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
3135            unreachable!("not part of this action")
3136        }
3137        async fn loggers(
3138            &self,
3139            _query: &crate::client::query::ListQuery,
3140        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
3141        {
3142            unreachable!("not part of this action")
3143        }
3144        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
3145            unreachable!("not part of this action")
3146        }
3147        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
3148            unreachable!("not part of this action")
3149        }
3150        async fn restart(&self) -> Result<(), CoreError> {
3151            unreachable!("not part of this action")
3152        }
3153        async fn scan_projects(&self) -> Result<(), CoreError> {
3154            unreachable!("not part of this action")
3155        }
3156        async fn security_properties(
3157            &self,
3158        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
3159            unreachable!("not part of this action")
3160        }
3161        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
3162            unreachable!("not part of this action")
3163        }
3164        async fn webdev_route_call(
3165            &self,
3166            _project: &str,
3167            _route: &str,
3168            _body: &serde_json::Value,
3169            _extra_headers: &[(&str, &str)],
3170        ) -> Result<serde_json::Value, CoreError> {
3171            unreachable!("not part of this action")
3172        }
3173        async fn webdev_route_probe(
3174            &self,
3175            _project: &str,
3176            _route: &str,
3177            _extra_headers: &[(&str, &str)],
3178        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
3179            unreachable!("not part of this action")
3180        }
3181        async fn project_find(
3182            &self,
3183            _name: &str,
3184        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
3185            unreachable!("not part of this action")
3186        }
3187        async fn project_create(
3188            &self,
3189            _body: &crate::client::projects::ProjectCreate,
3190        ) -> Result<(), CoreError> {
3191            unreachable!("not part of this action")
3192        }
3193        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
3194            unreachable!("not part of this action")
3195        }
3196        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
3197            unreachable!("not part of this action")
3198        }
3199        async fn project_modify(
3200            &self,
3201            _name: &str,
3202            _body: &crate::client::projects::ProjectModify,
3203        ) -> Result<(), CoreError> {
3204            unreachable!("not part of this action")
3205        }
3206        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
3207            unreachable!("not part of this action")
3208        }
3209        async fn project_import(
3210            &self,
3211            _name: &str,
3212            _zip: Vec<u8>,
3213            _overwrite: bool,
3214        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
3215            unreachable!("not part of this action")
3216        }
3217    }
3218
3219    /// THE composition pin: gwbk first, every project exported (the
3220    /// spaced name percent-encodes INJECTIVELY into its file name),
3221    /// the manifest EXACT (both exclusion notes verbatim, version from
3222    /// gateway_info), and the call order pinned on the fake's log.
3223    #[tokio::test]
3224    async fn snapshot_composes_gwbk_exports_and_exact_manifest() {
3225        let out_dir = tempfile::tempdir().expect("tempdir");
3226        let rig = SnapshotRig {
3227            project_names: vec!["alpha".into(), "My Project".into()],
3228            version: "8.3.3 (b1)".into(),
3229            ping_state: "RUNNING",
3230            ..SnapshotRig::default()
3231        };
3232
3233        let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
3234            .await
3235            .expect("snapshot composes");
3236        let fixture_len = SnapshotRig::fixture_bytes().len() as u64;
3237        assert_eq!(result.gwbk_bytes, fixture_len);
3238        assert_eq!(
3239            result.projects,
3240            vec!["alpha".to_string(), "My Project".to_string()]
3241        );
3242        assert_eq!(result.dir, out_dir.path().display().to_string());
3243
3244        // The gwbk landed with the fixture bytes (the primary artifact).
3245        let on_disk = std::fs::read(out_dir.path().join("fixture-rig.gwbk")).expect("gwbk exists");
3246        assert_eq!(on_disk, SnapshotRig::fixture_bytes());
3247        // The spaced project exported under its INJECTIVE encoded name.
3248        assert!(out_dir.path().join("projects/alpha.zip").exists());
3249        assert!(out_dir.path().join("projects/My%20Project.zip").exists());
3250
3251        // The manifest — EXACT (taken_at read back and range-checked,
3252        // every other key byte-pinned including BOTH exclusion notes).
3253        let manifest_path = out_dir.path().join("manifest.json");
3254        assert_eq!(result.manifest_path, manifest_path.display().to_string());
3255        let manifest: serde_json::Value =
3256            serde_json::from_str(&std::fs::read_to_string(&manifest_path).expect("manifest read"))
3257                .expect("manifest parses");
3258        let taken_at = manifest["taken_at"].as_i64().expect("taken_at epoch s");
3259        let now = std::time::SystemTime::now()
3260            .duration_since(std::time::UNIX_EPOCH)
3261            .unwrap()
3262            .as_secs() as i64;
3263        assert!(
3264            (now - 5..=now + 5).contains(&taken_at),
3265            "taken_at is epoch seconds near now: {taken_at}"
3266        );
3267        assert_eq!(
3268            manifest,
3269            serde_json::json!({
3270                "rig": "fixture-rig",
3271                "taken_at": taken_at,
3272                "ignition": { "version": "8.3.3 (b1)" },
3273                "gwbk": "fixture-rig.gwbk",
3274                "projects": [
3275                    { "name": "alpha", "file": "projects/alpha.zip" },
3276                    { "name": "My Project", "file": "projects/My%20Project.zip" }
3277                ],
3278                "notes": [
3279                    "trial clock state is NOT captured by gwbk (unknown behavior — reset \
3280                     separately via rig trial reset)",
3281                    "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
3282                     config via gateway data"
3283                ]
3284            }),
3285            "EXACT manifest shape — the honest composition contract"
3286        );
3287
3288        // The call order: gwbk FIRST, then list, then per-project
3289        // exports, then gateway_info (for the manifest).
3290        assert_eq!(
3291            rig.calls(),
3292            vec![
3293                "backup_download".to_string(),
3294                "projects".to_string(),
3295                "export:alpha".to_string(),
3296                "export:My Project".to_string(),
3297                "gateway_info".to_string(),
3298            ]
3299        );
3300    }
3301
3302    /// An empty gateway still snapshots: gwbk + manifest with an EMPTY
3303    /// projects array (all keys always) and no projects/ dir created.
3304    #[tokio::test]
3305    async fn snapshot_of_empty_gateway_carries_empty_projects_key() {
3306        let out_dir = tempfile::tempdir().expect("tempdir");
3307        let rig = SnapshotRig {
3308            version: "8.3.6".into(),
3309            ping_state: "RUNNING",
3310            ..SnapshotRig::default()
3311        };
3312        let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
3313            .await
3314            .expect("empty snapshot composes");
3315        assert_eq!(result.projects, Vec::<String>::new());
3316        assert!(!out_dir.path().join("projects").exists(), "no empty dir");
3317        let manifest: serde_json::Value = serde_json::from_str(
3318            &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
3319        )
3320        .expect("parses");
3321        assert_eq!(
3322            manifest["projects"],
3323            serde_json::json!([]),
3324            "the key is present and empty — agents never key-hunt"
3325        );
3326    }
3327
3328    /// A failed gateway_info degrades HONESTLY: the manifest carries
3329    /// `ignition.version: null` (the snapshot itself succeeded — the
3330    /// gap rides the artifact visibly). gateway_info is the LAST call,
3331    /// so the gwbk + exports already landed.
3332    #[tokio::test]
3333    async fn snapshot_survives_gateway_info_failure_with_null_version() {
3334        // A rig whose gateway_info errors: reuse SnapshotRig for the
3335        // good legs but intercept via a thin wrapper — simpler: point
3336        // version at "" and assert the manifest verbatim? No — the
3337        // honest shape needs a REAL failure. Wiremock does it: serve
3338        // /backup + /projects + /StatusPing, 500 the gateway-info.
3339        let server = wiremock::MockServer::start().await;
3340        wiremock::Mock::given(wiremock::matchers::method("GET"))
3341            .and(wiremock::matchers::path("/data/api/v1/backup"))
3342            .respond_with(
3343                wiremock::ResponseTemplate::new(200)
3344                    .set_body_raw(vec![0x50, 0x4B, 0x03, 0x04], "application/octet-stream"),
3345            )
3346            .mount(&server)
3347            .await;
3348        wiremock::Mock::given(wiremock::matchers::method("GET"))
3349            .and(wiremock::matchers::path("/data/api/v1/projects/list"))
3350            .respond_with(
3351                wiremock::ResponseTemplate::new(200)
3352                    .set_body_json(serde_json::json!({ "items": [], "metadata": {
3353                    "total": 0, "matching": 0, "limit": -1, "offset": 0 } })),
3354            )
3355            .mount(&server)
3356            .await;
3357        wiremock::Mock::given(wiremock::matchers::method("GET"))
3358            .and(wiremock::matchers::path("/data/api/v1/gateway-info"))
3359            .respond_with(wiremock::ResponseTemplate::new(500))
3360            .mount(&server)
3361            .await;
3362        let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
3363
3364        let out_dir = tempfile::tempdir().expect("tempdir");
3365        let result = rig_snapshot(&api, "fixture-rig", Some(out_dir.path()))
3366            .await
3367            .expect("the snapshot survives the metadata failure");
3368        assert_eq!(result.gwbk_bytes, 4);
3369        let manifest: serde_json::Value = serde_json::from_str(
3370            &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
3371        )
3372        .expect("parses");
3373        assert_eq!(manifest["ignition"]["version"], serde_json::Value::Null);
3374    }
3375
3376    /// THE restore pin: pre-checks pass → POST → the witnessed RUNNING
3377    /// wait → the token warning FIRST in data. Exact serialized shape.
3378    #[tokio::test]
3379    async fn restore_posts_waits_and_warns() {
3380        let work = tempfile::tempdir().expect("tempdir");
3381        let gwbk = work.path().join("snapshot.gwbk");
3382        std::fs::write(&gwbk, b"PK\x03\x04restore-fixture").expect("write gwbk");
3383        let rig = SnapshotRig {
3384            ping_state: "RUNNING",
3385            ..SnapshotRig::default()
3386        };
3387
3388        let result = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
3389            .await
3390            .expect("restore completes");
3391        assert_eq!(
3392            serde_json::to_value(&result).unwrap(),
3393            serde_json::json!({
3394                "restored_from": gwbk.display().to_string(),
3395                "state": "running",
3396                "warnings": [
3397                    "API tokens may have been reset by restore — re-provision via \
3398                     gateway UI, then ign doctor"
3399                ],
3400            }),
3401            "EXACT shape — state witnessed (never a bare 2xx), token warning first"
3402        );
3403        assert_eq!(
3404            rig.calls(),
3405            vec!["backup_restore".to_string()],
3406            "the POST fired once (the wait's status_ping probes aren't \
3407             recorded — the fake serves the state directly)"
3408        );
3409    }
3410
3411    /// Pre-check failures are usage-class (exit 2, `invalid_input`)
3412    /// and do ZERO network work — the missing, empty, and unreadable
3413    /// shapes (03-03's lesson; the 03-03 put's --file precedent).
3414    #[tokio::test]
3415    async fn restore_prechecks_fail_before_any_network() {
3416        let rig = SnapshotRig::default();
3417
3418        // Missing file.
3419        let missing = PathBuf::from("/nonexistent/snap.gwbk");
3420        let err = rig_restore(&rig, "http://localhost:9088", &missing, 300)
3421            .await
3422            .expect_err("missing file refuses");
3423        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3424        assert_eq!(err.exit_code(), 2);
3425        assert_eq!(err.code(), "invalid_input");
3426        let message = err.to_string();
3427        assert!(message.contains("not found"), "{message}");
3428
3429        // Empty file.
3430        let work = tempfile::tempdir().expect("tempdir");
3431        let empty = work.path().join("empty.gwbk");
3432        std::fs::write(&empty, b"").expect("write empty");
3433        let err = rig_restore(&rig, "http://localhost:9088", &empty, 300)
3434            .await
3435            .expect_err("empty file refuses");
3436        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3437        assert!(err.to_string().contains("empty"), "{}", err);
3438
3439        // Zero network work across all three refusals (one unreadable
3440        // shape completes the set: a DIRECTORY is not a regular file
3441        // — File::open on a dir succeeds on macOS, so the portable
3442        // is_file() gate is the one that must fire).
3443        let unreadable = work.path(); // a directory
3444        let err = rig_restore(&rig, "http://localhost:9088", unreadable, 300)
3445            .await
3446            .expect_err("directory is not a restorable file");
3447        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3448        assert!(
3449            rig.calls().is_empty(),
3450            "pre-check refusals never touch the gateway: {:?}",
3451            rig.calls()
3452        );
3453    }
3454
3455    /// A failed post-restore wait is a Rig error (exit 7): a
3456    /// non-retryable probe failure aborts the wait and the mapping
3457    /// wraps it as "did not reach RUNNING" — the deadline-expiry
3458    /// variant is the same mapping (pinned by the up-cycle tests at a
3459    /// 1 s deadline; restore's own deadline floors at 300 s, too long
3460    /// to sit out in a unit test).
3461    #[tokio::test]
3462    async fn restore_wait_failure_is_a_rig_error() {
3463        let work = tempfile::tempdir().expect("tempdir");
3464        let gwbk = work.path().join("snapshot.gwbk");
3465        std::fs::write(&gwbk, b"PK\x03\x04fixture").expect("write gwbk");
3466        let rig = SnapshotRig {
3467            ping_fail: true,
3468            ..SnapshotRig::default()
3469        };
3470        let err = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
3471            .await
3472            .expect_err("a failed wait errors the restore");
3473        assert!(matches!(err, CoreError::Rig(_)), "{err}");
3474        assert_eq!(err.exit_code(), 7);
3475        let message = err.to_string();
3476        assert!(message.contains("did not reach RUNNING"), "{message}");
3477        assert!(
3478            rig.calls().contains(&"backup_restore".to_string()),
3479            "the POST fired before the wait: {:?}",
3480            rig.calls()
3481        );
3482    }
3483
3484    /// The floor pins: 300 s (the Pitfall-6 class) and the clamp — an
3485    /// explicit short budget is raised to the floor, a longer one
3486    /// passes through (an explicit `--timeout 30` cannot buy an
3487    /// unknown-state mid-restart report).
3488    #[test]
3489    fn restore_wait_floor_is_300s_and_clamps() {
3490        assert_eq!(super::RESTORE_WAIT_FLOOR_S, 300);
3491        assert_eq!(super::restore_deadline(1), 300, "short budgets floor up");
3492        assert_eq!(super::restore_deadline(300), 300);
3493        assert_eq!(
3494            super::restore_deadline(600),
3495            600,
3496            "longer budgets pass through"
3497        );
3498    }
3499
3500    /// The std-only stamp: known instants render the documented
3501    /// `yyyyMMdd-HHmmss` shape (civil_from_days pinned at the epoch
3502    /// and a known recent instant).
3503    #[test]
3504    fn stamp_renders_utc_compact() {
3505        assert_eq!(super::stamp_from_secs(0), "19700101-000000");
3506        assert_eq!(super::stamp_from_secs(1_787_346_747), "20260821-211227");
3507        assert_eq!(super::civil_from_days(0), (1970, 1, 1));
3508        assert_eq!(super::civil_from_days(19_723), (2024, 1, 1));
3509    }
3510
3511    // ---------------------------------------------------------------------
3512    // rig_status — the allowlist pin
3513    // ---------------------------------------------------------------------
3514
3515    /// ps LDJSON with a Publishers array + a second bare service.
3516    const PS_STDOUT: &str = concat!(
3517        r#"{"Name":"fixture-rig-ignition-1","Service":"ignition","State":"running","Health":"healthy","ExitCode":0,"Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}]}"#,
3518        "\n",
3519        r#"{"Name":"fixture-rig-db-1","Service":"db","State":"exited","ExitCode":137,"Publishers":[]}"#,
3520        "\n",
3521    );
3522
3523    const VOLUME_STDOUT: &str = concat!(
3524        r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
3525        "\n",
3526    );
3527
3528    #[tokio::test]
3529    async fn status_serializes_the_allowlist_exactly() {
3530        // Call order: version(run), ps(run), volume ls(docker), then a
3531        // docker ps per host port (2 ports), each showing an occupant
3532        // (ports_free=false — a running rig holds its own ports).
3533        let occupant = r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
3534        let runner = FakeRunner::with(vec![
3535            version_ok(),
3536            ok(PS_STDOUT),
3537            ok(VOLUME_STDOUT),
3538            ok(occupant),
3539            ok(occupant),
3540        ]);
3541
3542        let result = rig_status(&runner, &gw_plan())
3543            .await
3544            .expect("status succeeds");
3545        let json = serde_json::to_value(&result).unwrap();
3546        assert_eq!(
3547            json,
3548            serde_json::json!({
3549                "rig": "fixture-rig",
3550                "project": "fixture-rig",
3551                "compose_file": "/rigs/docker/compose.yml",
3552                "services": [
3553                    {
3554                        "name": "ignition",
3555                        "state": "running",
3556                        "health": "healthy",
3557                        "exit_code": 0,
3558                        "publishers": [
3559                            {"published_port": 9088, "target_port": 8088, "protocol": "tcp"},
3560                            {"published_port": 9443, "target_port": 443, "protocol": "tcp"}
3561                        ]
3562                    },
3563                    {
3564                        "name": "db",
3565                        "state": "exited",
3566                        "health": null,
3567                        "exit_code": 137,
3568                        "publishers": []
3569                    }
3570                ],
3571                "volumes": ["fixture-rig_gw-data"],
3572                "ports_free": false
3573            }),
3574            "EXACT shape comparison: no compose-config passthrough, no \
3575             unknown keys — the allowlist IS the contract"
3576        );
3577    }
3578
3579    /// A down rig: empty ps, empty docker-ps per port → exit 0, empty
3580    /// services, ports_free true (state is data).
3581    #[tokio::test]
3582    async fn status_down_rig_is_data() {
3583        let runner = FakeRunner::with(vec![version_ok(), ok(""), ok(""), ok(""), ok("")]);
3584        let result = rig_status(&runner, &gw_plan())
3585            .await
3586            .expect("status of a down rig exits 0");
3587        assert!(result.services.is_empty());
3588        assert!(result.volumes.is_empty());
3589        assert!(result.ports_free);
3590    }
3591
3592    /// The result types serialize every family key — agents never
3593    /// key-hunt (the locked shape rule).
3594    #[test]
3595    fn up_and_down_results_carry_all_keys() {
3596        let up = RigUpResult {
3597            rig: "r".into(),
3598            project: "r".into(),
3599            state: "uncommissioned".into(),
3600            gateway_url: None,
3601            warnings: vec![],
3602            provisioned_modules: vec![],
3603            orphaned_modules: vec![],
3604        };
3605        let json = serde_json::to_value(&up).unwrap();
3606        for key in [
3607            "rig",
3608            "project",
3609            "state",
3610            "gateway_url",
3611            "warnings",
3612            "provisioned_modules",
3613            "orphaned_modules",
3614        ] {
3615            assert!(json.get(key).is_some(), "missing key {key}");
3616        }
3617        let down = RigDownResult {
3618            rig: "r".into(),
3619            project: "r".into(),
3620            state: "down".into(),
3621        };
3622        let json = serde_json::to_value(&down).unwrap();
3623        for key in ["rig", "project", "state"] {
3624            assert!(json.get(key).is_some(), "missing key {key}");
3625        }
3626        let reset = RigResetResult {
3627            rig: "r".into(),
3628            project: "r".into(),
3629            removed_volumes: vec![],
3630            state: "running".into(),
3631            warnings: vec![],
3632            provisioned_modules: vec![],
3633            orphaned_modules: vec![],
3634        };
3635        let json = serde_json::to_value(&reset).unwrap();
3636        for key in [
3637            "rig",
3638            "project",
3639            "removed_volumes",
3640            "state",
3641            "warnings",
3642            "provisioned_modules",
3643            "orphaned_modules",
3644        ] {
3645            assert!(json.get(key).is_some(), "missing key {key}");
3646        }
3647        let status_keys = [
3648            "rig",
3649            "project",
3650            "compose_file",
3651            "services",
3652            "volumes",
3653            "ports_free",
3654        ];
3655        let _ = RigStatusResult {
3656            rig: "r".into(),
3657            project: "r".into(),
3658            compose_file: "/c.yml".into(),
3659            services: vec![],
3660            volumes: vec![],
3661            ports_free: true,
3662        };
3663        // (shape asserted end-to-end in status_serializes_the_allowlist_exactly;
3664        // the keys list is pinned here for the doc string)
3665        assert_eq!(status_keys.len(), 6);
3666    }
3667}