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