1use std::path::{Path, PathBuf};
31use std::sync::Mutex;
32use std::time::Duration;
33
34use serde::Serialize;
35
36use crate::client::GatewayApi;
37use crate::error::CoreError;
38use crate::poll::{self, PollConfig, PollState};
39use crate::rig::compose::{
40 ComposeRunner, check_output, compose_version, docker_ps_publish_args, down_args, logs_args,
41 parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, reset_preview,
42 up_args, volume_ls_args,
43};
44use crate::rig::{RigPlan, port_preflight};
45
46pub const DEFAULT_WAIT_TIMEOUT_S: u64 = 300;
50
51const RUNNING: &str = "RUNNING";
53
54const GATEWAY_HTTP_TARGET: u16 = 8088;
56const GATEWAY_HTTPS_TARGET: u16 = 443;
57
58#[derive(Debug, Serialize)]
60pub struct RigUpResult {
61 pub rig: String,
63 pub project: String,
66 pub state: String,
68 pub gateway_url: Option<String>,
71 pub warnings: Vec<String>,
74}
75
76#[derive(Debug, Serialize)]
78pub struct RigDownResult {
79 pub rig: String,
81 pub project: String,
83 pub state: String,
85}
86
87#[derive(Debug, Serialize)]
89pub struct RigResetResult {
90 pub rig: String,
92 pub project: String,
94 pub removed_volumes: Vec<String>,
97 pub state: String,
100 pub warnings: Vec<String>,
102}
103
104#[derive(Debug, Serialize)]
106pub struct StatusPublisher {
107 pub published_port: Option<u16>,
109 pub target_port: Option<u16>,
111 pub protocol: Option<String>,
113}
114
115#[derive(Debug, Serialize)]
117pub struct StatusService {
118 pub name: String,
120 pub state: String,
122 pub health: Option<String>,
124 pub exit_code: Option<i64>,
126 pub publishers: Vec<StatusPublisher>,
128}
129
130#[derive(Debug, Serialize)]
134pub struct RigStatusResult {
135 pub rig: String,
137 pub project: String,
139 pub compose_file: String,
141 pub services: Vec<StatusService>,
144 pub volumes: Vec<String>,
146 pub ports_free: bool,
149}
150
151pub fn gateway_url_from(plan: &RigPlan) -> Option<String> {
157 if let Some(mapping) = plan
158 .port_mappings
159 .iter()
160 .find(|mapping| mapping.target == GATEWAY_HTTP_TARGET)
161 {
162 return Some(format!("http://localhost:{}", mapping.published));
163 }
164 if let Some(mapping) = plan
165 .port_mappings
166 .iter()
167 .find(|mapping| mapping.target == GATEWAY_HTTPS_TARGET)
168 {
169 return Some(format!("https://localhost:{}", mapping.published));
170 }
171 None
172}
173
174pub async fn rig_up(
182 runner: &dyn ComposeRunner,
183 plan: &RigPlan,
184 wait_timeout_s: u64,
185 gateway: Option<&dyn GatewayApi>,
186) -> Result<RigUpResult, CoreError> {
187 compose_version(runner).await?;
189
190 if let Some(conflict) = port_preflight(runner, plan).await?.first() {
194 return Err(CoreError::Rig(format!(
195 "port {} in use by {} — stop it or change the rig's published port",
196 conflict.port, conflict.attribution
197 )));
198 }
199
200 let output = runner.run(&up_args(plan, wait_timeout_s)).await;
202 check_output(&output, "docker compose up")?;
203
204 let gateway_url = gateway_url_from(plan);
205 let mut warnings = Vec::new();
206 let state = match (gateway, &gateway_url) {
207 (Some(api), Some(url)) => {
208 commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
209 }
210 _ => {
211 warnings.push(
212 "no gateway port mapping (target 8088/443) found — skipped the \
213 commissioned wait"
214 .to_string(),
215 );
216 "running".to_string()
217 }
218 };
219
220 Ok(RigUpResult {
221 rig: plan.name.clone(),
222 project: plan.name.clone(),
223 state,
224 gateway_url,
225 warnings,
226 })
227}
228
229async fn commissioned_wait(
235 api: &dyn GatewayApi,
236 url: &str,
237 wait_timeout_s: u64,
238 warnings: &mut Vec<String>,
239) -> Result<String, CoreError> {
240 let cfg = PollConfig {
241 subject: format!("rig gateway RUNNING (GET {url}/StatusPing)"),
242 interval: Duration::from_secs(2),
243 deadline: Duration::from_secs(wait_timeout_s),
244 ..PollConfig::default()
245 };
246 let mut uncommissioned = Mutex::new(false);
250 let url_owned = url.to_string();
251 let outcome = poll::poll(cfg, &mut uncommissioned, |uncommissioned| {
252 Box::pin(async {
253 match api.status_ping().await {
254 Ok(ping) if ping.state == RUNNING => Ok(PollState::<()>::Done(())),
255 Ok(ping) => Ok(PollState::Pending(Some(ping.state))),
256 Err(CoreError::GatewayNotCommissioned { .. }) => {
259 *uncommissioned.get_mut().expect("commissioned flag") = true;
260 Ok(PollState::Pending(Some(format!(
261 "gateway uncommissioned — open {url_owned}/welcome"
262 ))))
263 }
264 Err(other) => Err(other),
266 }
267 })
268 })
269 .await;
270 match outcome {
271 Ok(()) => Ok("running".to_string()),
272 Err(CoreError::Network { source: None, .. })
276 if *uncommissioned.lock().expect("commissioned flag") =>
277 {
278 warnings.push(format!(
279 "gateway uncommissioned — open {url}/welcome in a browser and complete \
280 the commissioning wizard (no headless commissioning exists)"
281 ));
282 Ok("uncommissioned".to_string())
283 }
284 Err(other) => Err(CoreError::Rig(format!(
285 "gateway did not reach RUNNING within {wait_timeout_s}s — {other}"
286 ))),
287 }
288}
289
290pub async fn rig_down(
293 runner: &dyn ComposeRunner,
294 plan: &RigPlan,
295) -> Result<RigDownResult, CoreError> {
296 compose_version(runner).await?;
297 let output = runner.run(&down_args(plan, false)).await;
298 check_output(&output, "docker compose down")?;
299 Ok(RigDownResult {
300 rig: plan.name.clone(),
301 project: plan.name.clone(),
302 state: "down".to_string(),
303 })
304}
305
306pub async fn rig_reset(
326 runner: &dyn ComposeRunner,
327 plan: &RigPlan,
328 wait_timeout_s: u64,
329 gateway: Option<&dyn GatewayApi>,
330) -> Result<RigResetResult, CoreError> {
331 let removed_volumes = reset_preview(runner, plan).await?;
333
334 compose_version(runner).await?;
336
337 let output = runner.run(&down_args(plan, true)).await;
339 check_output(&output, "docker compose down")?;
340
341 if let Some(conflict) = port_preflight(runner, plan).await?.first() {
343 return Err(CoreError::Rig(format!(
344 "port {} in use by {} — stop it or change the rig's published port \
345 (the rig is torn down; re-run `rig up` once the port frees)",
346 conflict.port, conflict.attribution
347 )));
348 }
349
350 let output = runner.run(&up_args(plan, wait_timeout_s)).await;
352 check_output(&output, "docker compose up")?;
353
354 let gateway_url = gateway_url_from(plan);
356 let mut warnings = Vec::new();
357 let state = match (gateway, &gateway_url) {
358 (Some(api), Some(url)) => {
359 commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
360 }
361 _ => {
362 warnings.push(
363 "no gateway port mapping (target 8088/443) found — skipped the \
364 commissioned wait"
365 .to_string(),
366 );
367 "running".to_string()
368 }
369 };
370
371 Ok(RigResetResult {
372 rig: plan.name.clone(),
373 project: plan.name.clone(),
374 removed_volumes,
375 state,
376 warnings,
377 })
378}
379
380#[derive(Debug, Serialize)]
385pub struct RigLogsResult {
386 pub streamed: usize,
388}
389
390#[derive(Debug, Serialize)]
394pub struct TrialBanners {
395 pub severity: Option<String>,
398 pub expire_time_ms: Option<i64>,
401 pub active: bool,
405}
406
407#[derive(Debug, Serialize)]
411pub struct TrialStatusResult {
412 pub license_mode: String,
414 pub trial_state: String,
417 pub trial_remaining_s: i64,
420 pub expired: bool,
422 pub emergency: bool,
424 pub emergency_remaining_s: i64,
426 pub development: bool,
428 pub banners: TrialBanners,
430 pub warnings: Vec<String>,
433}
434
435fn epoch_ms_now() -> i64 {
437 std::time::SystemTime::now()
438 .duration_since(std::time::UNIX_EPOCH)
439 .expect("system clock is after the unix epoch")
440 .as_millis() as i64
441}
442
443pub async fn trial_status(gateway: &dyn GatewayApi) -> Result<TrialStatusResult, CoreError> {
453 let wire = gateway.trial_status_wire().await?;
454 let mut warnings = Vec::new();
455 let banners = match gateway.banners().await {
456 Ok(set) => {
457 let trial_banner = set.banners.iter().find(|banner| banner.r#type == "trial");
458 match trial_banner {
459 Some(banner) => {
460 let active = banner.data.severity == "info"
461 && banner
462 .data
463 .expire_time_ms
464 .is_some_and(|ms| ms > epoch_ms_now());
465 TrialBanners {
466 severity: Some(banner.data.severity.clone()),
467 expire_time_ms: banner.data.expire_time_ms,
468 active,
469 }
470 }
471 None => TrialBanners {
472 severity: None,
473 expire_time_ms: None,
474 active: false,
475 },
476 }
477 }
478 Err(err) => {
479 warnings.push(format!(
480 "banners cross-check unavailable ({}); the trial endpoint's \
481 expired flag is the truth",
482 err
483 ));
484 TrialBanners {
485 severity: None,
486 expire_time_ms: None,
487 active: false,
488 }
489 }
490 };
491 Ok(TrialStatusResult {
492 license_mode: wire.license_mode,
493 trial_state: wire.trial_state,
494 trial_remaining_s: wire.trial_seconds_left,
495 expired: wire.expired,
496 emergency: wire.emergency,
497 emergency_remaining_s: wire.emergency_seconds_left,
498 development: wire.development,
499 banners,
500 warnings,
501 })
502}
503
504#[derive(Debug, Serialize)]
508pub struct TrialResetResult {
509 pub rig_url: String,
511 pub mechanism: String,
515 pub expired_before: bool,
518 pub expired_after: bool,
521 pub trial_remaining_s: i64,
524}
525
526pub async fn trial_reset(
547 gateway: &dyn GatewayApi,
548 rig_url: &str,
549 token_available: bool,
550 basic: Option<(&str, &crate::config::Secret)>,
551) -> Result<TrialResetResult, CoreError> {
552 let before = gateway.trial_status_wire().await?;
555 if !before.expired {
556 return Err(CoreError::TrialNotExpired {
557 remaining_s: before.trial_seconds_left,
558 endpoint: Some(format!("{rig_url}/data/api/v1/trial")),
559 });
560 }
561
562 if token_available {
567 match gateway.trial_reset_wire().await {
568 Ok(_fresh) => {
569 let after = gateway.trial_status_wire().await?;
570 return finish(rig_url, "token", after);
571 }
572 Err(err) => {
573 tracing::warn!(
574 error = %err,
575 "trial-reset tier 0 (token-auth POST) failed — falling through to the login rung"
576 );
577 }
578 }
579 }
580
581 let Some((username, password)) = basic else {
584 return Err(CoreError::SecretUnavailable {
589 profile: rig_url.to_string(),
590 });
591 };
592 let flow = crate::client::idp::IdpLoginFlow::new(rig_url)?;
593 let (flow, session) = crate::client::idp::login(flow, username, password).await?;
594 crate::client::idp::trial_reset_via_session(&flow, &session).await?;
595 let after = gateway.trial_status_wire().await?;
597 finish(rig_url, "login", after)
598}
599
600fn finish(
602 rig_url: &str,
603 mechanism: &str,
604 after: crate::client::trial::TrialWire,
605) -> Result<TrialResetResult, CoreError> {
606 if after.expired {
607 return Err(CoreError::Internal(format!(
608 "trial reset was accepted but the read-back still reports expired \
609 ({}s left) — re-run `rig trial status` to see the gateway's answer",
610 after.trial_seconds_left
611 )));
612 }
613 Ok(TrialResetResult {
614 rig_url: rig_url.to_string(),
615 mechanism: mechanism.to_string(),
616 expired_before: true,
617 expired_after: after.expired,
618 trial_remaining_s: after.trial_seconds_left,
619 })
620}
621
622pub async fn rig_logs(
634 runner: &dyn ComposeRunner,
635 plan: &RigPlan,
636 tail: u32,
637 follow: bool,
638 service: Option<&str>,
639 sink: &mut (dyn FnMut(String) + Send),
640) -> Result<RigLogsResult, CoreError> {
641 let args = logs_args(plan, tail, follow, service);
642 let mut streamed = 0usize;
643 let output = if follow {
644 let mut forwarder = |line: &str| {
647 streamed += 1;
648 sink(line.to_string());
649 };
650 runner.run_streaming(&args, &mut forwarder).await
651 } else {
652 runner.run(&args).await
653 };
654 if !output.stderr.trim().is_empty() {
655 tracing::warn!(
656 source = "docker compose logs",
657 stderr = %output.stderr.trim(),
658 "compose diagnostics (stderr passthrough — never the data sink)"
659 );
660 }
661 let stdout = check_output(&output, "docker compose logs")?;
663 for line in stdout.lines() {
664 sink(line.to_string());
665 streamed += 1;
666 }
667 Ok(RigLogsResult { streamed })
668}
669
670pub const RESTORE_WAIT_FLOOR_S: u64 = 300;
680
681pub const RESTORE_TOKEN_WARNING: &str = "API tokens may have been reset by restore \
686— re-provision via gateway UI, then ign doctor";
687
688const MANIFEST_NOTES: [&str; 2] = [
693 "trial clock state is NOT captured by gwbk (unknown behavior — reset \
694 separately via rig trial reset)",
695 "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
696 config via gateway data",
697];
698
699#[derive(Debug, Serialize)]
701pub struct SnapshotResult {
702 pub dir: String,
705 pub gwbk_bytes: u64,
708 pub projects: Vec<String>,
710 pub manifest_path: String,
712}
713
714#[derive(Debug, Serialize)]
716pub struct RestoreResult {
717 pub restored_from: String,
719 pub state: String,
723 pub warnings: Vec<String>,
726}
727
728fn civil_from_days(days: i64) -> (i64, u32, u32) {
733 let z = days + 719_468;
734 let era = z.div_euclid(146_097);
735 let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
738 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)
743}
744
745fn stamp_from_secs(secs: i64) -> String {
747 let days = secs.div_euclid(86_400);
748 let time_of_day = secs.rem_euclid(86_400);
749 let (year, month, day) = civil_from_days(days);
750 let hour = time_of_day / 3600;
751 let minute = (time_of_day % 3600) / 60;
752 let second = time_of_day % 60;
753 format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}{second:02}")
754}
755
756pub async fn rig_snapshot(
781 gateway: &dyn GatewayApi,
782 rig_name: &str,
783 out_dir: Option<&Path>,
784) -> Result<SnapshotResult, CoreError> {
785 let epoch_s = std::time::SystemTime::now()
787 .duration_since(std::time::UNIX_EPOCH)
788 .expect("system clock is after the unix epoch")
789 .as_secs() as i64;
790 let dir: PathBuf = match out_dir {
791 Some(dir) => dir.to_path_buf(),
792 None => PathBuf::from("ign-rig-snapshots").join(format!(
793 "{}-{}",
794 rig_name,
795 stamp_from_secs(epoch_s)
796 )),
797 };
798 tokio::fs::create_dir_all(&dir)
799 .await
800 .map_err(|err| CoreError::Internal(format!("cannot create {}: {err}", dir.display())))?;
801
802 let gwbk_name = format!("{rig_name}.gwbk");
806 let meta = gateway
807 .backup_download(
808 &dir.join(&gwbk_name),
809 crate::client::backup::BackupType::Roaming,
810 )
811 .await?;
812
813 let page = gateway
815 .projects(&crate::client::query::ListQuery::default())
816 .await?;
817 let projects_dir = dir.join("projects");
818 let mut exported: Vec<(String, String)> = Vec::new();
819 for record in &page.items {
820 if exported.is_empty() {
821 tokio::fs::create_dir_all(&projects_dir)
822 .await
823 .map_err(|err| {
824 CoreError::Internal(format!("cannot create {}: {err}", projects_dir.display()))
825 })?;
826 }
827 let file = format!(
828 "projects/{}.zip",
829 crate::client::projects::encode_segment(&record.name)
830 );
831 gateway
832 .project_export_to_file(&record.name, &dir.join(&file))
833 .await?;
834 exported.push((record.name.clone(), file));
835 }
836
837 let version = gateway
839 .gateway_info()
840 .await
841 .ok()
842 .map(|info| info.ignition_version);
843 let manifest = serde_json::json!({
844 "rig": rig_name,
845 "taken_at": epoch_s,
846 "ignition": { "version": version },
847 "gwbk": gwbk_name,
848 "projects": exported
849 .iter()
850 .map(|(name, file)| serde_json::json!({ "name": name, "file": file }))
851 .collect::<Vec<_>>(),
852 "notes": MANIFEST_NOTES,
853 });
854 let manifest_path = dir.join("manifest.json");
855 tokio::fs::write(
856 &manifest_path,
857 serde_json::to_vec_pretty(&manifest)
858 .map_err(|err| CoreError::Internal(format!("manifest serialization failed: {err}")))?,
859 )
860 .await
861 .map_err(|err| {
862 CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
863 })?;
864
865 Ok(SnapshotResult {
866 dir: dir.display().to_string(),
867 gwbk_bytes: meta.bytes,
868 projects: exported.into_iter().map(|(name, _)| name).collect(),
869 manifest_path: manifest_path.display().to_string(),
870 })
871}
872
873fn restore_deadline(wait_timeout_s: u64) -> u64 {
878 wait_timeout_s.max(RESTORE_WAIT_FLOOR_S)
879}
880
881pub async fn rig_restore(
900 gateway: &dyn GatewayApi,
901 rig_url: &str,
902 gwbk: &Path,
903 wait_timeout_s: u64,
904) -> Result<RestoreResult, CoreError> {
905 let meta = std::fs::metadata(gwbk).map_err(|_| CoreError::InvalidInput {
911 reason: format!("gwbk file {} not found", gwbk.display()),
912 })?;
913 if !meta.is_file() {
914 return Err(CoreError::InvalidInput {
915 reason: format!("gwbk file {} is not a regular file", gwbk.display()),
916 });
917 }
918 if meta.len() == 0 {
919 return Err(CoreError::InvalidInput {
920 reason: format!("gwbk file {} is empty", gwbk.display()),
921 });
922 }
923
924 gateway.backup_restore(gwbk).await?;
927
928 let deadline_s = restore_deadline(wait_timeout_s);
930 let mut warnings = Vec::new();
931 let state = commissioned_wait(gateway, rig_url, deadline_s, &mut warnings).await?;
932
933 warnings.insert(0, RESTORE_TOKEN_WARNING.to_string());
935
936 Ok(RestoreResult {
937 restored_from: gwbk.display().to_string(),
938 state,
939 warnings,
940 })
941}
942
943pub async fn rig_status(
948 runner: &dyn ComposeRunner,
949 plan: &RigPlan,
950) -> Result<RigStatusResult, CoreError> {
951 compose_version(runner).await?;
952
953 let ps = runner.run(&ps_args(plan)).await;
954 let rows = parse_ps_ldjson(check_output(&ps, "docker compose ps")?);
955
956 let volume_ls = runner.run_docker(&volume_ls_args(&plan.name)).await;
957 let volumes = parse_volume_ls_ldjson(check_output(&volume_ls, "docker volume ls")?)
958 .into_iter()
959 .map(|entry| entry.name)
960 .collect();
961
962 let mut ports_free = true;
965 for port in &plan.host_ports {
966 let output = runner.run_docker(&docker_ps_publish_args(*port)).await;
967 let occupants = parse_docker_ps_ldjson(check_output(&output, "docker ps")?);
968 if !occupants.is_empty() {
969 ports_free = false;
970 }
971 }
972
973 let services = rows
974 .into_iter()
975 .map(|row| StatusService {
976 name: if row.service.is_empty() {
977 row.name
978 } else {
979 row.service
980 },
981 state: row.state,
982 health: row.health,
983 exit_code: row.exit_code,
984 publishers: row
985 .publishers
986 .into_iter()
987 .map(|publisher| StatusPublisher {
988 published_port: publisher.published_port,
989 target_port: publisher.target_port,
990 protocol: publisher.protocol,
991 })
992 .collect(),
993 })
994 .collect();
995
996 Ok(RigStatusResult {
997 rig: plan.name.clone(),
998 project: plan.name.clone(),
999 compose_file: plan.compose_file.display().to_string(),
1000 services,
1001 volumes,
1002 ports_free,
1003 })
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use std::collections::VecDeque;
1009 use std::path::{Path, PathBuf};
1010 use std::sync::Mutex;
1011
1012 use super::{
1013 DEFAULT_WAIT_TIMEOUT_S, RigDownResult, RigResetResult, RigStatusResult, RigUpResult,
1014 gateway_url_from, rig_down, rig_logs, rig_reset, rig_restore, rig_snapshot, rig_status,
1015 rig_up, trial_reset, trial_status,
1016 };
1017 use crate::client::GatewayApi;
1018 use crate::error::CoreError;
1019 use crate::rig::RigPlan;
1020 use crate::rig::compose::{
1021 ComposeOutput, ComposeRunner, PortMapping, down_args, logs_args, up_args, volume_ls_args,
1022 };
1023
1024 #[derive(Default)]
1031 struct FakeRunner {
1032 calls: Mutex<Vec<(&'static str, Vec<String>)>>,
1033 outputs: Mutex<VecDeque<ComposeOutput>>,
1034 }
1035
1036 impl FakeRunner {
1037 fn with(outputs: Vec<ComposeOutput>) -> Self {
1038 Self {
1039 outputs: Mutex::new(outputs.into()),
1040 ..Self::default()
1041 }
1042 }
1043
1044 fn calls(&self) -> Vec<(&'static str, Vec<String>)> {
1045 self.calls.lock().unwrap().clone()
1046 }
1047 }
1048
1049 #[async_trait::async_trait]
1050 impl ComposeRunner for FakeRunner {
1051 async fn run(&self, args: &[String]) -> ComposeOutput {
1052 self.calls
1053 .lock()
1054 .unwrap()
1055 .push(("docker compose", args.to_vec()));
1056 self.outputs
1057 .lock()
1058 .unwrap()
1059 .pop_front()
1060 .expect("outputs exhausted")
1061 }
1062
1063 async fn run_docker(&self, args: &[String]) -> ComposeOutput {
1064 self.calls.lock().unwrap().push(("docker", args.to_vec()));
1065 self.outputs
1066 .lock()
1067 .unwrap()
1068 .pop_front()
1069 .expect("outputs exhausted")
1070 }
1071
1072 async fn run_streaming(
1073 &self,
1074 args: &[String],
1075 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
1076 ) -> ComposeOutput {
1077 self.calls
1078 .lock()
1079 .unwrap()
1080 .push(("docker compose", args.to_vec()));
1081 let output = self
1082 .outputs
1083 .lock()
1084 .unwrap()
1085 .pop_front()
1086 .expect("outputs exhausted");
1087 for line in output.stdout.lines() {
1091 line_sink(line);
1092 }
1093 ComposeOutput {
1094 stdout: String::new(),
1095 stderr: output.stderr,
1096 code: output.code,
1097 }
1098 }
1099 }
1100
1101 fn ok(stdout: &str) -> ComposeOutput {
1102 ComposeOutput {
1103 stdout: stdout.to_string(),
1104 stderr: String::new(),
1105 code: 0,
1106 }
1107 }
1108
1109 fn version_ok() -> ComposeOutput {
1110 ok("Docker Compose version v5.1.2\n")
1111 }
1112
1113 const OWN_OCCUPANT: &str =
1120 r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
1121
1122 fn free_ports_for_own_project() -> Vec<ComposeOutput> {
1126 vec![ok(OWN_OCCUPANT), ok(OWN_OCCUPANT)]
1127 }
1128
1129 fn up_cycle_outputs() -> Vec<ComposeOutput> {
1132 let mut outputs = vec![version_ok()];
1133 outputs.extend(free_ports_for_own_project());
1134 outputs.push(ok(""));
1135 outputs
1136 }
1137
1138 fn gw_plan() -> RigPlan {
1141 RigPlan {
1142 name: "fixture-rig".into(),
1143 compose_file: "/rigs/docker/compose.yml".into(),
1144 project_dir: "/rigs/docker".into(),
1145 services: vec!["ignition".into()],
1146 host_ports: vec![9088, 9443],
1147 port_mappings: vec![
1148 PortMapping {
1149 target: 8088,
1150 published: 9088,
1151 },
1152 PortMapping {
1153 target: 443,
1154 published: 9443,
1155 },
1156 ],
1157 volumes: vec!["gw-data".into()],
1158 }
1159 }
1160
1161 async fn status_ping_server(state: &str) -> wiremock::MockServer {
1163 let server = wiremock::MockServer::start().await;
1164 wiremock::Mock::given(wiremock::matchers::method("GET"))
1165 .and(wiremock::matchers::path("/StatusPing"))
1166 .respond_with(
1167 wiremock::ResponseTemplate::new(200)
1168 .set_body_json(serde_json::json!({ "state": state })),
1169 )
1170 .expect(1..)
1171 .mount(&server)
1172 .await;
1173 server
1174 }
1175
1176 async fn uncommissioned_server() -> wiremock::MockServer {
1180 let server = wiremock::MockServer::start().await;
1181 wiremock::Mock::given(wiremock::matchers::method("GET"))
1182 .and(wiremock::matchers::path("/StatusPing"))
1183 .respond_with(
1184 wiremock::ResponseTemplate::new(302).insert_header("Location", "/welcome"),
1185 )
1186 .expect(1..)
1187 .mount(&server)
1188 .await;
1189 server
1190 }
1191
1192 #[test]
1197 fn gateway_url_prefers_http_8088_then_https_443() {
1198 assert_eq!(
1199 gateway_url_from(&gw_plan()),
1200 Some("http://localhost:9088".to_string()),
1201 "the 8088 mapping wins even though 443 is also present"
1202 );
1203 let https_only = RigPlan {
1204 port_mappings: vec![PortMapping {
1205 target: 443,
1206 published: 9443,
1207 }],
1208 host_ports: vec![9443],
1209 ..gw_plan()
1210 };
1211 assert_eq!(
1212 gateway_url_from(&https_only),
1213 Some("https://localhost:9443".to_string())
1214 );
1215 let no_gateway = RigPlan {
1216 port_mappings: vec![PortMapping {
1217 target: 22,
1218 published: 9022,
1219 }],
1220 host_ports: vec![9022],
1221 ..gw_plan()
1222 };
1223 assert_eq!(gateway_url_from(&no_gateway), None);
1224 }
1225
1226 #[test]
1227 fn default_wait_timeout_is_300() {
1228 assert_eq!(DEFAULT_WAIT_TIMEOUT_S, 300, "research Pitfall 3 headroom");
1229 }
1230
1231 #[tokio::test]
1238 async fn up_success_probes_to_running() {
1239 let server = status_ping_server("RUNNING").await;
1240 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1241
1242 let runner = FakeRunner::with(up_cycle_outputs());
1243 let result = rig_up(&runner, &gw_plan(), 300, Some(&api))
1244 .await
1245 .expect("up succeeds");
1246 assert_eq!(result.rig, "fixture-rig");
1247 assert_eq!(result.state, "running");
1248 assert!(result.warnings.is_empty());
1249 assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1250
1251 let calls = runner.calls();
1252 assert_eq!(calls[0], ("docker compose", vec!["version".to_string()]));
1253 assert_eq!(calls[1].0, "docker");
1255 assert_eq!(calls[2].0, "docker");
1256 assert_eq!(
1257 calls[3],
1258 ("docker compose", up_args(&gw_plan(), 300)),
1259 "up rides the LOCKED arg shape"
1260 );
1261 }
1262
1263 #[tokio::test]
1267 async fn up_uncommissioned_is_data_not_failure() {
1268 let server = uncommissioned_server().await;
1269 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1270
1271 let runner = FakeRunner::with(up_cycle_outputs());
1272 let result = rig_up(&runner, &gw_plan(), 1, Some(&api))
1273 .await
1274 .expect("uncommissioned is exit-0 data");
1275 assert_eq!(result.state, "uncommissioned");
1276 assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1277 assert!(
1278 result
1279 .warnings
1280 .iter()
1281 .any(|warning| warning.contains("http://localhost:9088/welcome")),
1282 "wizard URL in warnings: {:?}",
1283 result.warnings
1284 );
1285 }
1286
1287 #[tokio::test]
1289 async fn up_still_starting_at_deadline_is_rig_error() {
1290 let server = status_ping_server("STARTING").await;
1291 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1292
1293 let runner = FakeRunner::with(up_cycle_outputs());
1294 let err = rig_up(&runner, &gw_plan(), 1, Some(&api))
1295 .await
1296 .expect_err("still-STARTING deadline errors");
1297 assert!(matches!(err, CoreError::Rig(_)));
1298 assert_eq!(err.exit_code(), 7);
1299 let message = err.to_string();
1300 assert!(message.contains("did not reach RUNNING"), "{message}");
1301 assert!(
1302 message.contains("STARTING"),
1303 "last observation named: {message}"
1304 );
1305 }
1306
1307 #[tokio::test]
1309 async fn up_port_conflict_aborts_with_attribution() {
1310 let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1311 let runner = FakeRunner::with(vec![version_ok(), ok(occupant), ok(occupant)]);
1312
1313 let err = rig_up(&runner, &gw_plan(), 300, None)
1314 .await
1315 .expect_err("cross-project occupant aborts");
1316 let message = err.to_string();
1317 assert!(
1318 message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1319 "{message}"
1320 );
1321 let calls = runner.calls();
1323 assert_eq!(
1324 calls.len(),
1325 3,
1326 "version + two port checks only, no up: {calls:?}"
1327 );
1328 }
1329
1330 #[tokio::test]
1333 async fn up_without_probe_skips_wait_with_warning() {
1334 let runner = FakeRunner::with(up_cycle_outputs());
1335 let result = rig_up(&runner, &gw_plan(), 300, None)
1336 .await
1337 .expect("up succeeds without a probe");
1338 assert_eq!(result.state, "running");
1339 assert!(
1340 result
1341 .warnings
1342 .iter()
1343 .any(|warning| warning.contains("skipped the commissioned wait")),
1344 "{:?}",
1345 result.warnings
1346 );
1347 }
1348
1349 #[tokio::test]
1351 async fn up_missing_compose_fails_fast() {
1352 let missing = ComposeOutput {
1353 stdout: String::new(),
1354 stderr: "docker: command not found".into(),
1355 code: 127,
1356 };
1357 let runner = FakeRunner::with(vec![missing]);
1358 let err = rig_up(&runner, &gw_plan(), 300, None)
1359 .await
1360 .expect_err("no docker errors");
1361 let message = err.to_string();
1362 assert!(
1363 message.contains("docker compose is unavailable"),
1364 "{message}"
1365 );
1366 assert!(message.contains("not supported"), "{message}");
1367 }
1368
1369 #[tokio::test]
1374 async fn down_runs_exact_args_and_reports_down() {
1375 let runner = FakeRunner::with(vec![version_ok(), ok("")]);
1376 let result = rig_down(&runner, &gw_plan()).await.expect("down succeeds");
1377 assert_eq!(
1378 serde_json::to_value(&result).unwrap(),
1379 serde_json::json!({
1380 "rig": "fixture-rig",
1381 "project": "fixture-rig",
1382 "state": "down",
1383 }),
1384 "RigDownResult shape (all keys always)"
1385 );
1386 let calls = runner.calls();
1387 assert_eq!(calls[1], ("docker compose", down_args(&gw_plan(), false)));
1388 }
1389
1390 #[tokio::test]
1391 async fn down_failure_carries_stderr_tail() {
1392 let runner = FakeRunner::with(vec![
1393 version_ok(),
1394 ComposeOutput {
1395 stdout: String::new(),
1396 stderr: "error while removing network: active endpoints".into(),
1397 code: 1,
1398 },
1399 ]);
1400 let err = rig_down(&runner, &gw_plan())
1401 .await
1402 .expect_err("down failure errors");
1403 let message = err.to_string();
1404 assert!(
1405 message.contains("docker compose down failed (exit 1)"),
1406 "{message}"
1407 );
1408 assert!(message.contains("active endpoints"), "{message}");
1409 }
1410
1411 const RESET_VOLUME_STDOUT: &str = concat!(
1419 r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
1420 "\n",
1421 r#"{"Name":"other-rig_gw-data","Labels":{"com.docker.compose.project":"other-rig"}}"#,
1422 "\n",
1423 );
1424
1425 fn reset_cycle_outputs() -> Vec<ComposeOutput> {
1429 let mut outputs = vec![ok(RESET_VOLUME_STDOUT), version_ok(), ok("")];
1430 outputs.extend(free_ports_for_own_project());
1431 outputs.push(ok(""));
1432 outputs
1433 }
1434
1435 #[tokio::test]
1440 async fn reset_previews_tears_down_with_v_then_brings_up() {
1441 let server = status_ping_server("RUNNING").await;
1442 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1443
1444 let runner = FakeRunner::with(reset_cycle_outputs());
1445 let result = rig_reset(&runner, &gw_plan(), 300, Some(&api))
1446 .await
1447 .expect("reset succeeds");
1448 assert_eq!(result.rig, "fixture-rig");
1449 assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1450 assert_eq!(result.state, "running");
1451 assert!(result.warnings.is_empty());
1452
1453 let calls = runner.calls();
1455 assert_eq!(calls.len(), 6, "exactly the six scripted calls: {calls:?}");
1456 assert_eq!(
1457 calls[0],
1458 ("docker", volume_ls_args("fixture-rig")),
1459 "preview rides the plain-docker volume ls shape"
1460 );
1461 assert_eq!(calls[1], ("docker compose", vec!["version".to_string()]));
1462 assert_eq!(
1465 calls[2],
1466 (
1467 "docker compose",
1468 vec![
1469 "-p".to_string(),
1470 "fixture-rig".to_string(),
1471 "-f".to_string(),
1472 "/rigs/docker/compose.yml".to_string(),
1473 "down".to_string(),
1474 "--remove-orphans".to_string(),
1475 "-v".to_string(),
1476 ],
1477 ),
1478 "down -v --remove-orphans via the runner seam"
1479 );
1480 assert_eq!(calls[3].0, "docker");
1482 assert_eq!(calls[4].0, "docker");
1483 assert_eq!(calls[5], ("docker compose", up_args(&gw_plan(), 300)));
1484 }
1485
1486 #[tokio::test]
1489 async fn reset_uncommissioned_fresh_volume_is_data() {
1490 let server = uncommissioned_server().await;
1491 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1492
1493 let runner = FakeRunner::with(reset_cycle_outputs());
1494 let result = rig_reset(&runner, &gw_plan(), 1, Some(&api))
1495 .await
1496 .expect("uncommissioned reset is exit-0 data");
1497 assert_eq!(result.state, "uncommissioned");
1498 assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1499 assert!(
1500 result
1501 .warnings
1502 .iter()
1503 .any(|warning| warning.contains("http://localhost:9088/welcome")),
1504 "wizard URL in warnings: {:?}",
1505 result.warnings
1506 );
1507 }
1508
1509 #[tokio::test]
1512 async fn reset_port_regrabbed_midcycle_errors_and_never_ups() {
1513 let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1514 let runner = FakeRunner::with(vec![
1515 ok(""), version_ok(),
1517 ok(""), ok(occupant), ok(OWN_OCCUPANT), ]);
1521 let err = rig_reset(&runner, &gw_plan(), 300, None)
1522 .await
1523 .expect_err("mid-cycle port grab aborts before the up half");
1524 assert!(matches!(err, CoreError::Rig(_)));
1525 assert_eq!(err.exit_code(), 7);
1526 let message = err.to_string();
1527 assert!(
1528 message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1529 "{message}"
1530 );
1531 assert!(
1532 message.contains("torn down"),
1533 "the hint names the torn-down state: {message}"
1534 );
1535 let calls = runner.calls();
1537 assert_eq!(calls.len(), 5, "no up call: {calls:?}");
1538 assert_eq!(calls.last().expect("calls exist").0, "docker");
1539 }
1540
1541 #[tokio::test]
1543 async fn reset_down_failure_carries_stderr_tail() {
1544 let runner = FakeRunner::with(vec![
1545 ok(""),
1546 version_ok(),
1547 ComposeOutput {
1548 stdout: String::new(),
1549 stderr: "cannot remove volume: in use".into(),
1550 code: 1,
1551 },
1552 ]);
1553 let err = rig_reset(&runner, &gw_plan(), 300, None)
1554 .await
1555 .expect_err("down -v failure errors");
1556 let message = err.to_string();
1557 assert!(
1558 message.contains("docker compose down failed (exit 1)"),
1559 "{message}"
1560 );
1561 assert!(message.contains("in use"), "{message}");
1562 }
1563
1564 const LOGS_STDOUT: &str = concat!(
1571 "ignition-1 | 22:01:01.001 INFO Gateway - starting\n",
1572 "ignition-1 | 22:01:02.002 INFO Gateway - RUNNING\n",
1573 );
1574
1575 #[tokio::test]
1578 async fn logs_one_shot_sinks_lines_verbatim() {
1579 let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1580 let mut received: Vec<String> = Vec::new();
1581 let result = rig_logs(&runner, &gw_plan(), 200, false, None, &mut |line| {
1582 received.push(line)
1583 })
1584 .await
1585 .expect("logs succeeds");
1586 assert_eq!(result.streamed, 2);
1587 assert_eq!(
1588 received,
1589 vec![
1590 "ignition-1 | 22:01:01.001 INFO Gateway - starting",
1591 "ignition-1 | 22:01:02.002 INFO Gateway - RUNNING",
1592 ],
1593 "lines pass through verbatim — no envelope wrapping ever"
1594 );
1595 let calls = runner.calls();
1596 assert_eq!(
1597 calls,
1598 vec![("docker compose", logs_args(&gw_plan(), 200, false, None))]
1599 );
1600 }
1601
1602 #[tokio::test]
1605 async fn logs_follow_streams_via_the_streaming_seam() {
1606 let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1607 let mut received: Vec<String> = Vec::new();
1608 let result = rig_logs(
1609 &runner,
1610 &gw_plan(),
1611 50,
1612 true,
1613 Some("ignition"),
1614 &mut |line| received.push(line),
1615 )
1616 .await
1617 .expect("follow logs succeeds");
1618 assert_eq!(result.streamed, 2, "streamed lines counted in follow mode");
1619 assert_eq!(received.len(), 2);
1620 let calls = runner.calls();
1621 assert_eq!(
1622 calls,
1623 vec![(
1624 "docker compose",
1625 logs_args(&gw_plan(), 50, true, Some("ignition"))
1626 )]
1627 );
1628 }
1629
1630 #[tokio::test]
1633 async fn logs_failure_carries_stderr_tail_never_sink() {
1634 let runner = FakeRunner::with(vec![ComposeOutput {
1635 stdout: String::new(),
1636 stderr: "no such service: nosvc".into(),
1637 code: 1,
1638 }]);
1639 let mut received: Vec<String> = Vec::new();
1640 let err = rig_logs(
1641 &runner,
1642 &gw_plan(),
1643 200,
1644 false,
1645 Some("nosvc"),
1646 &mut |line| received.push(line),
1647 )
1648 .await
1649 .expect_err("unknown service errors");
1650 let message = err.to_string();
1651 assert!(
1652 message.contains("docker compose logs failed (exit 1)"),
1653 "{message}"
1654 );
1655 assert!(message.contains("no such service"), "{message}");
1656 assert!(received.is_empty(), "diagnostics never ride the data sink");
1657 }
1658
1659 async fn expired_trial_server() -> wiremock::MockServer {
1666 let server = wiremock::MockServer::start().await;
1667 wiremock::Mock::given(wiremock::matchers::method("GET"))
1668 .and(wiremock::matchers::path("/data/api/v1/trial"))
1669 .respond_with(
1670 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1671 "licenseMode": "Trial", "trialState": "AllInDemo",
1672 "trialSecondsLeft": 0, "expired": true,
1673 "emergency": false, "emergencySecondsLeft": 0,
1674 "development": false, "developmentSecondsLeft": 0
1675 })),
1676 )
1677 .mount(&server)
1678 .await;
1679 wiremock::Mock::given(wiremock::matchers::method("GET"))
1680 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
1681 .respond_with(
1682 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1683 "banners": [{
1684 "order": 0, "type": "trial",
1685 "data": { "severity": "warning", "expireTime": null,
1686 "toolTips": [], "actions": [] }
1687 }]
1688 })),
1689 )
1690 .mount(&server)
1691 .await;
1692 server
1693 }
1694
1695 #[tokio::test]
1698 async fn trial_status_expired_shape_is_exact() {
1699 let server = expired_trial_server().await;
1700 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1701 let result = trial_status(&api).await.expect("expired status parses");
1702 assert_eq!(
1703 serde_json::to_value(&result).unwrap(),
1704 serde_json::json!({
1705 "license_mode": "Trial",
1706 "trial_state": "AllInDemo",
1707 "trial_remaining_s": 0,
1708 "expired": true,
1709 "emergency": false,
1710 "emergency_remaining_s": 0,
1711 "development": false,
1712 "banners": {
1713 "severity": "warning",
1714 "expire_time_ms": null,
1715 "active": false
1716 },
1717 "warnings": []
1718 }),
1719 "EXACT shape comparison — the unit-explicit keys + the \
1720 banners cross-check block, no unknown keys"
1721 );
1722 }
1723
1724 #[tokio::test]
1729 async fn trial_status_banner_active_requires_future_expire_time() {
1730 for (expire_time, active) in [(9_999_999_999_999_999i64, true), (1i64, false)] {
1731 let server = wiremock::MockServer::start().await;
1732 wiremock::Mock::given(wiremock::matchers::method("GET"))
1733 .and(wiremock::matchers::path("/data/api/v1/trial"))
1734 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1735 serde_json::json!({
1736 "licenseMode": "Trial", "trialState": "AllInDemo",
1737 "trialSecondsLeft": 6590, "expired": false,
1738 "emergency": false, "emergencySecondsLeft": 0,
1739 "development": false, "developmentSecondsLeft": 0
1740 }),
1741 ))
1742 .mount(&server)
1743 .await;
1744 wiremock::Mock::given(wiremock::matchers::method("GET"))
1745 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
1746 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
1747 serde_json::json!({
1748 "banners": [{
1749 "order": 5, "type": "trial",
1750 "data": { "severity": "info",
1751 "expireTime": expire_time,
1752 "toolTips": [], "actions": [] }
1753 }]
1754 }),
1755 ))
1756 .mount(&server)
1757 .await;
1758 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1759 let result = trial_status(&api).await.expect("active status parses");
1760 assert!(!result.expired, "primary truth from the trial endpoint");
1761 assert_eq!(result.trial_remaining_s, 6590);
1762 assert_eq!(
1763 result.banners.severity.as_deref(),
1764 Some("info"),
1765 "the trial banner surfaced (8.3.3 serves order 5 — not an index)"
1766 );
1767 assert_eq!(
1768 result.banners.active, active,
1769 "info severity + expireTime {expire_time} → active {active} (Pitfall 7)"
1770 );
1771 }
1772 }
1773
1774 #[tokio::test]
1777 async fn trial_status_banners_failure_degrades_with_warning() {
1778 let server = wiremock::MockServer::start().await;
1779 wiremock::Mock::given(wiremock::matchers::method("GET"))
1780 .and(wiremock::matchers::path("/data/api/v1/trial"))
1781 .respond_with(
1782 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1783 "licenseMode": "Trial", "trialState": "AllInDemo",
1784 "trialSecondsLeft": 0, "expired": true,
1785 "emergency": false, "emergencySecondsLeft": 0,
1786 "development": false, "developmentSecondsLeft": 0
1787 })),
1788 )
1789 .mount(&server)
1790 .await;
1791 wiremock::Mock::given(wiremock::matchers::method("GET"))
1792 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
1793 .respond_with(wiremock::ResponseTemplate::new(500))
1794 .mount(&server)
1795 .await;
1796 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1797 let result = trial_status(&api).await.expect("primary endpoint answered");
1798 assert!(
1799 result.expired,
1800 "primary truth survives the cross-check failure"
1801 );
1802 assert_eq!(result.banners.severity, None);
1803 assert_eq!(result.banners.expire_time_ms, None);
1804 assert!(!result.banners.active);
1805 assert!(
1806 result
1807 .warnings
1808 .iter()
1809 .any(|warning| warning.contains("banners cross-check unavailable")),
1810 "the degradation is visible data: {:?}",
1811 result.warnings
1812 );
1813 }
1814
1815 fn trial_body(expired: bool, seconds_left: i64) -> serde_json::Value {
1821 serde_json::json!({
1822 "licenseMode": "Trial", "trialState": "AllInDemo",
1823 "trialSecondsLeft": seconds_left, "expired": expired,
1824 "emergency": false, "emergencySecondsLeft": 0,
1825 "development": false, "developmentSecondsLeft": 0
1826 })
1827 }
1828
1829 #[derive(Clone)]
1835 struct TrialFlipScript {
1836 reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
1837 post_status: u16,
1838 }
1839
1840 impl wiremock::Respond for TrialFlipScript {
1841 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
1842 if request.method.as_str() == "POST" {
1843 if self.post_status == 200 {
1844 self.reset_done
1845 .store(true, std::sync::atomic::Ordering::SeqCst);
1846 return wiremock::ResponseTemplate::new(200)
1847 .set_body_json(trial_body(false, 7199));
1848 }
1849 return wiremock::ResponseTemplate::new(self.post_status);
1850 }
1851 let expired = !self.reset_done.load(std::sync::atomic::Ordering::SeqCst);
1852 wiremock::ResponseTemplate::new(200)
1853 .set_body_json(trial_body(expired, if expired { 0 } else { 7199 }))
1854 }
1855 }
1856
1857 async fn trial_reset_server(post_status: u16) -> (wiremock::MockServer, TrialFlipScript) {
1860 let server = wiremock::MockServer::start().await;
1861 let script = TrialFlipScript {
1862 reset_done: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1863 post_status,
1864 };
1865 wiremock::Mock::given(wiremock::matchers::method("GET"))
1866 .and(wiremock::matchers::path("/data/api/v1/trial"))
1867 .respond_with(script.clone())
1868 .mount(&server)
1869 .await;
1870 wiremock::Mock::given(wiremock::matchers::method("POST"))
1871 .and(wiremock::matchers::path("/data/api/v1/trial"))
1872 .respond_with(script.clone())
1873 .mount(&server)
1874 .await;
1875 (server, script)
1876 }
1877
1878 #[tokio::test]
1882 async fn trial_reset_refuses_active_trial_up_front() {
1883 let (server, script) = trial_reset_server(200).await;
1884 script
1887 .reset_done
1888 .store(true, std::sync::atomic::Ordering::SeqCst);
1889 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1890 let err = trial_reset(&api, &server.uri(), false, None)
1891 .await
1892 .expect_err("an active trial is refused before any POST");
1893 assert!(matches!(err, CoreError::TrialNotExpired { .. }), "{err}");
1894 assert_eq!(err.exit_code(), 6);
1895 assert_eq!(err.code(), "trial_not_expired");
1896 let message = err.to_string();
1897 assert!(
1898 message.contains("7199s left"),
1899 "the message names the countdown: {message}"
1900 );
1901 }
1902
1903 #[tokio::test]
1906 async fn trial_reset_tier0_lands_with_read_back_flip() {
1907 let (server, _script) = trial_reset_server(200).await;
1908 let credential = crate::config::Credential::Token(crate::config::Secret::new(
1909 "spike:tokengeneratedlive",
1910 ));
1911 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
1912 let result = trial_reset(&api, &server.uri(), true, None)
1913 .await
1914 .expect("tier 0 resets the expired trial");
1915 assert_eq!(
1916 serde_json::to_value(&result).unwrap(),
1917 serde_json::json!({
1918 "rig_url": server.uri(),
1919 "mechanism": "token",
1920 "expired_before": true,
1921 "expired_after": false,
1922 "trial_remaining_s": 7199
1923 }),
1924 "EXACT shape — which rung landed + the before/after flip"
1925 );
1926 }
1927
1928 #[tokio::test]
1932 async fn trial_reset_token_refused_without_login_errors() {
1933 let (server, _script) = trial_reset_server(401).await;
1934 let credential =
1935 crate::config::Credential::Token(crate::config::Secret::new("spike:wrongtoken"));
1936 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
1937 let err = trial_reset(&api, &server.uri(), true, None)
1938 .await
1939 .expect_err("the refused token rung has no fallback");
1940 assert!(matches!(err, CoreError::SecretUnavailable { .. }), "{err}");
1941 assert_eq!(err.exit_code(), 3);
1942 }
1943
1944 async fn login_dance_server() -> (wiremock::MockServer, TrialFlipScript) {
1952 let (server, script) = trial_reset_server(401).await;
1953 wiremock::Mock::given(wiremock::matchers::method("GET"))
1954 .and(wiremock::matchers::path("/data/app/login"))
1955 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
1956 "Location",
1957 "/idp/default/oidc/auth?app=gateway&state=st&nonce=nc",
1958 ))
1959 .mount(&server)
1960 .await;
1961 wiremock::Mock::given(wiremock::matchers::method("GET"))
1962 .and(wiremock::matchers::path("/idp/default/oidc/auth"))
1963 .and(wiremock::matchers::query_param_is_missing("token"))
1964 .respond_with(
1965 wiremock::ResponseTemplate::new(302)
1966 .insert_header("Location", "/idp/default/authn/login?app=gateway&token=TT0"),
1967 )
1968 .mount(&server)
1969 .await;
1970 for (body_token, answer) in [
1971 (
1972 "TT0",
1973 r#"{"complete":false,"nextChallenge":[{"type":"basic"}],"token":"TT1"}"#,
1974 ),
1975 ("TT2", r#"{"complete":true,"token":"TT3"}"#),
1976 ] {
1977 wiremock::Mock::given(wiremock::matchers::method("POST"))
1978 .and(wiremock::matchers::path(
1979 "/idp/default/authn/next-challenge",
1980 ))
1981 .and(wiremock::matchers::body_json(
1982 serde_json::json!({ "token": body_token }),
1983 ))
1984 .respond_with(
1985 wiremock::ResponseTemplate::new(200)
1986 .set_body_string(answer)
1987 .insert_header("Content-Type", "application/json"),
1988 )
1989 .mount(&server)
1990 .await;
1991 }
1992 wiremock::Mock::given(wiremock::matchers::method("POST"))
1993 .and(wiremock::matchers::path(
1994 "/idp/default/authn/submit-challenge/basic",
1995 ))
1996 .respond_with(
1997 wiremock::ResponseTemplate::new(200)
1998 .set_body_string(r#"{"success":true,"token":"TT2"}"#)
1999 .insert_header("Content-Type", "application/json"),
2000 )
2001 .mount(&server)
2002 .await;
2003 wiremock::Mock::given(wiremock::matchers::method("GET"))
2004 .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2005 .and(wiremock::matchers::query_param("token", "TT3"))
2006 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2007 "Location",
2008 "/data/federate/callback/internal?code=c&state=st",
2009 ))
2010 .mount(&server)
2011 .await;
2012 wiremock::Mock::given(wiremock::matchers::method("GET"))
2013 .and(wiremock::matchers::path("/data/federate/callback/internal"))
2014 .respond_with(
2015 wiremock::ResponseTemplate::new(302)
2016 .insert_header("Location", "/app")
2017 .append_header("Set-Cookie", "webui-sid-1=sess; Path=/; HttpOnly"),
2018 )
2019 .mount(&server)
2020 .await;
2021 wiremock::Mock::given(wiremock::matchers::method("GET"))
2022 .and(wiremock::matchers::path("/data/app/session"))
2023 .respond_with(
2024 wiremock::ResponseTemplate::new(200)
2025 .set_body_string(r#"{"userPayload":{},"csrfToken":"csrf1"}"#)
2026 .insert_header("Content-Type", "application/json"),
2027 )
2028 .mount(&server)
2029 .await;
2030 wiremock::Mock::given(wiremock::matchers::method("POST"))
2031 .and(wiremock::matchers::path("/data/api/v1/trial"))
2032 .and(wiremock::matchers::header("X-CSRF-Token", "csrf1"))
2033 .respond_with(SessionResetFlip {
2034 reset_done: script.reset_done.clone(),
2035 })
2036 .with_priority(1)
2037 .mount(&server)
2038 .await;
2039 (server, script)
2040 }
2041
2042 struct SessionResetFlip {
2045 reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2046 }
2047
2048 impl wiremock::Respond for SessionResetFlip {
2049 fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
2050 self.reset_done
2051 .store(true, std::sync::atomic::Ordering::SeqCst);
2052 wiremock::ResponseTemplate::new(200).set_body_json(trial_body(false, 7199))
2053 }
2054 }
2055
2056 #[tokio::test]
2060 async fn trial_reset_falls_through_to_the_login_rung() {
2061 let (server, _script) = login_dance_server().await;
2062 let credential =
2063 crate::config::Credential::Token(crate::config::Secret::new("spike:rejectedtoken"));
2064 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2065 let password = crate::config::Secret::new("rig-password");
2066 let result = trial_reset(&api, &server.uri(), true, Some(("admin", &password)))
2067 .await
2068 .expect("the login rung carries the reset");
2069 assert_eq!(result.mechanism, "login");
2070 assert!(result.expired_before);
2071 assert!(!result.expired_after);
2072 assert_eq!(result.trial_remaining_s, 7199);
2073 }
2074
2075 #[tokio::test]
2078 async fn trial_reset_login_rung_alone() {
2079 let (server, _script) = login_dance_server().await;
2080 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2081 let password = crate::config::Secret::new("rig-password");
2082 let result = trial_reset(&api, &server.uri(), false, Some(("admin", &password)))
2083 .await
2084 .expect("login-only reset works");
2085 assert_eq!(result.mechanism, "login");
2086 assert!(!result.expired_after);
2087 }
2088
2089 struct SnapshotRig {
2098 calls: Mutex<Vec<String>>,
2099 project_names: Vec<String>,
2101 version: String,
2103 ping_state: &'static str,
2108 ping_fail: bool,
2112 }
2113
2114 impl Default for SnapshotRig {
2115 fn default() -> Self {
2116 Self {
2117 calls: Mutex::new(Vec::new()),
2118 project_names: Vec::new(),
2119 version: String::new(),
2120 ping_state: "RUNNING",
2121 ping_fail: false,
2122 }
2123 }
2124 }
2125
2126 impl SnapshotRig {
2127 fn calls(&self) -> Vec<String> {
2128 self.calls.lock().unwrap().clone()
2129 }
2130
2131 fn fixture_bytes() -> Vec<u8> {
2133 let mut bytes: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04];
2134 bytes.extend_from_slice(b"snapshot-fixture");
2135 bytes
2136 }
2137
2138 fn record(&self, call: String) {
2139 self.calls.lock().unwrap().push(call);
2140 }
2141
2142 fn serve_download(out: &Path, fixture_len: u64) -> crate::client::projects::ExportMeta {
2143 std::fs::write(out, Self::fixture_bytes()).expect("write fixture file");
2144 crate::client::projects::ExportMeta {
2145 filename: None,
2146 bytes: fixture_len,
2147 content_type: Some("application/octet-stream".into()),
2148 }
2149 }
2150 }
2151
2152 #[async_trait::async_trait]
2153 impl GatewayApi for SnapshotRig {
2154 async fn bundle_generate(
2155 &self,
2156 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2157 unreachable!("not part of this action")
2158 }
2159 async fn bundle_status(
2160 &self,
2161 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2162 unreachable!("not part of this action")
2163 }
2164 async fn bundle_download(
2165 &self,
2166 _out: &std::path::Path,
2167 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2168 unreachable!("not part of this action")
2169 }
2170 async fn tag_provider_list(
2171 &self,
2172 _query: &crate::client::query::ListQuery,
2173 ) -> Result<
2174 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
2175 CoreError,
2176 > {
2177 unreachable!("not part of this action")
2178 }
2179 async fn tag_provider_find(
2180 &self,
2181 _name: &str,
2182 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
2183 unreachable!("not part of this action")
2184 }
2185 async fn tag_provider_create(
2186 &self,
2187 _body: &[crate::client::tags::TagProviderCreate],
2188 ) -> Result<(), CoreError> {
2189 unreachable!("not part of this action")
2190 }
2191 async fn tag_provider_delete(
2192 &self,
2193 _name: &str,
2194 _signature: &str,
2195 ) -> Result<(), CoreError> {
2196 unreachable!("not part of this action")
2197 }
2198 async fn backup_download(
2199 &self,
2200 out: &Path,
2201 _backup_type: crate::client::backup::BackupType,
2202 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2203 self.record("backup_download".into());
2204 Ok(Self::serve_download(
2205 out,
2206 Self::fixture_bytes().len() as u64,
2207 ))
2208 }
2209 async fn backup_restore(&self, _gwbk: &Path) -> Result<(), CoreError> {
2210 self.record("backup_restore".into());
2211 Ok(())
2212 }
2213 async fn eam_task_history(
2214 &self,
2215 _limit: Option<u32>,
2216 _search: Option<&str>,
2217 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
2218 {
2219 unreachable!("not part of this action")
2220 }
2221 async fn eam_task_definitions(
2222 &self,
2223 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
2224 {
2225 unreachable!("not part of this action")
2226 }
2227 async fn eam_task_find(
2228 &self,
2229 _name: &str,
2230 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
2231 unreachable!("not part of this action")
2232 }
2233 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
2234 unreachable!("not part of this action")
2235 }
2236 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
2237 unreachable!("not part of this action")
2238 }
2239 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
2240 unreachable!("not part of this action")
2241 }
2242 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
2243 unreachable!("not part of this action")
2244 }
2245 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
2246 unreachable!("not part of this action")
2247 }
2248 async fn eam_tasks_scheduled(
2249 &self,
2250 _running: bool,
2251 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
2252 unreachable!("not part of this action")
2253 }
2254 async fn eam_task_modify(
2255 &self,
2256 _definition: &serde_json::Value,
2257 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
2258 unreachable!("not part of this action")
2259 }
2260 async fn eam_task_delete(
2261 &self,
2262 _name: &str,
2263 _signature: &str,
2264 _confirm: bool,
2265 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
2266 unreachable!("not part of this action")
2267 }
2268 async fn api_call(
2269 &self,
2270 _call: &crate::client::apicall::ApiCallRequest,
2271 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
2272 unreachable!("not part of this action")
2273 }
2274 async fn license_status(
2275 &self,
2276 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
2277 unreachable!("not part of this action")
2278 }
2279 async fn redundancy_status(
2280 &self,
2281 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
2282 unreachable!("not part of this action")
2283 }
2284 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
2285 unreachable!("not part of this action")
2286 }
2287 async fn projects(
2288 &self,
2289 _query: &crate::client::query::ListQuery,
2290 ) -> Result<
2291 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
2292 CoreError,
2293 > {
2294 self.record("projects".into());
2295 let items: Vec<crate::client::projects::ProjectRecord> = self
2296 .project_names
2297 .iter()
2298 .map(|name| crate::client::projects::ProjectRecord {
2299 name: name.clone(),
2300 title: None,
2301 description: None,
2302 enabled: true,
2303 parent: None,
2304 inheritable: None,
2305 default_db: None,
2306 tag_provider: None,
2307 user_source: None,
2308 extra: Default::default(),
2309 })
2310 .collect();
2311 let total = items.len() as i64;
2312 Ok(crate::client::query::ListEnvelope {
2313 items,
2314 metadata: crate::client::query::ListMetadata {
2315 total,
2316 matching: total,
2317 limit: -1,
2318 offset: 0,
2319 },
2320 })
2321 }
2322 async fn project_export_to_file(
2323 &self,
2324 name: &str,
2325 out: &Path,
2326 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2327 self.record(format!("export:{name}"));
2328 Ok(Self::serve_download(
2329 out,
2330 Self::fixture_bytes().len() as u64,
2331 ))
2332 }
2333 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
2334 self.record("gateway_info".into());
2335 Ok(crate::client::version::GatewayInfo {
2336 name: None,
2337 redundancy_role: None,
2338 edition: None,
2339 ignition_version: self.version.clone(),
2340 jvm_version: None,
2341 license: None,
2342 endpoint: None,
2343 })
2344 }
2345 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
2346 if self.ping_fail {
2347 return Err(CoreError::Internal("probe fixture failure".into()));
2352 }
2353 Ok(crate::client::status::StatusPing {
2354 state: self.ping_state.to_string(),
2355 })
2356 }
2357
2358 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
2360 unreachable!("not part of this action")
2361 }
2362 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
2363 unreachable!("not part of this action")
2364 }
2365 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
2366 unreachable!("not part of this action")
2367 }
2368 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
2369 unreachable!("not part of this action")
2370 }
2371 async fn modules(
2372 &self,
2373 _quarantined: bool,
2374 _query: &crate::client::query::ListQuery,
2375 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
2376 {
2377 unreachable!("not part of this action")
2378 }
2379 async fn metrics_current(
2380 &self,
2381 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
2382 unreachable!("not part of this action")
2383 }
2384 async fn metrics_historic(
2385 &self,
2386 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
2387 unreachable!("not part of this action")
2388 }
2389 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
2390 unreachable!("not part of this action")
2391 }
2392 async fn designers(
2393 &self,
2394 _query: &crate::client::query::ListQuery,
2395 ) -> Result<
2396 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
2397 CoreError,
2398 > {
2399 unreachable!("not part of this action")
2400 }
2401 async fn perspective_sessions(
2402 &self,
2403 _query: &crate::client::query::ListQuery,
2404 ) -> Result<
2405 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
2406 CoreError,
2407 > {
2408 unreachable!("not part of this action")
2409 }
2410 async fn vision_clients(
2411 &self,
2412 _query: &crate::client::query::ListQuery,
2413 ) -> Result<
2414 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
2415 CoreError,
2416 > {
2417 unreachable!("not part of this action")
2418 }
2419 async fn terminate_perspective_session(
2420 &self,
2421 _id: &str,
2422 _message: Option<&str>,
2423 ) -> Result<(), CoreError> {
2424 unreachable!("not part of this action")
2425 }
2426 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
2427 unreachable!("not part of this action")
2428 }
2429 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
2430 unreachable!("not part of this action")
2431 }
2432 async fn database_connections(
2433 &self,
2434 ) -> Result<
2435 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
2436 CoreError,
2437 > {
2438 unreachable!("not part of this action")
2439 }
2440 async fn opc_connections(
2441 &self,
2442 ) -> Result<
2443 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
2444 CoreError,
2445 > {
2446 unreachable!("not part of this action")
2447 }
2448 async fn logs(
2449 &self,
2450 _filter: &crate::client::logs::LogQuery,
2451 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
2452 {
2453 unreachable!("not part of this action")
2454 }
2455 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
2456 unreachable!("not part of this action")
2457 }
2458 async fn loggers(
2459 &self,
2460 _query: &crate::client::query::ListQuery,
2461 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
2462 {
2463 unreachable!("not part of this action")
2464 }
2465 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
2466 unreachable!("not part of this action")
2467 }
2468 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
2469 unreachable!("not part of this action")
2470 }
2471 async fn restart(&self) -> Result<(), CoreError> {
2472 unreachable!("not part of this action")
2473 }
2474 async fn scan_projects(&self) -> Result<(), CoreError> {
2475 unreachable!("not part of this action")
2476 }
2477 async fn security_properties(
2478 &self,
2479 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
2480 unreachable!("not part of this action")
2481 }
2482 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
2483 unreachable!("not part of this action")
2484 }
2485 async fn webdev_route_call(
2486 &self,
2487 _project: &str,
2488 _route: &str,
2489 _body: &serde_json::Value,
2490 _extra_headers: &[(&str, &str)],
2491 ) -> Result<serde_json::Value, CoreError> {
2492 unreachable!("not part of this action")
2493 }
2494 async fn webdev_route_probe(
2495 &self,
2496 _project: &str,
2497 _route: &str,
2498 _extra_headers: &[(&str, &str)],
2499 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
2500 unreachable!("not part of this action")
2501 }
2502 async fn project_find(
2503 &self,
2504 _name: &str,
2505 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
2506 unreachable!("not part of this action")
2507 }
2508 async fn project_create(
2509 &self,
2510 _body: &crate::client::projects::ProjectCreate,
2511 ) -> Result<(), CoreError> {
2512 unreachable!("not part of this action")
2513 }
2514 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
2515 unreachable!("not part of this action")
2516 }
2517 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
2518 unreachable!("not part of this action")
2519 }
2520 async fn project_modify(
2521 &self,
2522 _name: &str,
2523 _body: &crate::client::projects::ProjectModify,
2524 ) -> Result<(), CoreError> {
2525 unreachable!("not part of this action")
2526 }
2527 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
2528 unreachable!("not part of this action")
2529 }
2530 async fn project_import(
2531 &self,
2532 _name: &str,
2533 _zip: Vec<u8>,
2534 _overwrite: bool,
2535 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
2536 unreachable!("not part of this action")
2537 }
2538 }
2539
2540 #[tokio::test]
2545 async fn snapshot_composes_gwbk_exports_and_exact_manifest() {
2546 let out_dir = tempfile::tempdir().expect("tempdir");
2547 let rig = SnapshotRig {
2548 project_names: vec!["alpha".into(), "My Project".into()],
2549 version: "8.3.3 (b1)".into(),
2550 ping_state: "RUNNING",
2551 ..SnapshotRig::default()
2552 };
2553
2554 let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
2555 .await
2556 .expect("snapshot composes");
2557 let fixture_len = SnapshotRig::fixture_bytes().len() as u64;
2558 assert_eq!(result.gwbk_bytes, fixture_len);
2559 assert_eq!(
2560 result.projects,
2561 vec!["alpha".to_string(), "My Project".to_string()]
2562 );
2563 assert_eq!(result.dir, out_dir.path().display().to_string());
2564
2565 let on_disk = std::fs::read(out_dir.path().join("fixture-rig.gwbk")).expect("gwbk exists");
2567 assert_eq!(on_disk, SnapshotRig::fixture_bytes());
2568 assert!(out_dir.path().join("projects/alpha.zip").exists());
2570 assert!(out_dir.path().join("projects/My%20Project.zip").exists());
2571
2572 let manifest_path = out_dir.path().join("manifest.json");
2575 assert_eq!(result.manifest_path, manifest_path.display().to_string());
2576 let manifest: serde_json::Value =
2577 serde_json::from_str(&std::fs::read_to_string(&manifest_path).expect("manifest read"))
2578 .expect("manifest parses");
2579 let taken_at = manifest["taken_at"].as_i64().expect("taken_at epoch s");
2580 let now = std::time::SystemTime::now()
2581 .duration_since(std::time::UNIX_EPOCH)
2582 .unwrap()
2583 .as_secs() as i64;
2584 assert!(
2585 (now - 5..=now + 5).contains(&taken_at),
2586 "taken_at is epoch seconds near now: {taken_at}"
2587 );
2588 assert_eq!(
2589 manifest,
2590 serde_json::json!({
2591 "rig": "fixture-rig",
2592 "taken_at": taken_at,
2593 "ignition": { "version": "8.3.3 (b1)" },
2594 "gwbk": "fixture-rig.gwbk",
2595 "projects": [
2596 { "name": "alpha", "file": "projects/alpha.zip" },
2597 { "name": "My Project", "file": "projects/My%20Project.zip" }
2598 ],
2599 "notes": [
2600 "trial clock state is NOT captured by gwbk (unknown behavior — reset \
2601 separately via rig trial reset)",
2602 "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
2603 config via gateway data"
2604 ]
2605 }),
2606 "EXACT manifest shape — the honest composition contract"
2607 );
2608
2609 assert_eq!(
2612 rig.calls(),
2613 vec![
2614 "backup_download".to_string(),
2615 "projects".to_string(),
2616 "export:alpha".to_string(),
2617 "export:My Project".to_string(),
2618 "gateway_info".to_string(),
2619 ]
2620 );
2621 }
2622
2623 #[tokio::test]
2626 async fn snapshot_of_empty_gateway_carries_empty_projects_key() {
2627 let out_dir = tempfile::tempdir().expect("tempdir");
2628 let rig = SnapshotRig {
2629 version: "8.3.6".into(),
2630 ping_state: "RUNNING",
2631 ..SnapshotRig::default()
2632 };
2633 let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
2634 .await
2635 .expect("empty snapshot composes");
2636 assert_eq!(result.projects, Vec::<String>::new());
2637 assert!(!out_dir.path().join("projects").exists(), "no empty dir");
2638 let manifest: serde_json::Value = serde_json::from_str(
2639 &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
2640 )
2641 .expect("parses");
2642 assert_eq!(
2643 manifest["projects"],
2644 serde_json::json!([]),
2645 "the key is present and empty — agents never key-hunt"
2646 );
2647 }
2648
2649 #[tokio::test]
2654 async fn snapshot_survives_gateway_info_failure_with_null_version() {
2655 let server = wiremock::MockServer::start().await;
2661 wiremock::Mock::given(wiremock::matchers::method("GET"))
2662 .and(wiremock::matchers::path("/data/api/v1/backup"))
2663 .respond_with(
2664 wiremock::ResponseTemplate::new(200)
2665 .set_body_raw(vec![0x50, 0x4B, 0x03, 0x04], "application/octet-stream"),
2666 )
2667 .mount(&server)
2668 .await;
2669 wiremock::Mock::given(wiremock::matchers::method("GET"))
2670 .and(wiremock::matchers::path("/data/api/v1/projects/list"))
2671 .respond_with(
2672 wiremock::ResponseTemplate::new(200)
2673 .set_body_json(serde_json::json!({ "items": [], "metadata": {
2674 "total": 0, "matching": 0, "limit": -1, "offset": 0 } })),
2675 )
2676 .mount(&server)
2677 .await;
2678 wiremock::Mock::given(wiremock::matchers::method("GET"))
2679 .and(wiremock::matchers::path("/data/api/v1/gateway-info"))
2680 .respond_with(wiremock::ResponseTemplate::new(500))
2681 .mount(&server)
2682 .await;
2683 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2684
2685 let out_dir = tempfile::tempdir().expect("tempdir");
2686 let result = rig_snapshot(&api, "fixture-rig", Some(out_dir.path()))
2687 .await
2688 .expect("the snapshot survives the metadata failure");
2689 assert_eq!(result.gwbk_bytes, 4);
2690 let manifest: serde_json::Value = serde_json::from_str(
2691 &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
2692 )
2693 .expect("parses");
2694 assert_eq!(manifest["ignition"]["version"], serde_json::Value::Null);
2695 }
2696
2697 #[tokio::test]
2700 async fn restore_posts_waits_and_warns() {
2701 let work = tempfile::tempdir().expect("tempdir");
2702 let gwbk = work.path().join("snapshot.gwbk");
2703 std::fs::write(&gwbk, b"PK\x03\x04restore-fixture").expect("write gwbk");
2704 let rig = SnapshotRig {
2705 ping_state: "RUNNING",
2706 ..SnapshotRig::default()
2707 };
2708
2709 let result = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
2710 .await
2711 .expect("restore completes");
2712 assert_eq!(
2713 serde_json::to_value(&result).unwrap(),
2714 serde_json::json!({
2715 "restored_from": gwbk.display().to_string(),
2716 "state": "running",
2717 "warnings": [
2718 "API tokens may have been reset by restore — re-provision via \
2719 gateway UI, then ign doctor"
2720 ],
2721 }),
2722 "EXACT shape — state witnessed (never a bare 2xx), token warning first"
2723 );
2724 assert_eq!(
2725 rig.calls(),
2726 vec!["backup_restore".to_string()],
2727 "the POST fired once (the wait's status_ping probes aren't \
2728 recorded — the fake serves the state directly)"
2729 );
2730 }
2731
2732 #[tokio::test]
2736 async fn restore_prechecks_fail_before_any_network() {
2737 let rig = SnapshotRig::default();
2738
2739 let missing = PathBuf::from("/nonexistent/snap.gwbk");
2741 let err = rig_restore(&rig, "http://localhost:9088", &missing, 300)
2742 .await
2743 .expect_err("missing file refuses");
2744 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
2745 assert_eq!(err.exit_code(), 2);
2746 assert_eq!(err.code(), "invalid_input");
2747 let message = err.to_string();
2748 assert!(message.contains("not found"), "{message}");
2749
2750 let work = tempfile::tempdir().expect("tempdir");
2752 let empty = work.path().join("empty.gwbk");
2753 std::fs::write(&empty, b"").expect("write empty");
2754 let err = rig_restore(&rig, "http://localhost:9088", &empty, 300)
2755 .await
2756 .expect_err("empty file refuses");
2757 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
2758 assert!(err.to_string().contains("empty"), "{}", err);
2759
2760 let unreadable = work.path(); let err = rig_restore(&rig, "http://localhost:9088", unreadable, 300)
2766 .await
2767 .expect_err("directory is not a restorable file");
2768 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
2769 assert!(
2770 rig.calls().is_empty(),
2771 "pre-check refusals never touch the gateway: {:?}",
2772 rig.calls()
2773 );
2774 }
2775
2776 #[tokio::test]
2783 async fn restore_wait_failure_is_a_rig_error() {
2784 let work = tempfile::tempdir().expect("tempdir");
2785 let gwbk = work.path().join("snapshot.gwbk");
2786 std::fs::write(&gwbk, b"PK\x03\x04fixture").expect("write gwbk");
2787 let rig = SnapshotRig {
2788 ping_fail: true,
2789 ..SnapshotRig::default()
2790 };
2791 let err = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
2792 .await
2793 .expect_err("a failed wait errors the restore");
2794 assert!(matches!(err, CoreError::Rig(_)), "{err}");
2795 assert_eq!(err.exit_code(), 7);
2796 let message = err.to_string();
2797 assert!(message.contains("did not reach RUNNING"), "{message}");
2798 assert!(
2799 rig.calls().contains(&"backup_restore".to_string()),
2800 "the POST fired before the wait: {:?}",
2801 rig.calls()
2802 );
2803 }
2804
2805 #[test]
2810 fn restore_wait_floor_is_300s_and_clamps() {
2811 assert_eq!(super::RESTORE_WAIT_FLOOR_S, 300);
2812 assert_eq!(super::restore_deadline(1), 300, "short budgets floor up");
2813 assert_eq!(super::restore_deadline(300), 300);
2814 assert_eq!(
2815 super::restore_deadline(600),
2816 600,
2817 "longer budgets pass through"
2818 );
2819 }
2820
2821 #[test]
2825 fn stamp_renders_utc_compact() {
2826 assert_eq!(super::stamp_from_secs(0), "19700101-000000");
2827 assert_eq!(super::stamp_from_secs(1_787_346_747), "20260821-211227");
2828 assert_eq!(super::civil_from_days(0), (1970, 1, 1));
2829 assert_eq!(super::civil_from_days(19_723), (2024, 1, 1));
2830 }
2831
2832 const PS_STDOUT: &str = concat!(
2838 r#"{"Name":"fixture-rig-ignition-1","Service":"ignition","State":"running","Health":"healthy","ExitCode":0,"Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}]}"#,
2839 "\n",
2840 r#"{"Name":"fixture-rig-db-1","Service":"db","State":"exited","ExitCode":137,"Publishers":[]}"#,
2841 "\n",
2842 );
2843
2844 const VOLUME_STDOUT: &str = concat!(
2845 r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
2846 "\n",
2847 );
2848
2849 #[tokio::test]
2850 async fn status_serializes_the_allowlist_exactly() {
2851 let occupant = r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
2855 let runner = FakeRunner::with(vec![
2856 version_ok(),
2857 ok(PS_STDOUT),
2858 ok(VOLUME_STDOUT),
2859 ok(occupant),
2860 ok(occupant),
2861 ]);
2862
2863 let result = rig_status(&runner, &gw_plan())
2864 .await
2865 .expect("status succeeds");
2866 let json = serde_json::to_value(&result).unwrap();
2867 assert_eq!(
2868 json,
2869 serde_json::json!({
2870 "rig": "fixture-rig",
2871 "project": "fixture-rig",
2872 "compose_file": "/rigs/docker/compose.yml",
2873 "services": [
2874 {
2875 "name": "ignition",
2876 "state": "running",
2877 "health": "healthy",
2878 "exit_code": 0,
2879 "publishers": [
2880 {"published_port": 9088, "target_port": 8088, "protocol": "tcp"},
2881 {"published_port": 9443, "target_port": 443, "protocol": "tcp"}
2882 ]
2883 },
2884 {
2885 "name": "db",
2886 "state": "exited",
2887 "health": null,
2888 "exit_code": 137,
2889 "publishers": []
2890 }
2891 ],
2892 "volumes": ["fixture-rig_gw-data"],
2893 "ports_free": false
2894 }),
2895 "EXACT shape comparison: no compose-config passthrough, no \
2896 unknown keys — the allowlist IS the contract"
2897 );
2898 }
2899
2900 #[tokio::test]
2903 async fn status_down_rig_is_data() {
2904 let runner = FakeRunner::with(vec![version_ok(), ok(""), ok(""), ok(""), ok("")]);
2905 let result = rig_status(&runner, &gw_plan())
2906 .await
2907 .expect("status of a down rig exits 0");
2908 assert!(result.services.is_empty());
2909 assert!(result.volumes.is_empty());
2910 assert!(result.ports_free);
2911 }
2912
2913 #[test]
2916 fn up_and_down_results_carry_all_keys() {
2917 let up = RigUpResult {
2918 rig: "r".into(),
2919 project: "r".into(),
2920 state: "uncommissioned".into(),
2921 gateway_url: None,
2922 warnings: vec![],
2923 };
2924 let json = serde_json::to_value(&up).unwrap();
2925 for key in ["rig", "project", "state", "gateway_url", "warnings"] {
2926 assert!(json.get(key).is_some(), "missing key {key}");
2927 }
2928 let down = RigDownResult {
2929 rig: "r".into(),
2930 project: "r".into(),
2931 state: "down".into(),
2932 };
2933 let json = serde_json::to_value(&down).unwrap();
2934 for key in ["rig", "project", "state"] {
2935 assert!(json.get(key).is_some(), "missing key {key}");
2936 }
2937 let reset = RigResetResult {
2938 rig: "r".into(),
2939 project: "r".into(),
2940 removed_volumes: vec![],
2941 state: "running".into(),
2942 warnings: vec![],
2943 };
2944 let json = serde_json::to_value(&reset).unwrap();
2945 for key in ["rig", "project", "removed_volumes", "state", "warnings"] {
2946 assert!(json.get(key).is_some(), "missing key {key}");
2947 }
2948 let status_keys = [
2949 "rig",
2950 "project",
2951 "compose_file",
2952 "services",
2953 "volumes",
2954 "ports_free",
2955 ];
2956 let _ = RigStatusResult {
2957 rig: "r".into(),
2958 project: "r".into(),
2959 compose_file: "/c.yml".into(),
2960 services: vec![],
2961 volumes: vec![],
2962 ports_free: true,
2963 };
2964 assert_eq!(status_keys.len(), 6);
2967 }
2968}