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