1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::time::Duration;
34
35use serde::Serialize;
36
37use crate::client::GatewayApi;
38use crate::config::ModuleDeclaration;
39use crate::error::CoreError;
40use crate::module::ModuleSpec;
41use crate::poll::{self, PollConfig, PollState};
42use crate::rig::compose::{
43 ComposeRunner, check_output, compose_version, docker_ps_publish_args, down_args, logs_args,
44 parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, reset_preview,
45 up_args, volume_ls_args,
46};
47use crate::rig::modules::{
48 ModuleProvisioning, OrphanedModule, ProvisionedModule, existing_override,
49};
50use crate::rig::{RigPlan, port_preflight};
51
52pub const DEFAULT_WAIT_TIMEOUT_S: u64 = 300;
56
57const RUNNING: &str = "RUNNING";
59
60const GATEWAY_HTTP_TARGET: u16 = 8088;
62const GATEWAY_HTTPS_TARGET: u16 = 443;
63
64#[derive(Debug, Serialize)]
66pub struct RigUpResult {
67 pub rig: String,
69 pub project: String,
72 pub state: String,
74 pub gateway_url: Option<String>,
77 pub warnings: Vec<String>,
80 pub provisioned_modules: Vec<ProvisionedModule>,
85 pub orphaned_modules: Vec<OrphanedModule>,
92}
93
94#[derive(Debug, Serialize)]
96pub struct RigDownResult {
97 pub rig: String,
99 pub project: String,
101 pub state: String,
103}
104
105#[derive(Debug, Serialize)]
107pub struct RigResetResult {
108 pub rig: String,
110 pub project: String,
112 pub removed_volumes: Vec<String>,
115 pub state: String,
118 pub warnings: Vec<String>,
120 pub provisioned_modules: Vec<ProvisionedModule>,
125 pub orphaned_modules: Vec<OrphanedModule>,
129}
130
131#[derive(Debug, Serialize)]
133pub struct StatusPublisher {
134 pub published_port: Option<u16>,
136 pub target_port: Option<u16>,
138 pub protocol: Option<String>,
140}
141
142#[derive(Debug, Serialize)]
144pub struct StatusService {
145 pub name: String,
147 pub state: String,
149 pub health: Option<String>,
151 pub exit_code: Option<i64>,
153 pub publishers: Vec<StatusPublisher>,
155}
156
157#[derive(Debug, Serialize)]
161pub struct RigStatusResult {
162 pub rig: String,
164 pub project: String,
166 pub compose_file: String,
168 pub services: Vec<StatusService>,
171 pub volumes: Vec<String>,
173 pub ports_free: bool,
176}
177
178pub fn gateway_url_from(plan: &RigPlan) -> Option<String> {
184 if let Some(mapping) = plan
185 .port_mappings
186 .iter()
187 .find(|mapping| mapping.target == GATEWAY_HTTP_TARGET)
188 {
189 return Some(format!("http://localhost:{}", mapping.published));
190 }
191 if let Some(mapping) = plan
192 .port_mappings
193 .iter()
194 .find(|mapping| mapping.target == GATEWAY_HTTPS_TARGET)
195 {
196 return Some(format!("https://localhost:{}", mapping.published));
197 }
198 None
199}
200
201pub async fn rig_up(
216 runner: &dyn ComposeRunner,
217 plan: &RigPlan,
218 wait_timeout_s: u64,
219 gateway: Option<&dyn GatewayApi>,
220 provisioning: &ModuleProvisioning,
221) -> Result<RigUpResult, CoreError> {
222 compose_version(runner).await?;
224
225 if let Some(conflict) = port_preflight(runner, plan).await?.first() {
229 return Err(CoreError::Rig(format!(
230 "port {} in use by {} — stop it or change the rig's published port",
231 conflict.port, conflict.attribution
232 )));
233 }
234
235 let output = runner
237 .run(&up_args(
238 plan,
239 wait_timeout_s,
240 provisioning.override_files(),
241 ))
242 .await;
243 check_output(&output, "docker compose up")?;
244
245 let gateway_url = gateway_url_from(plan);
246 let mut warnings = Vec::new();
247 let state = match (gateway, &gateway_url) {
248 (Some(api), Some(url)) => {
249 commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
250 }
251 _ => {
252 warnings.push(
253 "no gateway port mapping (target 8088/443) found — skipped the \
254 commissioned wait"
255 .to_string(),
256 );
257 "running".to_string()
258 }
259 };
260
261 Ok(RigUpResult {
262 rig: plan.name.clone(),
263 project: plan.name.clone(),
264 state,
265 gateway_url,
266 warnings,
267 provisioned_modules: provisioning.modules.clone(),
268 orphaned_modules: provisioning.orphaned_modules.clone(),
269 })
270}
271
272async fn commissioned_wait(
278 api: &dyn GatewayApi,
279 url: &str,
280 wait_timeout_s: u64,
281 warnings: &mut Vec<String>,
282) -> Result<String, CoreError> {
283 let cfg = PollConfig {
284 subject: format!("rig gateway RUNNING (GET {url}/StatusPing)"),
285 interval: Duration::from_secs(2),
286 deadline: Duration::from_secs(wait_timeout_s),
287 ..PollConfig::default()
288 };
289 let mut uncommissioned = Mutex::new(false);
293 let url_owned = url.to_string();
294 let outcome = poll::poll(cfg, &mut uncommissioned, |uncommissioned| {
295 Box::pin(async {
296 match api.status_ping().await {
297 Ok(ping) if ping.state == RUNNING => Ok(PollState::<()>::Done(())),
298 Ok(ping) => Ok(PollState::Pending(Some(ping.state))),
299 Err(CoreError::GatewayNotCommissioned { .. }) => {
302 *uncommissioned.get_mut().expect("commissioned flag") = true;
303 Ok(PollState::Pending(Some(format!(
304 "gateway uncommissioned — open {url_owned}/welcome"
305 ))))
306 }
307 Err(other) => Err(other),
309 }
310 })
311 })
312 .await;
313 match outcome {
314 Ok(()) => Ok("running".to_string()),
315 Err(CoreError::Network { source: None, .. })
319 if *uncommissioned.lock().expect("commissioned flag") =>
320 {
321 warnings.push(format!(
322 "gateway uncommissioned — open {url}/welcome in a browser and complete \
323 the commissioning wizard (no headless commissioning exists)"
324 ));
325 Ok("uncommissioned".to_string())
326 }
327 Err(other) => Err(CoreError::Rig(format!(
328 "gateway did not reach RUNNING within {wait_timeout_s}s — {other}"
329 ))),
330 }
331}
332
333pub async fn rig_down(
342 runner: &dyn ComposeRunner,
343 plan: &RigPlan,
344) -> Result<RigDownResult, CoreError> {
345 compose_version(runner).await?;
346 let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
347 let output = runner.run(&down_args(plan, false, &override_files)).await;
348 check_output(&output, "docker compose down")?;
349 Ok(RigDownResult {
350 rig: plan.name.clone(),
351 project: plan.name.clone(),
352 state: "down".to_string(),
353 })
354}
355
356pub async fn rig_reset(
382 runner: &dyn ComposeRunner,
383 plan: &RigPlan,
384 wait_timeout_s: u64,
385 gateway: Option<&dyn GatewayApi>,
386 provisioning: &ModuleProvisioning,
387) -> Result<RigResetResult, CoreError> {
388 let removed_volumes = reset_preview(runner, plan).await?;
390
391 compose_version(runner).await?;
393
394 let output = runner
398 .run(&down_args(plan, true, provisioning.override_files()))
399 .await;
400 check_output(&output, "docker compose down")?;
401
402 if let Some(conflict) = port_preflight(runner, plan).await?.first() {
404 return Err(CoreError::Rig(format!(
405 "port {} in use by {} — stop it or change the rig's published port \
406 (the rig is torn down; re-run `rig up` once the port frees)",
407 conflict.port, conflict.attribution
408 )));
409 }
410
411 let output = runner
413 .run(&up_args(
414 plan,
415 wait_timeout_s,
416 provisioning.override_files(),
417 ))
418 .await;
419 check_output(&output, "docker compose up")?;
420
421 let gateway_url = gateway_url_from(plan);
423 let mut warnings = Vec::new();
424 let state = match (gateway, &gateway_url) {
425 (Some(api), Some(url)) => {
426 commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
427 }
428 _ => {
429 warnings.push(
430 "no gateway port mapping (target 8088/443) found — skipped the \
431 commissioned wait"
432 .to_string(),
433 );
434 "running".to_string()
435 }
436 };
437
438 Ok(RigResetResult {
439 rig: plan.name.clone(),
440 project: plan.name.clone(),
441 removed_volumes,
442 state,
443 warnings,
444 provisioned_modules: provisioning.modules.clone(),
445 orphaned_modules: provisioning.orphaned_modules.clone(),
446 })
447}
448
449#[derive(Debug, Serialize)]
454pub struct RigLogsResult {
455 pub streamed: usize,
457}
458
459#[derive(Debug, Serialize)]
463pub struct TrialBanners {
464 pub severity: Option<String>,
467 pub expire_time_ms: Option<i64>,
470 pub active: bool,
474}
475
476#[derive(Debug, Serialize)]
480pub struct TrialStatusResult {
481 pub license_mode: String,
483 pub trial_state: String,
486 pub trial_remaining_s: i64,
489 pub expired: bool,
491 pub emergency: bool,
493 pub emergency_remaining_s: i64,
495 pub development: bool,
497 pub banners: TrialBanners,
499 pub warnings: Vec<String>,
502}
503
504fn epoch_ms_now() -> i64 {
506 std::time::SystemTime::now()
507 .duration_since(std::time::UNIX_EPOCH)
508 .expect("system clock is after the unix epoch")
509 .as_millis() as i64
510}
511
512pub async fn trial_status(gateway: &dyn GatewayApi) -> Result<TrialStatusResult, CoreError> {
522 let wire = gateway.trial_status_wire().await?;
523 let mut warnings = Vec::new();
524 let banners = match gateway.banners().await {
525 Ok(set) => {
526 let trial_banner = set.banners.iter().find(|banner| banner.r#type == "trial");
527 match trial_banner {
528 Some(banner) => {
529 let active = banner.data.severity == "info"
530 && banner
531 .data
532 .expire_time_ms
533 .is_some_and(|ms| ms > epoch_ms_now());
534 TrialBanners {
535 severity: Some(banner.data.severity.clone()),
536 expire_time_ms: banner.data.expire_time_ms,
537 active,
538 }
539 }
540 None => TrialBanners {
541 severity: None,
542 expire_time_ms: None,
543 active: false,
544 },
545 }
546 }
547 Err(err) => {
548 warnings.push(format!(
549 "banners cross-check unavailable ({}); the trial endpoint's \
550 expired flag is the truth",
551 err
552 ));
553 TrialBanners {
554 severity: None,
555 expire_time_ms: None,
556 active: false,
557 }
558 }
559 };
560 Ok(TrialStatusResult {
561 license_mode: wire.license_mode,
562 trial_state: wire.trial_state,
563 trial_remaining_s: wire.trial_seconds_left,
564 expired: wire.expired,
565 emergency: wire.emergency,
566 emergency_remaining_s: wire.emergency_seconds_left,
567 development: wire.development,
568 banners,
569 warnings,
570 })
571}
572
573#[derive(Debug, Serialize)]
577pub struct TrialResetResult {
578 pub rig_url: String,
580 pub mechanism: String,
584 pub expired_before: bool,
587 pub expired_after: bool,
590 pub trial_remaining_s: i64,
593}
594
595pub async fn require_rig_up_for_verb(
611 runner: &dyn ComposeRunner,
612 plan: &RigPlan,
613 verb: &str,
614) -> Result<(), CoreError> {
615 let Some(gateway_service) = plan.gateway_service.as_deref() else {
616 return Ok(());
617 };
618 let status = rig_status(runner, plan).await?;
619 let gateway_running = status
620 .services
621 .iter()
622 .any(|service| service.name == gateway_service && service.state == "running");
623 if gateway_running {
624 Ok(())
625 } else {
626 Err(CoreError::Rig(format!(
627 "rig {:?} is DOWN — {verb} addresses the rig's gateway and its gateway \
628 service {gateway_service:?} is not running; `ign rig up` brings it back \
629 (if its ports report held, whatever squats on them is NOT the rig)",
630 plan.name
631 )))
632 }
633}
634
635pub async fn trial_reset(
663 gateway: &dyn GatewayApi,
664 rig_url: &str,
665 token_available: bool,
666 basic: Option<(&str, &crate::config::Secret)>,
667) -> Result<TrialResetResult, CoreError> {
668 let before = gateway.trial_status_wire().await?;
671 if !before.expired {
672 return Err(CoreError::TrialNotExpired {
673 remaining_s: before.trial_seconds_left,
674 endpoint: Some(format!("{rig_url}/data/api/v1/trial")),
675 });
676 }
677
678 if token_available {
683 match gateway.trial_reset_wire().await {
684 Ok(_fresh) => {
685 let after = gateway.trial_status_wire().await?;
686 return finish(rig_url, "token", after);
687 }
688 Err(err) => {
689 tracing::warn!(
690 error = %err,
691 "trial-reset tier 0 (token-auth POST) failed — falling through to the login rung"
692 );
693 }
694 }
695 }
696
697 let Some((username, password)) = basic else {
700 let detail = if token_available {
708 "the token POST was refused and no login pair (IGNITION_USER + \
709 IGNITION_PASSWORD) is set"
710 } else {
711 "no API token (IGNITION_TOKEN) and no login pair (IGNITION_USER + \
712 IGNITION_PASSWORD) resolved"
713 };
714 return Err(CoreError::Rig(format!(
715 "trial reset at {rig_url}: {detail} — the reset needs one working \
716 credential for the rig's gateway"
717 )));
718 };
719 let flow = crate::client::idp::IdpLoginFlow::new(rig_url)?;
720 let (flow, session) = crate::client::idp::login(flow, username, password).await?;
721 crate::client::idp::trial_reset_via_session(&flow, &session).await?;
722 let after = gateway.trial_status_wire().await?;
724 finish(rig_url, "login", after)
725}
726
727fn finish(
729 rig_url: &str,
730 mechanism: &str,
731 after: crate::client::trial::TrialWire,
732) -> Result<TrialResetResult, CoreError> {
733 if after.expired {
734 return Err(CoreError::Internal(format!(
735 "trial reset was accepted but the read-back still reports expired \
736 ({}s left) — re-run `rig trial status` to see the gateway's answer",
737 after.trial_seconds_left
738 )));
739 }
740 Ok(TrialResetResult {
741 rig_url: rig_url.to_string(),
742 mechanism: mechanism.to_string(),
743 expired_before: true,
744 expired_after: after.expired,
745 trial_remaining_s: after.trial_seconds_left,
746 })
747}
748
749pub async fn rig_logs(
761 runner: &dyn ComposeRunner,
762 plan: &RigPlan,
763 tail: u32,
764 follow: bool,
765 service: Option<&str>,
766 sink: &mut (dyn FnMut(String) + Send),
767) -> Result<RigLogsResult, CoreError> {
768 let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
771 let args = logs_args(plan, tail, follow, service, &override_files);
772 let mut streamed = 0usize;
773 let output = if follow {
774 let mut forwarder = |line: &str| {
777 streamed += 1;
778 sink(line.to_string());
779 };
780 runner.run_streaming(&args, &mut forwarder).await
781 } else {
782 runner.run(&args).await
783 };
784 if !output.stderr.trim().is_empty() {
785 tracing::warn!(
786 source = "docker compose logs",
787 stderr = %output.stderr.trim(),
788 "compose diagnostics (stderr passthrough — never the data sink)"
789 );
790 }
791 let stdout = check_output(&output, "docker compose logs")?;
793 for line in stdout.lines() {
794 sink(line.to_string());
795 streamed += 1;
796 }
797 Ok(RigLogsResult { streamed })
798}
799
800pub const RESTORE_WAIT_FLOOR_S: u64 = 300;
810
811pub const RESTORE_TOKEN_WARNING: &str = "API tokens may have been reset by restore \
816— re-provision via gateway UI, then ign doctor";
817
818const MANIFEST_NOTES: [&str; 2] = [
823 "trial clock state is NOT captured by gwbk (unknown behavior — reset \
824 separately via rig trial reset)",
825 "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
826 config via gateway data",
827];
828
829#[derive(Debug, Serialize)]
831pub struct SnapshotResult {
832 pub dir: String,
835 pub gwbk_bytes: u64,
838 pub projects: Vec<String>,
840 pub manifest_path: String,
842}
843
844#[derive(Debug, Serialize)]
846pub struct RestoreResult {
847 pub restored_from: String,
849 pub state: String,
853 pub warnings: Vec<String>,
856}
857
858#[derive(Debug, Serialize)]
861pub struct ModuleUninstallResult {
862 pub rig: String,
864 pub module_id: String,
866 pub gateway_module_id: String,
869 pub uninstalled: bool,
872}
873
874pub fn module_still_declared_error(id: &str, rig: &str) -> CoreError {
889 CoreError::Rig(format!(
890 "module {id:?} is still declared in [rigs.{rig}.modules.{id}] — \
891 uninstalling it now would be undone by the very next `ign rig up` \
892 (the gateway refuses to forget a module whose `.modl` is still \
893 mounted); remove that table entry, run `ign rig up` so the mount \
894 is dropped, then retry `ign rig module uninstall {id} --yes`"
895 ))
896}
897
898pub async fn rig_module_uninstall(
912 api: &dyn GatewayApi,
913 rig: &str,
914 spec: &'static ModuleSpec,
915 declared: &BTreeMap<String, ModuleDeclaration>,
916) -> Result<ModuleUninstallResult, CoreError> {
917 if declared.contains_key(spec.id) {
918 return Err(module_still_declared_error(spec.id, rig));
919 }
920 api.uninstall_module(spec.gateway_module_id, spec.id)
921 .await?;
922 Ok(ModuleUninstallResult {
923 rig: rig.to_string(),
924 module_id: spec.id.to_string(),
925 gateway_module_id: spec.gateway_module_id.to_string(),
926 uninstalled: true,
927 })
928}
929
930fn civil_from_days(days: i64) -> (i64, u32, u32) {
935 let z = days + 719_468;
936 let era = z.div_euclid(146_097);
937 let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
940 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
945}
946
947fn stamp_from_secs(secs: i64) -> String {
949 let days = secs.div_euclid(86_400);
950 let time_of_day = secs.rem_euclid(86_400);
951 let (year, month, day) = civil_from_days(days);
952 let hour = time_of_day / 3600;
953 let minute = (time_of_day % 3600) / 60;
954 let second = time_of_day % 60;
955 format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}{second:02}")
956}
957
958pub async fn rig_snapshot(
983 gateway: &dyn GatewayApi,
984 rig_name: &str,
985 out_dir: Option<&Path>,
986) -> Result<SnapshotResult, CoreError> {
987 let epoch_s = std::time::SystemTime::now()
989 .duration_since(std::time::UNIX_EPOCH)
990 .expect("system clock is after the unix epoch")
991 .as_secs() as i64;
992 let dir: PathBuf = match out_dir {
993 Some(dir) => dir.to_path_buf(),
994 None => PathBuf::from("ign-rig-snapshots").join(format!(
995 "{}-{}",
996 rig_name,
997 stamp_from_secs(epoch_s)
998 )),
999 };
1000 tokio::fs::create_dir_all(&dir)
1001 .await
1002 .map_err(|err| CoreError::Internal(format!("cannot create {}: {err}", dir.display())))?;
1003
1004 let gwbk_name = format!("{rig_name}.gwbk");
1008 let meta = gateway
1009 .backup_download(
1010 &dir.join(&gwbk_name),
1011 crate::client::backup::BackupType::Roaming,
1012 )
1013 .await?;
1014
1015 let page = gateway
1017 .projects(&crate::client::query::ListQuery::default())
1018 .await?;
1019 let projects_dir = dir.join("projects");
1020 let mut exported: Vec<(String, String)> = Vec::new();
1021 for record in &page.items {
1022 if exported.is_empty() {
1023 tokio::fs::create_dir_all(&projects_dir)
1024 .await
1025 .map_err(|err| {
1026 CoreError::Internal(format!("cannot create {}: {err}", projects_dir.display()))
1027 })?;
1028 }
1029 let file = format!(
1030 "projects/{}.zip",
1031 crate::client::projects::encode_segment(&record.name)
1032 );
1033 gateway
1034 .project_export_to_file(&record.name, &dir.join(&file))
1035 .await?;
1036 exported.push((record.name.clone(), file));
1037 }
1038
1039 let version = gateway
1041 .gateway_info()
1042 .await
1043 .ok()
1044 .map(|info| info.ignition_version);
1045 let manifest = serde_json::json!({
1046 "rig": rig_name,
1047 "taken_at": epoch_s,
1048 "ignition": { "version": version },
1049 "gwbk": gwbk_name,
1050 "projects": exported
1051 .iter()
1052 .map(|(name, file)| serde_json::json!({ "name": name, "file": file }))
1053 .collect::<Vec<_>>(),
1054 "notes": MANIFEST_NOTES,
1055 });
1056 let manifest_path = dir.join("manifest.json");
1057 tokio::fs::write(
1058 &manifest_path,
1059 serde_json::to_vec_pretty(&manifest)
1060 .map_err(|err| CoreError::Internal(format!("manifest serialization failed: {err}")))?,
1061 )
1062 .await
1063 .map_err(|err| {
1064 CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
1065 })?;
1066
1067 Ok(SnapshotResult {
1068 dir: dir.display().to_string(),
1069 gwbk_bytes: meta.bytes,
1070 projects: exported.into_iter().map(|(name, _)| name).collect(),
1071 manifest_path: manifest_path.display().to_string(),
1072 })
1073}
1074
1075fn restore_deadline(wait_timeout_s: u64) -> u64 {
1080 wait_timeout_s.max(RESTORE_WAIT_FLOOR_S)
1081}
1082
1083pub async fn rig_restore(
1102 gateway: &dyn GatewayApi,
1103 rig_url: &str,
1104 gwbk: &Path,
1105 wait_timeout_s: u64,
1106) -> Result<RestoreResult, CoreError> {
1107 let meta = std::fs::metadata(gwbk).map_err(|_| CoreError::InvalidInput {
1113 reason: format!("gwbk file {} not found", gwbk.display()),
1114 })?;
1115 if !meta.is_file() {
1116 return Err(CoreError::InvalidInput {
1117 reason: format!("gwbk file {} is not a regular file", gwbk.display()),
1118 });
1119 }
1120 if meta.len() == 0 {
1121 return Err(CoreError::InvalidInput {
1122 reason: format!("gwbk file {} is empty", gwbk.display()),
1123 });
1124 }
1125
1126 gateway.backup_restore(gwbk).await?;
1129
1130 let deadline_s = restore_deadline(wait_timeout_s);
1132 let mut warnings = Vec::new();
1133 let state = commissioned_wait(gateway, rig_url, deadline_s, &mut warnings).await?;
1134
1135 warnings.insert(0, RESTORE_TOKEN_WARNING.to_string());
1137
1138 Ok(RestoreResult {
1139 restored_from: gwbk.display().to_string(),
1140 state,
1141 warnings,
1142 })
1143}
1144
1145pub async fn rig_status(
1150 runner: &dyn ComposeRunner,
1151 plan: &RigPlan,
1152) -> Result<RigStatusResult, CoreError> {
1153 compose_version(runner).await?;
1154
1155 let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
1158 let ps = runner.run(&ps_args(plan, &override_files)).await;
1159 let rows = parse_ps_ldjson(check_output(&ps, "docker compose ps")?);
1160
1161 let volume_ls = runner.run_docker(&volume_ls_args(&plan.name)).await;
1162 let volumes = parse_volume_ls_ldjson(check_output(&volume_ls, "docker volume ls")?)
1163 .into_iter()
1164 .map(|entry| entry.name)
1165 .collect();
1166
1167 let mut ports_free = true;
1170 for port in &plan.host_ports {
1171 let output = runner.run_docker(&docker_ps_publish_args(*port)).await;
1172 let occupants = parse_docker_ps_ldjson(check_output(&output, "docker ps")?);
1173 if !occupants.is_empty() {
1174 ports_free = false;
1175 }
1176 }
1177
1178 let services = rows
1179 .into_iter()
1180 .map(|row| StatusService {
1181 name: if row.service.is_empty() {
1182 row.name
1183 } else {
1184 row.service
1185 },
1186 state: row.state,
1187 health: row.health,
1188 exit_code: row.exit_code,
1189 publishers: row
1190 .publishers
1191 .into_iter()
1192 .map(|publisher| StatusPublisher {
1193 published_port: publisher.published_port,
1194 target_port: publisher.target_port,
1195 protocol: publisher.protocol,
1196 })
1197 .collect(),
1198 })
1199 .collect();
1200
1201 Ok(RigStatusResult {
1202 rig: plan.name.clone(),
1203 project: plan.name.clone(),
1204 compose_file: plan.compose_file.display().to_string(),
1205 services,
1206 volumes,
1207 ports_free,
1208 })
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use std::collections::VecDeque;
1214 use std::path::{Path, PathBuf};
1215 use std::sync::Mutex;
1216
1217 use super::{
1218 DEFAULT_WAIT_TIMEOUT_S, RigDownResult, RigResetResult, RigStatusResult, RigUpResult,
1219 gateway_url_from, require_rig_up_for_verb, rig_down, rig_logs, rig_reset, rig_restore,
1220 rig_snapshot, rig_status, rig_up, trial_reset, trial_status,
1221 };
1222 use crate::client::GatewayApi;
1223 use crate::config::ModuleDeclaration;
1224 use crate::error::CoreError;
1225 use crate::rig::RigPlan;
1226 use crate::rig::compose::{
1227 ComposeOutput, ComposeRunner, PortMapping, down_args, logs_args, up_args, volume_ls_args,
1228 };
1229 use crate::rig::modules::{ModuleProvisioning, provision_modules, undeclared_provisioning};
1230
1231 #[derive(Default)]
1238 struct FakeRunner {
1239 calls: Mutex<Vec<(&'static str, Vec<String>)>>,
1240 outputs: Mutex<VecDeque<ComposeOutput>>,
1241 }
1242
1243 impl FakeRunner {
1244 fn with(outputs: Vec<ComposeOutput>) -> Self {
1245 Self {
1246 outputs: Mutex::new(outputs.into()),
1247 ..Self::default()
1248 }
1249 }
1250
1251 fn calls(&self) -> Vec<(&'static str, Vec<String>)> {
1252 self.calls.lock().unwrap().clone()
1253 }
1254 }
1255
1256 #[async_trait::async_trait]
1257 impl ComposeRunner for FakeRunner {
1258 async fn run(&self, args: &[String]) -> ComposeOutput {
1259 self.calls
1260 .lock()
1261 .unwrap()
1262 .push(("docker compose", args.to_vec()));
1263 self.outputs
1264 .lock()
1265 .unwrap()
1266 .pop_front()
1267 .expect("outputs exhausted")
1268 }
1269
1270 async fn run_docker(&self, args: &[String]) -> ComposeOutput {
1271 self.calls.lock().unwrap().push(("docker", args.to_vec()));
1272 self.outputs
1273 .lock()
1274 .unwrap()
1275 .pop_front()
1276 .expect("outputs exhausted")
1277 }
1278
1279 async fn run_streaming(
1280 &self,
1281 args: &[String],
1282 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
1283 ) -> ComposeOutput {
1284 self.calls
1285 .lock()
1286 .unwrap()
1287 .push(("docker compose", args.to_vec()));
1288 let output = self
1289 .outputs
1290 .lock()
1291 .unwrap()
1292 .pop_front()
1293 .expect("outputs exhausted");
1294 for line in output.stdout.lines() {
1298 line_sink(line);
1299 }
1300 ComposeOutput {
1301 stdout: String::new(),
1302 stderr: output.stderr,
1303 code: output.code,
1304 }
1305 }
1306 }
1307
1308 fn ok(stdout: &str) -> ComposeOutput {
1309 ComposeOutput {
1310 stdout: stdout.to_string(),
1311 stderr: String::new(),
1312 code: 0,
1313 }
1314 }
1315
1316 fn version_ok() -> ComposeOutput {
1317 ok("Docker Compose version v5.1.2\n")
1318 }
1319
1320 const OWN_OCCUPANT: &str =
1327 r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
1328
1329 fn free_ports_for_own_project() -> Vec<ComposeOutput> {
1333 vec![ok(OWN_OCCUPANT), ok(OWN_OCCUPANT)]
1334 }
1335
1336 fn up_cycle_outputs() -> Vec<ComposeOutput> {
1339 let mut outputs = vec![version_ok()];
1340 outputs.extend(free_ports_for_own_project());
1341 outputs.push(ok(""));
1342 outputs
1343 }
1344
1345 fn gw_plan() -> RigPlan {
1348 RigPlan {
1349 name: "fixture-rig".into(),
1350 config_name: None,
1351 compose_file: "/rigs/docker/compose.yml".into(),
1352 project_dir: "/rigs/docker".into(),
1353 services: vec!["ignition".into()],
1354 host_ports: vec![9088, 9443],
1355 port_mappings: vec![
1356 PortMapping {
1357 target: 8088,
1358 published: 9088,
1359 },
1360 PortMapping {
1361 target: 443,
1362 published: 9443,
1363 },
1364 ],
1365 volumes: vec!["gw-data".into()],
1366 gateway_service: Some("ignition".into()),
1367 modules: std::collections::BTreeMap::new(),
1368 }
1369 }
1370
1371 async fn status_ping_server(state: &str) -> wiremock::MockServer {
1373 let server = wiremock::MockServer::start().await;
1374 wiremock::Mock::given(wiremock::matchers::method("GET"))
1375 .and(wiremock::matchers::path("/StatusPing"))
1376 .respond_with(
1377 wiremock::ResponseTemplate::new(200)
1378 .set_body_json(serde_json::json!({ "state": state })),
1379 )
1380 .expect(1..)
1381 .mount(&server)
1382 .await;
1383 server
1384 }
1385
1386 async fn uncommissioned_server() -> wiremock::MockServer {
1390 let server = wiremock::MockServer::start().await;
1391 wiremock::Mock::given(wiremock::matchers::method("GET"))
1392 .and(wiremock::matchers::path("/StatusPing"))
1393 .respond_with(
1394 wiremock::ResponseTemplate::new(302).insert_header("Location", "/welcome"),
1395 )
1396 .expect(1..)
1397 .mount(&server)
1398 .await;
1399 server
1400 }
1401
1402 #[test]
1407 fn gateway_url_prefers_http_8088_then_https_443() {
1408 assert_eq!(
1409 gateway_url_from(&gw_plan()),
1410 Some("http://localhost:9088".to_string()),
1411 "the 8088 mapping wins even though 443 is also present"
1412 );
1413 let https_only = RigPlan {
1414 port_mappings: vec![PortMapping {
1415 target: 443,
1416 published: 9443,
1417 }],
1418 host_ports: vec![9443],
1419 ..gw_plan()
1420 };
1421 assert_eq!(
1422 gateway_url_from(&https_only),
1423 Some("https://localhost:9443".to_string())
1424 );
1425 let no_gateway = RigPlan {
1426 port_mappings: vec![PortMapping {
1427 target: 22,
1428 published: 9022,
1429 }],
1430 host_ports: vec![9022],
1431 ..gw_plan()
1432 };
1433 assert_eq!(gateway_url_from(&no_gateway), None);
1434 }
1435
1436 #[test]
1437 fn default_wait_timeout_is_300() {
1438 assert_eq!(DEFAULT_WAIT_TIMEOUT_S, 300, "research Pitfall 3 headroom");
1439 }
1440
1441 #[tokio::test]
1448 async fn up_success_probes_to_running() {
1449 let server = status_ping_server("RUNNING").await;
1450 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1451
1452 let runner = FakeRunner::with(up_cycle_outputs());
1453 let result = rig_up(
1454 &runner,
1455 &gw_plan(),
1456 300,
1457 Some(&api),
1458 &ModuleProvisioning::default(),
1459 )
1460 .await
1461 .expect("up succeeds");
1462 assert_eq!(result.rig, "fixture-rig");
1463 assert_eq!(result.state, "running");
1464 assert!(result.warnings.is_empty());
1465 assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1466
1467 let calls = runner.calls();
1468 assert_eq!(calls[0], ("docker compose", vec!["version".to_string()]));
1469 assert_eq!(calls[1].0, "docker");
1471 assert_eq!(calls[2].0, "docker");
1472 assert_eq!(
1473 calls[3],
1474 ("docker compose", up_args(&gw_plan(), 300, &[])),
1475 "up rides the LOCKED arg shape"
1476 );
1477 }
1478
1479 #[tokio::test]
1489 async fn up_wires_override_file_as_second_f_argument() {
1490 let override_path = PathBuf::from("/rigs/docker/compose.ign-modules.yml");
1491 let provisioning = ModuleProvisioning {
1492 override_file: Some(override_path.clone()),
1493 modules: Vec::new(),
1494 orphaned_modules: Vec::new(),
1495 };
1496
1497 let runner = FakeRunner::with(up_cycle_outputs());
1498 let result = rig_up(&runner, &gw_plan(), 300, None, &provisioning)
1499 .await
1500 .expect("up succeeds with a provisioned override");
1501 assert!(result.provisioned_modules.is_empty());
1502
1503 let calls = runner.calls();
1504 assert_eq!(
1505 calls[3],
1506 (
1507 "docker compose",
1508 vec![
1509 "-p".to_string(),
1510 "fixture-rig".to_string(),
1511 "-f".to_string(),
1512 "/rigs/docker/compose.yml".to_string(),
1513 "-f".to_string(),
1514 "/rigs/docker/compose.ign-modules.yml".to_string(),
1515 "up".to_string(),
1516 "-d".to_string(),
1517 "--wait".to_string(),
1518 "--wait-timeout".to_string(),
1519 "300".to_string(),
1520 "--remove-orphans".to_string(),
1521 ]
1522 ),
1523 "override is the SECOND -f, base file first, up tail unchanged"
1524 );
1525 }
1526
1527 #[tokio::test]
1531 async fn up_uncommissioned_is_data_not_failure() {
1532 let server = uncommissioned_server().await;
1533 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1534
1535 let runner = FakeRunner::with(up_cycle_outputs());
1536 let result = rig_up(
1537 &runner,
1538 &gw_plan(),
1539 1,
1540 Some(&api),
1541 &ModuleProvisioning::default(),
1542 )
1543 .await
1544 .expect("uncommissioned is exit-0 data");
1545 assert_eq!(result.state, "uncommissioned");
1546 assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1547 assert!(
1548 result
1549 .warnings
1550 .iter()
1551 .any(|warning| warning.contains("http://localhost:9088/welcome")),
1552 "wizard URL in warnings: {:?}",
1553 result.warnings
1554 );
1555 }
1556
1557 #[tokio::test]
1559 async fn up_still_starting_at_deadline_is_rig_error() {
1560 let server = status_ping_server("STARTING").await;
1561 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1562
1563 let runner = FakeRunner::with(up_cycle_outputs());
1564 let err = rig_up(
1565 &runner,
1566 &gw_plan(),
1567 1,
1568 Some(&api),
1569 &ModuleProvisioning::default(),
1570 )
1571 .await
1572 .expect_err("still-STARTING deadline errors");
1573 assert!(matches!(err, CoreError::Rig(_)));
1574 assert_eq!(err.exit_code(), 7);
1575 let message = err.to_string();
1576 assert!(message.contains("did not reach RUNNING"), "{message}");
1577 assert!(
1578 message.contains("STARTING"),
1579 "last observation named: {message}"
1580 );
1581 }
1582
1583 #[tokio::test]
1585 async fn up_port_conflict_aborts_with_attribution() {
1586 let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1587 let runner = FakeRunner::with(vec![version_ok(), ok(occupant), ok(occupant)]);
1588
1589 let err = rig_up(
1590 &runner,
1591 &gw_plan(),
1592 300,
1593 None,
1594 &ModuleProvisioning::default(),
1595 )
1596 .await
1597 .expect_err("cross-project occupant aborts");
1598 let message = err.to_string();
1599 assert!(
1600 message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1601 "{message}"
1602 );
1603 let calls = runner.calls();
1605 assert_eq!(
1606 calls.len(),
1607 3,
1608 "version + two port checks only, no up: {calls:?}"
1609 );
1610 }
1611
1612 #[tokio::test]
1615 async fn up_without_probe_skips_wait_with_warning() {
1616 let runner = FakeRunner::with(up_cycle_outputs());
1617 let result = rig_up(
1618 &runner,
1619 &gw_plan(),
1620 300,
1621 None,
1622 &ModuleProvisioning::default(),
1623 )
1624 .await
1625 .expect("up succeeds without a probe");
1626 assert_eq!(result.state, "running");
1627 assert!(
1628 result
1629 .warnings
1630 .iter()
1631 .any(|warning| warning.contains("skipped the commissioned wait")),
1632 "{:?}",
1633 result.warnings
1634 );
1635 }
1636
1637 #[tokio::test]
1639 async fn up_missing_compose_fails_fast() {
1640 let missing = ComposeOutput {
1641 stdout: String::new(),
1642 stderr: "docker: command not found".into(),
1643 code: 127,
1644 };
1645 let runner = FakeRunner::with(vec![missing]);
1646 let err = rig_up(
1647 &runner,
1648 &gw_plan(),
1649 300,
1650 None,
1651 &ModuleProvisioning::default(),
1652 )
1653 .await
1654 .expect_err("no docker errors");
1655 let message = err.to_string();
1656 assert!(
1657 message.contains("docker compose is unavailable"),
1658 "{message}"
1659 );
1660 assert!(message.contains("not supported"), "{message}");
1661 }
1662
1663 #[tokio::test]
1668 async fn down_runs_exact_args_and_reports_down() {
1669 let runner = FakeRunner::with(vec![version_ok(), ok("")]);
1670 let result = rig_down(&runner, &gw_plan()).await.expect("down succeeds");
1671 assert_eq!(
1672 serde_json::to_value(&result).unwrap(),
1673 serde_json::json!({
1674 "rig": "fixture-rig",
1675 "project": "fixture-rig",
1676 "state": "down",
1677 }),
1678 "RigDownResult shape (all keys always)"
1679 );
1680 let calls = runner.calls();
1681 assert_eq!(
1682 calls[1],
1683 ("docker compose", down_args(&gw_plan(), false, &[]))
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn down_failure_carries_stderr_tail() {
1689 let runner = FakeRunner::with(vec![
1690 version_ok(),
1691 ComposeOutput {
1692 stdout: String::new(),
1693 stderr: "error while removing network: active endpoints".into(),
1694 code: 1,
1695 },
1696 ]);
1697 let err = rig_down(&runner, &gw_plan())
1698 .await
1699 .expect_err("down failure errors");
1700 let message = err.to_string();
1701 assert!(
1702 message.contains("docker compose down failed (exit 1)"),
1703 "{message}"
1704 );
1705 assert!(message.contains("active endpoints"), "{message}");
1706 }
1707
1708 const RESET_VOLUME_STDOUT: &str = concat!(
1716 r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
1717 "\n",
1718 r#"{"Name":"other-rig_gw-data","Labels":{"com.docker.compose.project":"other-rig"}}"#,
1719 "\n",
1720 );
1721
1722 fn reset_cycle_outputs() -> Vec<ComposeOutput> {
1726 let mut outputs = vec![ok(RESET_VOLUME_STDOUT), version_ok(), ok("")];
1727 outputs.extend(free_ports_for_own_project());
1728 outputs.push(ok(""));
1729 outputs
1730 }
1731
1732 #[tokio::test]
1737 async fn reset_previews_tears_down_with_v_then_brings_up() {
1738 let server = status_ping_server("RUNNING").await;
1739 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1740
1741 let runner = FakeRunner::with(reset_cycle_outputs());
1742 let result = rig_reset(
1743 &runner,
1744 &gw_plan(),
1745 300,
1746 Some(&api),
1747 &ModuleProvisioning::default(),
1748 )
1749 .await
1750 .expect("reset succeeds");
1751 assert_eq!(result.rig, "fixture-rig");
1752 assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1753 assert_eq!(result.state, "running");
1754 assert!(result.warnings.is_empty());
1755
1756 let calls = runner.calls();
1758 assert_eq!(calls.len(), 6, "exactly the six scripted calls: {calls:?}");
1759 assert_eq!(
1760 calls[0],
1761 ("docker", volume_ls_args("fixture-rig")),
1762 "preview rides the plain-docker volume ls shape"
1763 );
1764 assert_eq!(calls[1], ("docker compose", vec!["version".to_string()]));
1765 assert_eq!(
1768 calls[2],
1769 (
1770 "docker compose",
1771 vec![
1772 "-p".to_string(),
1773 "fixture-rig".to_string(),
1774 "-f".to_string(),
1775 "/rigs/docker/compose.yml".to_string(),
1776 "down".to_string(),
1777 "--remove-orphans".to_string(),
1778 "-v".to_string(),
1779 ],
1780 ),
1781 "down -v --remove-orphans via the runner seam"
1782 );
1783 assert_eq!(calls[3].0, "docker");
1785 assert_eq!(calls[4].0, "docker");
1786 assert_eq!(calls[5], ("docker compose", up_args(&gw_plan(), 300, &[])));
1787 }
1788
1789 #[tokio::test]
1792 async fn reset_uncommissioned_fresh_volume_is_data() {
1793 let server = uncommissioned_server().await;
1794 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1795
1796 let runner = FakeRunner::with(reset_cycle_outputs());
1797 let result = rig_reset(
1798 &runner,
1799 &gw_plan(),
1800 1,
1801 Some(&api),
1802 &ModuleProvisioning::default(),
1803 )
1804 .await
1805 .expect("uncommissioned reset is exit-0 data");
1806 assert_eq!(result.state, "uncommissioned");
1807 assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1808 assert!(
1809 result
1810 .warnings
1811 .iter()
1812 .any(|warning| warning.contains("http://localhost:9088/welcome")),
1813 "wizard URL in warnings: {:?}",
1814 result.warnings
1815 );
1816 }
1817
1818 #[tokio::test]
1821 async fn reset_port_regrabbed_midcycle_errors_and_never_ups() {
1822 let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1823 let runner = FakeRunner::with(vec![
1824 ok(""), version_ok(),
1826 ok(""), ok(occupant), ok(OWN_OCCUPANT), ]);
1830 let err = rig_reset(
1831 &runner,
1832 &gw_plan(),
1833 300,
1834 None,
1835 &ModuleProvisioning::default(),
1836 )
1837 .await
1838 .expect_err("mid-cycle port grab aborts before the up half");
1839 assert!(matches!(err, CoreError::Rig(_)));
1840 assert_eq!(err.exit_code(), 7);
1841 let message = err.to_string();
1842 assert!(
1843 message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1844 "{message}"
1845 );
1846 assert!(
1847 message.contains("torn down"),
1848 "the hint names the torn-down state: {message}"
1849 );
1850 let calls = runner.calls();
1852 assert_eq!(calls.len(), 5, "no up call: {calls:?}");
1853 assert_eq!(calls.last().expect("calls exist").0, "docker");
1854 }
1855
1856 #[tokio::test]
1858 async fn reset_down_failure_carries_stderr_tail() {
1859 let runner = FakeRunner::with(vec![
1860 ok(""),
1861 version_ok(),
1862 ComposeOutput {
1863 stdout: String::new(),
1864 stderr: "cannot remove volume: in use".into(),
1865 code: 1,
1866 },
1867 ]);
1868 let err = rig_reset(
1869 &runner,
1870 &gw_plan(),
1871 300,
1872 None,
1873 &ModuleProvisioning::default(),
1874 )
1875 .await
1876 .expect_err("down -v failure errors");
1877 let message = err.to_string();
1878 assert!(
1879 message.contains("docker compose down failed (exit 1)"),
1880 "{message}"
1881 );
1882 assert!(message.contains("in use"), "{message}");
1883 }
1884
1885 const LOGS_STDOUT: &str = concat!(
1892 "ignition-1 | 22:01:01.001 INFO Gateway - starting\n",
1893 "ignition-1 | 22:01:02.002 INFO Gateway - RUNNING\n",
1894 );
1895
1896 #[tokio::test]
1899 async fn logs_one_shot_sinks_lines_verbatim() {
1900 let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1901 let mut received: Vec<String> = Vec::new();
1902 let result = rig_logs(&runner, &gw_plan(), 200, false, None, &mut |line| {
1903 received.push(line)
1904 })
1905 .await
1906 .expect("logs succeeds");
1907 assert_eq!(result.streamed, 2);
1908 assert_eq!(
1909 received,
1910 vec![
1911 "ignition-1 | 22:01:01.001 INFO Gateway - starting",
1912 "ignition-1 | 22:01:02.002 INFO Gateway - RUNNING",
1913 ],
1914 "lines pass through verbatim — no envelope wrapping ever"
1915 );
1916 let calls = runner.calls();
1917 assert_eq!(
1918 calls,
1919 vec![(
1920 "docker compose",
1921 logs_args(&gw_plan(), 200, false, None, &[])
1922 )]
1923 );
1924 }
1925
1926 #[tokio::test]
1929 async fn logs_follow_streams_via_the_streaming_seam() {
1930 let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1931 let mut received: Vec<String> = Vec::new();
1932 let result = rig_logs(
1933 &runner,
1934 &gw_plan(),
1935 50,
1936 true,
1937 Some("ignition"),
1938 &mut |line| received.push(line),
1939 )
1940 .await
1941 .expect("follow logs succeeds");
1942 assert_eq!(result.streamed, 2, "streamed lines counted in follow mode");
1943 assert_eq!(received.len(), 2);
1944 let calls = runner.calls();
1945 assert_eq!(
1946 calls,
1947 vec![(
1948 "docker compose",
1949 logs_args(&gw_plan(), 50, true, Some("ignition"), &[])
1950 )]
1951 );
1952 }
1953
1954 #[tokio::test]
1957 async fn logs_failure_carries_stderr_tail_never_sink() {
1958 let runner = FakeRunner::with(vec![ComposeOutput {
1959 stdout: String::new(),
1960 stderr: "no such service: nosvc".into(),
1961 code: 1,
1962 }]);
1963 let mut received: Vec<String> = Vec::new();
1964 let err = rig_logs(
1965 &runner,
1966 &gw_plan(),
1967 200,
1968 false,
1969 Some("nosvc"),
1970 &mut |line| received.push(line),
1971 )
1972 .await
1973 .expect_err("unknown service errors");
1974 let message = err.to_string();
1975 assert!(
1976 message.contains("docker compose logs failed (exit 1)"),
1977 "{message}"
1978 );
1979 assert!(message.contains("no such service"), "{message}");
1980 assert!(received.is_empty(), "diagnostics never ride the data sink");
1981 }
1982
1983 async fn expired_trial_server() -> wiremock::MockServer {
1990 let server = wiremock::MockServer::start().await;
1991 wiremock::Mock::given(wiremock::matchers::method("GET"))
1992 .and(wiremock::matchers::path("/data/api/v1/trial"))
1993 .respond_with(
1994 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1995 "licenseMode": "Trial", "trialState": "AllInDemo",
1996 "trialSecondsLeft": 0, "expired": true,
1997 "emergency": false, "emergencySecondsLeft": 0,
1998 "development": false, "developmentSecondsLeft": 0
1999 })),
2000 )
2001 .mount(&server)
2002 .await;
2003 wiremock::Mock::given(wiremock::matchers::method("GET"))
2004 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2005 .respond_with(
2006 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2007 "banners": [{
2008 "order": 0, "type": "trial",
2009 "data": { "severity": "warning", "expireTime": null,
2010 "toolTips": [], "actions": [] }
2011 }]
2012 })),
2013 )
2014 .mount(&server)
2015 .await;
2016 server
2017 }
2018
2019 #[tokio::test]
2022 async fn trial_status_expired_shape_is_exact() {
2023 let server = expired_trial_server().await;
2024 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2025 let result = trial_status(&api).await.expect("expired status parses");
2026 assert_eq!(
2027 serde_json::to_value(&result).unwrap(),
2028 serde_json::json!({
2029 "license_mode": "Trial",
2030 "trial_state": "AllInDemo",
2031 "trial_remaining_s": 0,
2032 "expired": true,
2033 "emergency": false,
2034 "emergency_remaining_s": 0,
2035 "development": false,
2036 "banners": {
2037 "severity": "warning",
2038 "expire_time_ms": null,
2039 "active": false
2040 },
2041 "warnings": []
2042 }),
2043 "EXACT shape comparison — the unit-explicit keys + the \
2044 banners cross-check block, no unknown keys"
2045 );
2046 }
2047
2048 #[tokio::test]
2053 async fn trial_status_banner_active_requires_future_expire_time() {
2054 for (expire_time, active) in [(9_999_999_999_999_999i64, true), (1i64, false)] {
2055 let server = wiremock::MockServer::start().await;
2056 wiremock::Mock::given(wiremock::matchers::method("GET"))
2057 .and(wiremock::matchers::path("/data/api/v1/trial"))
2058 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2059 serde_json::json!({
2060 "licenseMode": "Trial", "trialState": "AllInDemo",
2061 "trialSecondsLeft": 6590, "expired": false,
2062 "emergency": false, "emergencySecondsLeft": 0,
2063 "development": false, "developmentSecondsLeft": 0
2064 }),
2065 ))
2066 .mount(&server)
2067 .await;
2068 wiremock::Mock::given(wiremock::matchers::method("GET"))
2069 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2070 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2071 serde_json::json!({
2072 "banners": [{
2073 "order": 5, "type": "trial",
2074 "data": { "severity": "info",
2075 "expireTime": expire_time,
2076 "toolTips": [], "actions": [] }
2077 }]
2078 }),
2079 ))
2080 .mount(&server)
2081 .await;
2082 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2083 let result = trial_status(&api).await.expect("active status parses");
2084 assert!(!result.expired, "primary truth from the trial endpoint");
2085 assert_eq!(result.trial_remaining_s, 6590);
2086 assert_eq!(
2087 result.banners.severity.as_deref(),
2088 Some("info"),
2089 "the trial banner surfaced (8.3.3 serves order 5 — not an index)"
2090 );
2091 assert_eq!(
2092 result.banners.active, active,
2093 "info severity + expireTime {expire_time} → active {active} (Pitfall 7)"
2094 );
2095 }
2096 }
2097
2098 #[tokio::test]
2101 async fn trial_status_banners_failure_degrades_with_warning() {
2102 let server = wiremock::MockServer::start().await;
2103 wiremock::Mock::given(wiremock::matchers::method("GET"))
2104 .and(wiremock::matchers::path("/data/api/v1/trial"))
2105 .respond_with(
2106 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2107 "licenseMode": "Trial", "trialState": "AllInDemo",
2108 "trialSecondsLeft": 0, "expired": true,
2109 "emergency": false, "emergencySecondsLeft": 0,
2110 "development": false, "developmentSecondsLeft": 0
2111 })),
2112 )
2113 .mount(&server)
2114 .await;
2115 wiremock::Mock::given(wiremock::matchers::method("GET"))
2116 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2117 .respond_with(wiremock::ResponseTemplate::new(500))
2118 .mount(&server)
2119 .await;
2120 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2121 let result = trial_status(&api).await.expect("primary endpoint answered");
2122 assert!(
2123 result.expired,
2124 "primary truth survives the cross-check failure"
2125 );
2126 assert_eq!(result.banners.severity, None);
2127 assert_eq!(result.banners.expire_time_ms, None);
2128 assert!(!result.banners.active);
2129 assert!(
2130 result
2131 .warnings
2132 .iter()
2133 .any(|warning| warning.contains("banners cross-check unavailable")),
2134 "the degradation is visible data: {:?}",
2135 result.warnings
2136 );
2137 }
2138
2139 fn trial_body(expired: bool, seconds_left: i64) -> serde_json::Value {
2145 serde_json::json!({
2146 "licenseMode": "Trial", "trialState": "AllInDemo",
2147 "trialSecondsLeft": seconds_left, "expired": expired,
2148 "emergency": false, "emergencySecondsLeft": 0,
2149 "development": false, "developmentSecondsLeft": 0
2150 })
2151 }
2152
2153 #[derive(Clone)]
2159 struct TrialFlipScript {
2160 reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2161 post_status: u16,
2162 }
2163
2164 impl wiremock::Respond for TrialFlipScript {
2165 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
2166 if request.method.as_str() == "POST" {
2167 if self.post_status == 200 {
2168 self.reset_done
2169 .store(true, std::sync::atomic::Ordering::SeqCst);
2170 return wiremock::ResponseTemplate::new(200)
2171 .set_body_json(trial_body(false, 7199));
2172 }
2173 return wiremock::ResponseTemplate::new(self.post_status);
2174 }
2175 let expired = !self.reset_done.load(std::sync::atomic::Ordering::SeqCst);
2176 wiremock::ResponseTemplate::new(200)
2177 .set_body_json(trial_body(expired, if expired { 0 } else { 7199 }))
2178 }
2179 }
2180
2181 async fn trial_reset_server(post_status: u16) -> (wiremock::MockServer, TrialFlipScript) {
2184 let server = wiremock::MockServer::start().await;
2185 let script = TrialFlipScript {
2186 reset_done: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2187 post_status,
2188 };
2189 wiremock::Mock::given(wiremock::matchers::method("GET"))
2190 .and(wiremock::matchers::path("/data/api/v1/trial"))
2191 .respond_with(script.clone())
2192 .mount(&server)
2193 .await;
2194 wiremock::Mock::given(wiremock::matchers::method("POST"))
2195 .and(wiremock::matchers::path("/data/api/v1/trial"))
2196 .respond_with(script.clone())
2197 .mount(&server)
2198 .await;
2199 (server, script)
2200 }
2201
2202 #[tokio::test]
2206 async fn trial_reset_refuses_active_trial_up_front() {
2207 let (server, script) = trial_reset_server(200).await;
2208 script
2211 .reset_done
2212 .store(true, std::sync::atomic::Ordering::SeqCst);
2213 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2214 let err = trial_reset(&api, &server.uri(), false, None)
2215 .await
2216 .expect_err("an active trial is refused before any POST");
2217 assert!(matches!(err, CoreError::TrialNotExpired { .. }), "{err}");
2218 assert_eq!(err.exit_code(), 6);
2219 assert_eq!(err.code(), "trial_not_expired");
2220 let message = err.to_string();
2221 assert!(
2222 message.contains("7199s left"),
2223 "the message names the countdown: {message}"
2224 );
2225 }
2226
2227 #[tokio::test]
2230 async fn trial_reset_tier0_lands_with_read_back_flip() {
2231 let (server, _script) = trial_reset_server(200).await;
2232 let credential = crate::config::Credential::Token(crate::config::Secret::new(
2233 "spike:tokengeneratedlive",
2234 ));
2235 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2236 let result = trial_reset(&api, &server.uri(), true, None)
2237 .await
2238 .expect("tier 0 resets the expired trial");
2239 assert_eq!(
2240 serde_json::to_value(&result).unwrap(),
2241 serde_json::json!({
2242 "rig_url": server.uri(),
2243 "mechanism": "token",
2244 "expired_before": true,
2245 "expired_after": false,
2246 "trial_remaining_s": 7199
2247 }),
2248 "EXACT shape — which rung landed + the before/after flip"
2249 );
2250 }
2251
2252 #[tokio::test]
2258 async fn trial_reset_token_refused_without_login_errors() {
2259 let (server, _script) = trial_reset_server(401).await;
2260 let credential =
2261 crate::config::Credential::Token(crate::config::Secret::new("spike:wrongtoken"));
2262 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2263 let err = trial_reset(&api, &server.uri(), true, None)
2264 .await
2265 .expect_err("the refused token rung has no fallback");
2266 assert!(matches!(err, CoreError::Rig(_)), "{err}");
2267 assert_eq!(err.exit_code(), 7);
2268 assert_eq!(err.code(), "rig_error");
2269 assert!(
2270 err.to_string().contains("IGNITION_USER")
2271 && err.to_string().contains("IGNITION_PASSWORD")
2272 && err.to_string().contains(&server.uri()),
2273 "the tail names the URL and both rungs: {err}"
2274 );
2275 }
2276
2277 #[tokio::test]
2281 async fn require_rig_up_refuses_a_down_rig() {
2282 let runner = FakeRunner::with(vec![version_ok(), ok(""), ok(""), ok(""), ok("")]);
2283 let err = require_rig_up_for_verb(&runner, &gw_plan(), "rig trial reset")
2284 .await
2285 .expect_err("a down rig refuses");
2286 assert!(matches!(err, CoreError::Rig(_)), "{err}");
2287 assert_eq!(err.exit_code(), 7);
2288 assert!(
2289 err.to_string().contains("ign rig up") && err.to_string().contains("DOWN"),
2290 "names the state and the way back: {err}"
2291 );
2292 }
2293
2294 #[tokio::test]
2298 async fn require_rig_up_passes_a_running_rig() {
2299 let occupant = r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
2300 let runner = FakeRunner::with(vec![
2301 version_ok(),
2302 ok(PS_STDOUT),
2303 ok(VOLUME_STDOUT),
2304 ok(occupant),
2305 ok(occupant),
2306 ]);
2307 require_rig_up_for_verb(&runner, &gw_plan(), "rig trial reset")
2308 .await
2309 .expect("a running rig passes");
2310 }
2311
2312 async fn login_dance_server() -> (wiremock::MockServer, TrialFlipScript) {
2320 let (server, script) = trial_reset_server(401).await;
2321 wiremock::Mock::given(wiremock::matchers::method("GET"))
2322 .and(wiremock::matchers::path("/data/app/login"))
2323 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2324 "Location",
2325 "/idp/default/oidc/auth?app=gateway&state=st&nonce=nc",
2326 ))
2327 .mount(&server)
2328 .await;
2329 wiremock::Mock::given(wiremock::matchers::method("GET"))
2330 .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2331 .and(wiremock::matchers::query_param_is_missing("token"))
2332 .respond_with(
2333 wiremock::ResponseTemplate::new(302)
2334 .insert_header("Location", "/idp/default/authn/login?app=gateway&token=TT0"),
2335 )
2336 .mount(&server)
2337 .await;
2338 for (body_token, answer) in [
2339 (
2340 "TT0",
2341 r#"{"complete":false,"nextChallenge":[{"type":"basic"}],"token":"TT1"}"#,
2342 ),
2343 ("TT2", r#"{"complete":true,"token":"TT3"}"#),
2344 ] {
2345 wiremock::Mock::given(wiremock::matchers::method("POST"))
2346 .and(wiremock::matchers::path(
2347 "/idp/default/authn/next-challenge",
2348 ))
2349 .and(wiremock::matchers::body_json(
2350 serde_json::json!({ "token": body_token }),
2351 ))
2352 .respond_with(
2353 wiremock::ResponseTemplate::new(200)
2354 .set_body_string(answer)
2355 .insert_header("Content-Type", "application/json"),
2356 )
2357 .mount(&server)
2358 .await;
2359 }
2360 wiremock::Mock::given(wiremock::matchers::method("POST"))
2361 .and(wiremock::matchers::path(
2362 "/idp/default/authn/submit-challenge/basic",
2363 ))
2364 .respond_with(
2365 wiremock::ResponseTemplate::new(200)
2366 .set_body_string(r#"{"success":true,"token":"TT2"}"#)
2367 .insert_header("Content-Type", "application/json"),
2368 )
2369 .mount(&server)
2370 .await;
2371 wiremock::Mock::given(wiremock::matchers::method("GET"))
2372 .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2373 .and(wiremock::matchers::query_param("token", "TT3"))
2374 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2375 "Location",
2376 "/data/federate/callback/internal?code=c&state=st",
2377 ))
2378 .mount(&server)
2379 .await;
2380 wiremock::Mock::given(wiremock::matchers::method("GET"))
2381 .and(wiremock::matchers::path("/data/federate/callback/internal"))
2382 .respond_with(
2383 wiremock::ResponseTemplate::new(302)
2384 .insert_header("Location", "/app")
2385 .append_header("Set-Cookie", "webui-sid-1=sess; Path=/; HttpOnly"),
2386 )
2387 .mount(&server)
2388 .await;
2389 wiremock::Mock::given(wiremock::matchers::method("GET"))
2390 .and(wiremock::matchers::path("/data/app/session"))
2391 .respond_with(
2392 wiremock::ResponseTemplate::new(200)
2393 .set_body_string(r#"{"userPayload":{},"csrfToken":"csrf1"}"#)
2394 .insert_header("Content-Type", "application/json"),
2395 )
2396 .mount(&server)
2397 .await;
2398 wiremock::Mock::given(wiremock::matchers::method("POST"))
2399 .and(wiremock::matchers::path("/data/api/v1/trial"))
2400 .and(wiremock::matchers::header("X-CSRF-Token", "csrf1"))
2401 .respond_with(SessionResetFlip {
2402 reset_done: script.reset_done.clone(),
2403 })
2404 .with_priority(1)
2405 .mount(&server)
2406 .await;
2407 (server, script)
2408 }
2409
2410 struct SessionResetFlip {
2413 reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2414 }
2415
2416 impl wiremock::Respond for SessionResetFlip {
2417 fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
2418 self.reset_done
2419 .store(true, std::sync::atomic::Ordering::SeqCst);
2420 wiremock::ResponseTemplate::new(200).set_body_json(trial_body(false, 7199))
2421 }
2422 }
2423
2424 #[tokio::test]
2428 async fn trial_reset_falls_through_to_the_login_rung() {
2429 let (server, _script) = login_dance_server().await;
2430 let credential =
2431 crate::config::Credential::Token(crate::config::Secret::new("spike:rejectedtoken"));
2432 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2433 let password = crate::config::Secret::new("rig-password");
2434 let result = trial_reset(&api, &server.uri(), true, Some(("admin", &password)))
2435 .await
2436 .expect("the login rung carries the reset");
2437 assert_eq!(result.mechanism, "login");
2438 assert!(result.expired_before);
2439 assert!(!result.expired_after);
2440 assert_eq!(result.trial_remaining_s, 7199);
2441 }
2442
2443 #[tokio::test]
2446 async fn trial_reset_login_rung_alone() {
2447 let (server, _script) = login_dance_server().await;
2448 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2449 let password = crate::config::Secret::new("rig-password");
2450 let result = trial_reset(&api, &server.uri(), false, Some(("admin", &password)))
2451 .await
2452 .expect("login-only reset works");
2453 assert_eq!(result.mechanism, "login");
2454 assert!(!result.expired_after);
2455 }
2456
2457 fn orphan_plan(dir: &Path) -> RigPlan {
2466 RigPlan {
2467 compose_file: dir.join("compose.yml"),
2468 project_dir: dir.to_path_buf(),
2469 ..gw_plan()
2470 }
2471 }
2472
2473 fn seed_two_mount_override(plan: &RigPlan) -> PathBuf {
2480 use crate::module::fetch::ArtifactSource;
2481 use crate::module::{GIT_MODULE, PROJECT_SCAN_ENDPOINT};
2482 use crate::rig::modules::{MountedModule, write_override};
2483
2484 let mounts = [
2485 MountedModule {
2486 spec: &GIT_MODULE,
2487 version: "2.3.4".to_string(),
2488 source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
2489 source_kind: ArtifactSource::Cache,
2490 },
2491 MountedModule {
2492 spec: &PROJECT_SCAN_ENDPOINT,
2493 version: "1.0.0".to_string(),
2494 source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
2495 source_kind: ArtifactSource::Cache,
2496 },
2497 ];
2498 write_override(plan, "ignition", &mounts)
2499 .expect("seed a real two-mount override")
2500 .expect("mounts were non-empty")
2501 }
2502
2503 fn seed_cache_entry(root: &Path, id: &str, version: &str, contents: &[u8]) {
2505 use sha2::{Digest, Sha256};
2506 let dir = root.join("modules").join(id);
2507 std::fs::create_dir_all(&dir).expect("cache dir");
2508 let digest = format!("{:x}", Sha256::digest(contents));
2509 std::fs::write(dir.join(format!("{version}-{digest}.modl")), contents)
2510 .expect("seed cached artifact");
2511 }
2512
2513 fn declared(entries: &[(&str, &str)]) -> std::collections::BTreeMap<String, ModuleDeclaration> {
2514 entries
2515 .iter()
2516 .map(|(id, version)| {
2517 (
2518 id.to_string(),
2519 ModuleDeclaration {
2520 version: version.to_string(),
2521 },
2522 )
2523 })
2524 .collect()
2525 }
2526
2527 fn orphan_ids(provisioning: &ModuleProvisioning) -> Vec<&str> {
2528 provisioning
2529 .orphaned_modules
2530 .iter()
2531 .map(|o| o.id.as_str())
2532 .collect()
2533 }
2534
2535 #[tokio::test]
2538 #[should_panic(expected = "rig up must never uninstall a module")]
2539 async fn the_uninstall_tripwire_actually_panics() {
2540 let rig = SnapshotRig::default();
2541 let _ = rig
2542 .uninstall_module("com.axone_io.ignition.git", "git")
2543 .await;
2544 }
2545
2546 #[tokio::test]
2550 async fn up_after_undeclaring_reports_orphans_and_issues_no_uninstall() {
2551 let dir = tempfile::tempdir().expect("tempdir");
2552 let plan = orphan_plan(dir.path());
2553 let override_path = seed_two_mount_override(&plan);
2554
2555 let provisioning = undeclared_provisioning(&plan).expect("undeclared provisioning");
2556 assert_eq!(
2557 orphan_ids(&provisioning),
2558 vec!["git", "project-scan-endpoint"],
2559 "both undeclared modules are reported"
2560 );
2561 assert!(!override_path.exists(), "the stale override is deleted");
2562
2563 let rig = SnapshotRig::default();
2564 let runner = FakeRunner::with(up_cycle_outputs());
2565 let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2566 .await
2567 .expect("rig up completes without touching the gateway's module list");
2568 assert_eq!(result.state, "running");
2569 assert_eq!(
2573 result
2574 .orphaned_modules
2575 .iter()
2576 .map(|o| o.id.as_str())
2577 .collect::<Vec<_>>(),
2578 vec!["git", "project-scan-endpoint"],
2579 "rig_up copies the orphan report into RigUpResult"
2580 );
2581 let json = serde_json::to_value(&result).expect("serialise");
2582 assert_eq!(json["orphaned_modules"][0]["id"], "git");
2583 assert_eq!(
2584 json["orphaned_modules"][0]["remove_with"],
2585 "ign rig module uninstall git --yes"
2586 );
2587 assert!(
2588 !rig.calls()
2589 .iter()
2590 .any(|c| c.starts_with("uninstall_module")),
2591 "no uninstall was attempted: {:?}",
2592 rig.calls()
2593 );
2594 }
2595
2596 #[tokio::test]
2600 async fn up_after_dropping_one_module_reports_only_that_one_and_issues_no_uninstall() {
2601 let dir = tempfile::tempdir().expect("tempdir");
2602 let plan = orphan_plan(dir.path());
2603 seed_two_mount_override(&plan);
2604
2605 let cache = tempfile::tempdir().expect("cache");
2606 seed_cache_entry(cache.path(), "git", "2.3.4", b"git payload");
2607 let feed = crate::module::fetch::ModuleFeed::for_base(
2608 url::Url::parse("http://127.0.0.1:1").expect("literal URL"),
2609 )
2610 .expect("feed");
2611
2612 let provisioning = provision_modules(
2613 &feed,
2614 &plan,
2615 &declared(&[("git", "2.3.4")]),
2616 cache.path(),
2617 crate::module::fetch::FetchPolicy::CacheFirst,
2618 )
2619 .await
2620 .expect("provisioning with git still declared");
2621 assert_eq!(orphan_ids(&provisioning), vec!["project-scan-endpoint"]);
2622
2623 let rig = SnapshotRig::default();
2624 let runner = FakeRunner::with(up_cycle_outputs());
2625 let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2626 .await
2627 .expect("rig up completes");
2628 assert_eq!(
2629 result
2630 .orphaned_modules
2631 .iter()
2632 .map(|o| o.id.as_str())
2633 .collect::<Vec<_>>(),
2634 vec!["project-scan-endpoint"],
2635 "RigUpResult carries exactly the dropped module"
2636 );
2637 assert!(
2638 !rig.calls()
2639 .iter()
2640 .any(|c| c.starts_with("uninstall_module"))
2641 );
2642 }
2643
2644 #[tokio::test]
2648 async fn up_with_no_module_changes_reports_no_orphans_and_issues_no_uninstall() {
2649 let dir = tempfile::tempdir().expect("tempdir");
2650 let plan = orphan_plan(dir.path());
2651 let override_path = seed_two_mount_override(&plan);
2652
2653 let cache = tempfile::tempdir().expect("cache");
2654 seed_cache_entry(cache.path(), "git", "2.3.4", b"git payload");
2655 seed_cache_entry(
2656 cache.path(),
2657 "project-scan-endpoint",
2658 "1.0.0",
2659 b"scan payload",
2660 );
2661 let feed = crate::module::fetch::ModuleFeed::for_base(
2662 url::Url::parse("http://127.0.0.1:1").expect("literal URL"),
2663 )
2664 .expect("feed");
2665
2666 let provisioning = provision_modules(
2667 &feed,
2668 &plan,
2669 &declared(&[("git", "2.3.4"), ("project-scan-endpoint", "1.0.0")]),
2670 cache.path(),
2671 crate::module::fetch::FetchPolicy::CacheFirst,
2672 )
2673 .await
2674 .expect("provisioning with both modules still declared");
2675 assert!(
2676 provisioning.orphaned_modules.is_empty(),
2677 "nothing changed, nothing is orphaned: {:?}",
2678 provisioning.orphaned_modules
2679 );
2680 assert!(override_path.exists(), "the override was regenerated");
2681
2682 let rig = SnapshotRig::default();
2683 let runner = FakeRunner::with(up_cycle_outputs());
2684 let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2685 .await
2686 .expect("rig up completes");
2687 assert!(
2688 result.orphaned_modules.is_empty(),
2689 "an unchanged rig reports nothing on the result either"
2690 );
2691 assert_eq!(
2692 serde_json::to_value(&result).expect("serialise")["orphaned_modules"],
2693 serde_json::json!([]),
2694 "the key is present and empty, never absent"
2695 );
2696 assert!(
2697 !rig.calls()
2698 .iter()
2699 .any(|c| c.starts_with("uninstall_module"))
2700 );
2701 }
2702
2703 #[tokio::test]
2708 async fn reset_after_undeclaring_reports_orphans_and_issues_no_uninstall() {
2709 let dir = tempfile::tempdir().expect("tempdir");
2710 let plan = orphan_plan(dir.path());
2711 let override_path = seed_two_mount_override(&plan);
2712
2713 let provisioning = undeclared_provisioning(&plan).expect("undeclared provisioning");
2714 assert!(!override_path.exists(), "the stale override is deleted");
2715
2716 let rig = SnapshotRig::default();
2717 let runner = FakeRunner::with(reset_cycle_outputs());
2718 let result = rig_reset(&runner, &plan, 300, Some(&rig), &provisioning)
2719 .await
2720 .expect("reset completes");
2721 assert_eq!(
2722 result
2723 .orphaned_modules
2724 .iter()
2725 .map(|o| o.id.as_str())
2726 .collect::<Vec<_>>(),
2727 vec!["git", "project-scan-endpoint"],
2728 "rig_reset copies the orphan report into RigResetResult"
2729 );
2730 assert!(
2731 !rig.calls()
2732 .iter()
2733 .any(|c| c.starts_with("uninstall_module")),
2734 "no uninstall was attempted: {:?}",
2735 rig.calls()
2736 );
2737 }
2738
2739 struct SnapshotRig {
2748 calls: Mutex<Vec<String>>,
2749 project_names: Vec<String>,
2751 version: String,
2753 ping_state: &'static str,
2758 ping_fail: bool,
2762 }
2763
2764 impl Default for SnapshotRig {
2765 fn default() -> Self {
2766 Self {
2767 calls: Mutex::new(Vec::new()),
2768 project_names: Vec::new(),
2769 version: String::new(),
2770 ping_state: "RUNNING",
2771 ping_fail: false,
2772 }
2773 }
2774 }
2775
2776 impl SnapshotRig {
2777 fn calls(&self) -> Vec<String> {
2778 self.calls.lock().unwrap().clone()
2779 }
2780
2781 fn fixture_bytes() -> Vec<u8> {
2783 let mut bytes: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04];
2784 bytes.extend_from_slice(b"snapshot-fixture");
2785 bytes
2786 }
2787
2788 fn record(&self, call: String) {
2789 self.calls.lock().unwrap().push(call);
2790 }
2791
2792 fn serve_download(out: &Path, fixture_len: u64) -> crate::client::projects::ExportMeta {
2793 std::fs::write(out, Self::fixture_bytes()).expect("write fixture file");
2794 crate::client::projects::ExportMeta {
2795 filename: None,
2796 bytes: fixture_len,
2797 content_type: Some("application/octet-stream".into()),
2798 }
2799 }
2800 }
2801
2802 #[async_trait::async_trait]
2803 impl GatewayApi for SnapshotRig {
2804 async fn thread_dump(
2805 &self,
2806 ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
2807 unreachable!("not part of this action")
2808 }
2809 async fn thread_deadlocks(
2810 &self,
2811 ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
2812 unreachable!("not part of this action")
2813 }
2814 async fn uninstall_module(
2822 &self,
2823 gateway_module_id: &str,
2824 _module_id: &str,
2825 ) -> Result<(), CoreError> {
2826 self.record(format!("uninstall_module({gateway_module_id})"));
2827 panic!(
2828 "rig up must never uninstall a module (D-19-01): \
2829 uninstall_module({gateway_module_id}) was called"
2830 );
2831 }
2832 async fn bundle_generate(
2833 &self,
2834 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2835 unreachable!("not part of this action")
2836 }
2837 async fn bundle_status(
2838 &self,
2839 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2840 unreachable!("not part of this action")
2841 }
2842 async fn bundle_download(
2843 &self,
2844 _out: &std::path::Path,
2845 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2846 unreachable!("not part of this action")
2847 }
2848 async fn tag_provider_list(
2849 &self,
2850 _query: &crate::client::query::ListQuery,
2851 ) -> Result<
2852 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
2853 CoreError,
2854 > {
2855 unreachable!("not part of this action")
2856 }
2857 async fn tag_provider_find(
2858 &self,
2859 _name: &str,
2860 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
2861 unreachable!("not part of this action")
2862 }
2863 async fn tag_provider_create(
2864 &self,
2865 _body: &[crate::client::tags::TagProviderCreate],
2866 ) -> Result<(), CoreError> {
2867 unreachable!("not part of this action")
2868 }
2869 async fn tag_provider_delete(
2870 &self,
2871 _name: &str,
2872 _signature: &str,
2873 ) -> Result<(), CoreError> {
2874 unreachable!("not part of this action")
2875 }
2876 async fn backup_download(
2877 &self,
2878 out: &Path,
2879 _backup_type: crate::client::backup::BackupType,
2880 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2881 self.record("backup_download".into());
2882 Ok(Self::serve_download(
2883 out,
2884 Self::fixture_bytes().len() as u64,
2885 ))
2886 }
2887 async fn backup_restore(&self, _gwbk: &Path) -> Result<(), CoreError> {
2888 self.record("backup_restore".into());
2889 Ok(())
2890 }
2891 async fn eam_task_history(
2892 &self,
2893 _limit: Option<u32>,
2894 _search: Option<&str>,
2895 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
2896 {
2897 unreachable!("not part of this action")
2898 }
2899 async fn eam_task_definitions(
2900 &self,
2901 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
2902 {
2903 unreachable!("not part of this action")
2904 }
2905 async fn eam_task_find(
2906 &self,
2907 _name: &str,
2908 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
2909 unreachable!("not part of this action")
2910 }
2911 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
2912 unreachable!("not part of this action")
2913 }
2914 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
2915 unreachable!("not part of this action")
2916 }
2917 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
2918 unreachable!("not part of this action")
2919 }
2920 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
2921 unreachable!("not part of this action")
2922 }
2923 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
2924 unreachable!("not part of this action")
2925 }
2926 async fn eam_tasks_scheduled(
2927 &self,
2928 _running: bool,
2929 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
2930 unreachable!("not part of this action")
2931 }
2932 async fn eam_task_modify(
2933 &self,
2934 _definition: &serde_json::Value,
2935 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
2936 unreachable!("not part of this action")
2937 }
2938 async fn eam_task_delete(
2939 &self,
2940 _name: &str,
2941 _signature: &str,
2942 _confirm: bool,
2943 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
2944 unreachable!("not part of this action")
2945 }
2946 async fn api_call(
2947 &self,
2948 _call: &crate::client::apicall::ApiCallRequest,
2949 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
2950 unreachable!("not part of this action")
2951 }
2952 async fn license_status(
2953 &self,
2954 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
2955 unreachable!("not part of this action")
2956 }
2957 async fn redundancy_status(
2958 &self,
2959 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
2960 unreachable!("not part of this action")
2961 }
2962 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
2963 unreachable!("not part of this action")
2964 }
2965 async fn projects(
2966 &self,
2967 _query: &crate::client::query::ListQuery,
2968 ) -> Result<
2969 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
2970 CoreError,
2971 > {
2972 self.record("projects".into());
2973 let items: Vec<crate::client::projects::ProjectRecord> = self
2974 .project_names
2975 .iter()
2976 .map(|name| crate::client::projects::ProjectRecord {
2977 name: name.clone(),
2978 title: None,
2979 description: None,
2980 enabled: true,
2981 parent: None,
2982 inheritable: None,
2983 invalid_parent: None,
2984 default_db: None,
2985 tag_provider: None,
2986 user_source: None,
2987 extra: Default::default(),
2988 })
2989 .collect();
2990 let total = items.len() as i64;
2991 Ok(crate::client::query::ListEnvelope {
2992 items,
2993 metadata: crate::client::query::ListMetadata {
2994 total,
2995 matching: total,
2996 limit: -1,
2997 offset: 0,
2998 },
2999 })
3000 }
3001 async fn project_export_to_file(
3002 &self,
3003 name: &str,
3004 out: &Path,
3005 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
3006 self.record(format!("export:{name}"));
3007 Ok(Self::serve_download(
3008 out,
3009 Self::fixture_bytes().len() as u64,
3010 ))
3011 }
3012 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
3013 self.record("gateway_info".into());
3014 Ok(crate::client::version::GatewayInfo {
3015 name: None,
3016 redundancy_role: None,
3017 edition: None,
3018 ignition_version: self.version.clone(),
3019 jvm_version: None,
3020 license: None,
3021 endpoint: None,
3022 })
3023 }
3024 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
3025 if self.ping_fail {
3026 return Err(CoreError::Internal("probe fixture failure".into()));
3031 }
3032 Ok(crate::client::status::StatusPing {
3033 state: self.ping_state.to_string(),
3034 })
3035 }
3036
3037 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
3039 unreachable!("not part of this action")
3040 }
3041 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
3042 unreachable!("not part of this action")
3043 }
3044 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
3045 unreachable!("not part of this action")
3046 }
3047 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
3048 unreachable!("not part of this action")
3049 }
3050 async fn modules(
3051 &self,
3052 _quarantined: bool,
3053 _query: &crate::client::query::ListQuery,
3054 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
3055 {
3056 unreachable!("not part of this action")
3057 }
3058 async fn metrics_current(
3059 &self,
3060 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
3061 unreachable!("not part of this action")
3062 }
3063 async fn metrics_historic(
3064 &self,
3065 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
3066 unreachable!("not part of this action")
3067 }
3068 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
3069 unreachable!("not part of this action")
3070 }
3071 async fn designers(
3072 &self,
3073 _query: &crate::client::query::ListQuery,
3074 ) -> Result<
3075 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
3076 CoreError,
3077 > {
3078 unreachable!("not part of this action")
3079 }
3080 async fn perspective_sessions(
3081 &self,
3082 _query: &crate::client::query::ListQuery,
3083 ) -> Result<
3084 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
3085 CoreError,
3086 > {
3087 unreachable!("not part of this action")
3088 }
3089 async fn vision_clients(
3090 &self,
3091 _query: &crate::client::query::ListQuery,
3092 ) -> Result<
3093 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
3094 CoreError,
3095 > {
3096 unreachable!("not part of this action")
3097 }
3098 async fn terminate_perspective_session(
3099 &self,
3100 _id: &str,
3101 _message: Option<&str>,
3102 ) -> Result<(), CoreError> {
3103 unreachable!("not part of this action")
3104 }
3105 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
3106 unreachable!("not part of this action")
3107 }
3108 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
3109 unreachable!("not part of this action")
3110 }
3111 async fn database_connections(
3112 &self,
3113 ) -> Result<
3114 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
3115 CoreError,
3116 > {
3117 unreachable!("not part of this action")
3118 }
3119 async fn opc_connections(
3120 &self,
3121 ) -> Result<
3122 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
3123 CoreError,
3124 > {
3125 unreachable!("not part of this action")
3126 }
3127 async fn logs(
3128 &self,
3129 _filter: &crate::client::logs::LogQuery,
3130 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
3131 {
3132 unreachable!("not part of this action")
3133 }
3134 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
3135 unreachable!("not part of this action")
3136 }
3137 async fn loggers(
3138 &self,
3139 _query: &crate::client::query::ListQuery,
3140 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
3141 {
3142 unreachable!("not part of this action")
3143 }
3144 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
3145 unreachable!("not part of this action")
3146 }
3147 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
3148 unreachable!("not part of this action")
3149 }
3150 async fn restart(&self) -> Result<(), CoreError> {
3151 unreachable!("not part of this action")
3152 }
3153 async fn scan_projects(&self) -> Result<(), CoreError> {
3154 unreachable!("not part of this action")
3155 }
3156 async fn security_properties(
3157 &self,
3158 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
3159 unreachable!("not part of this action")
3160 }
3161 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
3162 unreachable!("not part of this action")
3163 }
3164 async fn webdev_route_call(
3165 &self,
3166 _project: &str,
3167 _route: &str,
3168 _body: &serde_json::Value,
3169 _extra_headers: &[(&str, &str)],
3170 ) -> Result<serde_json::Value, CoreError> {
3171 unreachable!("not part of this action")
3172 }
3173 async fn webdev_route_probe(
3174 &self,
3175 _project: &str,
3176 _route: &str,
3177 _extra_headers: &[(&str, &str)],
3178 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
3179 unreachable!("not part of this action")
3180 }
3181 async fn project_find(
3182 &self,
3183 _name: &str,
3184 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
3185 unreachable!("not part of this action")
3186 }
3187 async fn project_create(
3188 &self,
3189 _body: &crate::client::projects::ProjectCreate,
3190 ) -> Result<(), CoreError> {
3191 unreachable!("not part of this action")
3192 }
3193 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
3194 unreachable!("not part of this action")
3195 }
3196 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
3197 unreachable!("not part of this action")
3198 }
3199 async fn project_modify(
3200 &self,
3201 _name: &str,
3202 _body: &crate::client::projects::ProjectModify,
3203 ) -> Result<(), CoreError> {
3204 unreachable!("not part of this action")
3205 }
3206 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
3207 unreachable!("not part of this action")
3208 }
3209 async fn project_import(
3210 &self,
3211 _name: &str,
3212 _zip: Vec<u8>,
3213 _overwrite: bool,
3214 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
3215 unreachable!("not part of this action")
3216 }
3217 }
3218
3219 #[tokio::test]
3224 async fn snapshot_composes_gwbk_exports_and_exact_manifest() {
3225 let out_dir = tempfile::tempdir().expect("tempdir");
3226 let rig = SnapshotRig {
3227 project_names: vec!["alpha".into(), "My Project".into()],
3228 version: "8.3.3 (b1)".into(),
3229 ping_state: "RUNNING",
3230 ..SnapshotRig::default()
3231 };
3232
3233 let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
3234 .await
3235 .expect("snapshot composes");
3236 let fixture_len = SnapshotRig::fixture_bytes().len() as u64;
3237 assert_eq!(result.gwbk_bytes, fixture_len);
3238 assert_eq!(
3239 result.projects,
3240 vec!["alpha".to_string(), "My Project".to_string()]
3241 );
3242 assert_eq!(result.dir, out_dir.path().display().to_string());
3243
3244 let on_disk = std::fs::read(out_dir.path().join("fixture-rig.gwbk")).expect("gwbk exists");
3246 assert_eq!(on_disk, SnapshotRig::fixture_bytes());
3247 assert!(out_dir.path().join("projects/alpha.zip").exists());
3249 assert!(out_dir.path().join("projects/My%20Project.zip").exists());
3250
3251 let manifest_path = out_dir.path().join("manifest.json");
3254 assert_eq!(result.manifest_path, manifest_path.display().to_string());
3255 let manifest: serde_json::Value =
3256 serde_json::from_str(&std::fs::read_to_string(&manifest_path).expect("manifest read"))
3257 .expect("manifest parses");
3258 let taken_at = manifest["taken_at"].as_i64().expect("taken_at epoch s");
3259 let now = std::time::SystemTime::now()
3260 .duration_since(std::time::UNIX_EPOCH)
3261 .unwrap()
3262 .as_secs() as i64;
3263 assert!(
3264 (now - 5..=now + 5).contains(&taken_at),
3265 "taken_at is epoch seconds near now: {taken_at}"
3266 );
3267 assert_eq!(
3268 manifest,
3269 serde_json::json!({
3270 "rig": "fixture-rig",
3271 "taken_at": taken_at,
3272 "ignition": { "version": "8.3.3 (b1)" },
3273 "gwbk": "fixture-rig.gwbk",
3274 "projects": [
3275 { "name": "alpha", "file": "projects/alpha.zip" },
3276 { "name": "My Project", "file": "projects/My%20Project.zip" }
3277 ],
3278 "notes": [
3279 "trial clock state is NOT captured by gwbk (unknown behavior — reset \
3280 separately via rig trial reset)",
3281 "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
3282 config via gateway data"
3283 ]
3284 }),
3285 "EXACT manifest shape — the honest composition contract"
3286 );
3287
3288 assert_eq!(
3291 rig.calls(),
3292 vec![
3293 "backup_download".to_string(),
3294 "projects".to_string(),
3295 "export:alpha".to_string(),
3296 "export:My Project".to_string(),
3297 "gateway_info".to_string(),
3298 ]
3299 );
3300 }
3301
3302 #[tokio::test]
3305 async fn snapshot_of_empty_gateway_carries_empty_projects_key() {
3306 let out_dir = tempfile::tempdir().expect("tempdir");
3307 let rig = SnapshotRig {
3308 version: "8.3.6".into(),
3309 ping_state: "RUNNING",
3310 ..SnapshotRig::default()
3311 };
3312 let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
3313 .await
3314 .expect("empty snapshot composes");
3315 assert_eq!(result.projects, Vec::<String>::new());
3316 assert!(!out_dir.path().join("projects").exists(), "no empty dir");
3317 let manifest: serde_json::Value = serde_json::from_str(
3318 &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
3319 )
3320 .expect("parses");
3321 assert_eq!(
3322 manifest["projects"],
3323 serde_json::json!([]),
3324 "the key is present and empty — agents never key-hunt"
3325 );
3326 }
3327
3328 #[tokio::test]
3333 async fn snapshot_survives_gateway_info_failure_with_null_version() {
3334 let server = wiremock::MockServer::start().await;
3340 wiremock::Mock::given(wiremock::matchers::method("GET"))
3341 .and(wiremock::matchers::path("/data/api/v1/backup"))
3342 .respond_with(
3343 wiremock::ResponseTemplate::new(200)
3344 .set_body_raw(vec![0x50, 0x4B, 0x03, 0x04], "application/octet-stream"),
3345 )
3346 .mount(&server)
3347 .await;
3348 wiremock::Mock::given(wiremock::matchers::method("GET"))
3349 .and(wiremock::matchers::path("/data/api/v1/projects/list"))
3350 .respond_with(
3351 wiremock::ResponseTemplate::new(200)
3352 .set_body_json(serde_json::json!({ "items": [], "metadata": {
3353 "total": 0, "matching": 0, "limit": -1, "offset": 0 } })),
3354 )
3355 .mount(&server)
3356 .await;
3357 wiremock::Mock::given(wiremock::matchers::method("GET"))
3358 .and(wiremock::matchers::path("/data/api/v1/gateway-info"))
3359 .respond_with(wiremock::ResponseTemplate::new(500))
3360 .mount(&server)
3361 .await;
3362 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
3363
3364 let out_dir = tempfile::tempdir().expect("tempdir");
3365 let result = rig_snapshot(&api, "fixture-rig", Some(out_dir.path()))
3366 .await
3367 .expect("the snapshot survives the metadata failure");
3368 assert_eq!(result.gwbk_bytes, 4);
3369 let manifest: serde_json::Value = serde_json::from_str(
3370 &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
3371 )
3372 .expect("parses");
3373 assert_eq!(manifest["ignition"]["version"], serde_json::Value::Null);
3374 }
3375
3376 #[tokio::test]
3379 async fn restore_posts_waits_and_warns() {
3380 let work = tempfile::tempdir().expect("tempdir");
3381 let gwbk = work.path().join("snapshot.gwbk");
3382 std::fs::write(&gwbk, b"PK\x03\x04restore-fixture").expect("write gwbk");
3383 let rig = SnapshotRig {
3384 ping_state: "RUNNING",
3385 ..SnapshotRig::default()
3386 };
3387
3388 let result = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
3389 .await
3390 .expect("restore completes");
3391 assert_eq!(
3392 serde_json::to_value(&result).unwrap(),
3393 serde_json::json!({
3394 "restored_from": gwbk.display().to_string(),
3395 "state": "running",
3396 "warnings": [
3397 "API tokens may have been reset by restore — re-provision via \
3398 gateway UI, then ign doctor"
3399 ],
3400 }),
3401 "EXACT shape — state witnessed (never a bare 2xx), token warning first"
3402 );
3403 assert_eq!(
3404 rig.calls(),
3405 vec!["backup_restore".to_string()],
3406 "the POST fired once (the wait's status_ping probes aren't \
3407 recorded — the fake serves the state directly)"
3408 );
3409 }
3410
3411 #[tokio::test]
3415 async fn restore_prechecks_fail_before_any_network() {
3416 let rig = SnapshotRig::default();
3417
3418 let missing = PathBuf::from("/nonexistent/snap.gwbk");
3420 let err = rig_restore(&rig, "http://localhost:9088", &missing, 300)
3421 .await
3422 .expect_err("missing file refuses");
3423 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3424 assert_eq!(err.exit_code(), 2);
3425 assert_eq!(err.code(), "invalid_input");
3426 let message = err.to_string();
3427 assert!(message.contains("not found"), "{message}");
3428
3429 let work = tempfile::tempdir().expect("tempdir");
3431 let empty = work.path().join("empty.gwbk");
3432 std::fs::write(&empty, b"").expect("write empty");
3433 let err = rig_restore(&rig, "http://localhost:9088", &empty, 300)
3434 .await
3435 .expect_err("empty file refuses");
3436 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3437 assert!(err.to_string().contains("empty"), "{}", err);
3438
3439 let unreadable = work.path(); let err = rig_restore(&rig, "http://localhost:9088", unreadable, 300)
3445 .await
3446 .expect_err("directory is not a restorable file");
3447 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3448 assert!(
3449 rig.calls().is_empty(),
3450 "pre-check refusals never touch the gateway: {:?}",
3451 rig.calls()
3452 );
3453 }
3454
3455 #[tokio::test]
3462 async fn restore_wait_failure_is_a_rig_error() {
3463 let work = tempfile::tempdir().expect("tempdir");
3464 let gwbk = work.path().join("snapshot.gwbk");
3465 std::fs::write(&gwbk, b"PK\x03\x04fixture").expect("write gwbk");
3466 let rig = SnapshotRig {
3467 ping_fail: true,
3468 ..SnapshotRig::default()
3469 };
3470 let err = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
3471 .await
3472 .expect_err("a failed wait errors the restore");
3473 assert!(matches!(err, CoreError::Rig(_)), "{err}");
3474 assert_eq!(err.exit_code(), 7);
3475 let message = err.to_string();
3476 assert!(message.contains("did not reach RUNNING"), "{message}");
3477 assert!(
3478 rig.calls().contains(&"backup_restore".to_string()),
3479 "the POST fired before the wait: {:?}",
3480 rig.calls()
3481 );
3482 }
3483
3484 #[test]
3489 fn restore_wait_floor_is_300s_and_clamps() {
3490 assert_eq!(super::RESTORE_WAIT_FLOOR_S, 300);
3491 assert_eq!(super::restore_deadline(1), 300, "short budgets floor up");
3492 assert_eq!(super::restore_deadline(300), 300);
3493 assert_eq!(
3494 super::restore_deadline(600),
3495 600,
3496 "longer budgets pass through"
3497 );
3498 }
3499
3500 #[test]
3504 fn stamp_renders_utc_compact() {
3505 assert_eq!(super::stamp_from_secs(0), "19700101-000000");
3506 assert_eq!(super::stamp_from_secs(1_787_346_747), "20260821-211227");
3507 assert_eq!(super::civil_from_days(0), (1970, 1, 1));
3508 assert_eq!(super::civil_from_days(19_723), (2024, 1, 1));
3509 }
3510
3511 const PS_STDOUT: &str = concat!(
3517 r#"{"Name":"fixture-rig-ignition-1","Service":"ignition","State":"running","Health":"healthy","ExitCode":0,"Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}]}"#,
3518 "\n",
3519 r#"{"Name":"fixture-rig-db-1","Service":"db","State":"exited","ExitCode":137,"Publishers":[]}"#,
3520 "\n",
3521 );
3522
3523 const VOLUME_STDOUT: &str = concat!(
3524 r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
3525 "\n",
3526 );
3527
3528 #[tokio::test]
3529 async fn status_serializes_the_allowlist_exactly() {
3530 let occupant = r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
3534 let runner = FakeRunner::with(vec![
3535 version_ok(),
3536 ok(PS_STDOUT),
3537 ok(VOLUME_STDOUT),
3538 ok(occupant),
3539 ok(occupant),
3540 ]);
3541
3542 let result = rig_status(&runner, &gw_plan())
3543 .await
3544 .expect("status succeeds");
3545 let json = serde_json::to_value(&result).unwrap();
3546 assert_eq!(
3547 json,
3548 serde_json::json!({
3549 "rig": "fixture-rig",
3550 "project": "fixture-rig",
3551 "compose_file": "/rigs/docker/compose.yml",
3552 "services": [
3553 {
3554 "name": "ignition",
3555 "state": "running",
3556 "health": "healthy",
3557 "exit_code": 0,
3558 "publishers": [
3559 {"published_port": 9088, "target_port": 8088, "protocol": "tcp"},
3560 {"published_port": 9443, "target_port": 443, "protocol": "tcp"}
3561 ]
3562 },
3563 {
3564 "name": "db",
3565 "state": "exited",
3566 "health": null,
3567 "exit_code": 137,
3568 "publishers": []
3569 }
3570 ],
3571 "volumes": ["fixture-rig_gw-data"],
3572 "ports_free": false
3573 }),
3574 "EXACT shape comparison: no compose-config passthrough, no \
3575 unknown keys — the allowlist IS the contract"
3576 );
3577 }
3578
3579 #[tokio::test]
3582 async fn status_down_rig_is_data() {
3583 let runner = FakeRunner::with(vec![version_ok(), ok(""), ok(""), ok(""), ok("")]);
3584 let result = rig_status(&runner, &gw_plan())
3585 .await
3586 .expect("status of a down rig exits 0");
3587 assert!(result.services.is_empty());
3588 assert!(result.volumes.is_empty());
3589 assert!(result.ports_free);
3590 }
3591
3592 #[test]
3595 fn up_and_down_results_carry_all_keys() {
3596 let up = RigUpResult {
3597 rig: "r".into(),
3598 project: "r".into(),
3599 state: "uncommissioned".into(),
3600 gateway_url: None,
3601 warnings: vec![],
3602 provisioned_modules: vec![],
3603 orphaned_modules: vec![],
3604 };
3605 let json = serde_json::to_value(&up).unwrap();
3606 for key in [
3607 "rig",
3608 "project",
3609 "state",
3610 "gateway_url",
3611 "warnings",
3612 "provisioned_modules",
3613 "orphaned_modules",
3614 ] {
3615 assert!(json.get(key).is_some(), "missing key {key}");
3616 }
3617 let down = RigDownResult {
3618 rig: "r".into(),
3619 project: "r".into(),
3620 state: "down".into(),
3621 };
3622 let json = serde_json::to_value(&down).unwrap();
3623 for key in ["rig", "project", "state"] {
3624 assert!(json.get(key).is_some(), "missing key {key}");
3625 }
3626 let reset = RigResetResult {
3627 rig: "r".into(),
3628 project: "r".into(),
3629 removed_volumes: vec![],
3630 state: "running".into(),
3631 warnings: vec![],
3632 provisioned_modules: vec![],
3633 orphaned_modules: vec![],
3634 };
3635 let json = serde_json::to_value(&reset).unwrap();
3636 for key in [
3637 "rig",
3638 "project",
3639 "removed_volumes",
3640 "state",
3641 "warnings",
3642 "provisioned_modules",
3643 "orphaned_modules",
3644 ] {
3645 assert!(json.get(key).is_some(), "missing key {key}");
3646 }
3647 let status_keys = [
3648 "rig",
3649 "project",
3650 "compose_file",
3651 "services",
3652 "volumes",
3653 "ports_free",
3654 ];
3655 let _ = RigStatusResult {
3656 rig: "r".into(),
3657 project: "r".into(),
3658 compose_file: "/c.yml".into(),
3659 services: vec![],
3660 volumes: vec![],
3661 ports_free: true,
3662 };
3663 assert_eq!(status_keys.len(), 6);
3666 }
3667}