1use serde::Serialize;
22
23pub const TUI_TTY_REFUSAL_REASON: &str = "ign tui requires a terminal (stdout is not a TTY)";
31
32pub const TOP_TTY_REFUSAL_REASON: &str = "ign top requires a terminal (stdout is not a TTY)";
39
40pub const LOSS_GATE_REFUSAL_REASON_PREFIX: &str = "loss report (";
51
52pub const TESTING_ROUTE_PREFIX: &str = "testing/";
62
63pub const GATEWAY_CLIENT_BODY_CAP_BYTES: usize = 4096;
70
71pub const GATEWAY_CLIENT_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
76
77pub fn truncate_api_body(body: &str) -> String {
83 if body.len() <= GATEWAY_CLIENT_BODY_CAP_BYTES {
84 return body.to_string();
85 }
86 let mut end = GATEWAY_CLIENT_BODY_CAP_BYTES;
87 while !body.is_char_boundary(end) {
88 end -= 1;
89 }
90 let mut truncated = body[..end].to_string();
91 truncated.push_str(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER);
92 truncated
93}
94
95#[derive(Debug, thiserror::Error)]
98pub enum CoreError {
99 #[error("internal error: {0}")]
101 Internal(String),
102
103 #[error("{operation} is destructive; rerun with --yes to confirm")]
107 ConfirmationRequired { operation: String },
108
109 #[error(
117 "{operation} actively instruments the gateway JVM; rerun with --i-accept-the-risk to acknowledge the production risk"
118 )]
119 ProfilingRiskNotAccepted { operation: String },
120
121 #[error("invalid import file: {reason}")]
125 InvalidImportFile { reason: String },
126
127 #[error("gateway rejected the project import for {project:?}: {problem}")]
135 ImportDenied {
136 project: String,
138 problem: String,
140 endpoint: Option<String>,
142 },
143
144 #[error("invalid input: {reason}")]
149 InvalidInput { reason: String },
150
151 #[error("profile {name:?} not found (known profiles: {known:?})")]
153 ProfileNotFound { name: String, known: Vec<String> },
154
155 #[error("no active profile configured")]
159 NoActiveProfile,
160
161 #[error("secret unavailable for profile {profile:?}")]
164 SecretUnavailable { profile: String },
165
166 #[error("login password unavailable for profile {profile:?}")]
172 PasswordUnavailable { profile: String },
173
174 #[error("invalid configuration: {reason}")]
176 ConfigInvalid { reason: String },
177
178 #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
185 PollIntervalTooSmall {
186 profile: String,
188 },
189
190 #[error(
202 "{lead} at {url}{source_note}{observation_note}",
203 lead = if observation.is_some() {
204 "no terminal state"
205 } else {
206 "gateway unreachable"
207 },
208 source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
209 observation_note = observation
210 .as_deref()
211 .map(|obs| format!("; last observation: {obs}"))
212 .unwrap_or_default()
213 )]
214 Network {
215 url: String,
216 #[source]
217 source: Option<reqwest::Error>,
218 observation: Option<String>,
223 },
224
225 #[error("gateway rejected credentials (HTTP {status})")]
227 Auth {
228 status: u16,
229 endpoint: Option<String>,
231 },
232
233 #[error("gateway version {found} is below minimum {minimum}")]
237 GatewayTooOld {
238 found: String,
239 minimum: String,
240 endpoint: Option<String>,
242 },
243
244 #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
248 GatewayNotCommissioned {
249 endpoint: Option<String>,
251 },
252
253 #[error("gateway is restarting (webserver up, services down)")]
257 GatewayRestarting {
258 endpoint: Option<String>,
260 },
261
262 #[error("resource not found on the gateway")]
266 NotFound {
267 endpoint: Option<String>,
269 },
270
271 #[error("project {name} already exists on the gateway")]
278 ProjectExists {
279 name: String,
281 endpoint: Option<String>,
283 },
284
285 #[error("resource {path:?} has binary content — not editable via the resource loop")]
291 ResourceBinary {
292 path: String,
294 endpoint: Option<String>,
296 },
297
298 #[error(
307 "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
308 )]
309 TrialNotExpired {
310 remaining_s: i64,
312 endpoint: Option<String>,
314 },
315
316 #[error(
324 "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
325 )]
326 RoutesNotDeployed {
327 project: String,
329 route: String,
331 endpoint: Option<String>,
333 },
334
335 #[error(
340 "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
341 )]
342 WebdevUnlicensed {
343 endpoint: Option<String>,
345 },
346
347 #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
352 RouteVersionMismatch {
353 route: String,
355 deployed: String,
357 expected: String,
360 endpoint: Option<String>,
362 },
363
364 #[error("tag provider {name:?} not found on the gateway")]
371 ProviderNotFound {
372 name: String,
374 endpoint: Option<String>,
376 },
377
378 #[error("webdev route denied the call ({code}): {message}")]
384 WebdevRouteError {
385 code: String,
387 message: String,
389 endpoint: Option<String>,
391 },
392
393 #[error(
400 "tag collision importing into provider {provider:?}: {} already exist(s)",
401 names.join(", ")
402 )]
403 TagCollision {
404 provider: String,
406 names: Vec<String>,
408 endpoint: Option<String>,
410 },
411
412 #[error(
423 "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
424 )]
425 AlarmJournalMissing {
426 endpoint: Option<String>,
428 },
429
430 #[error("designer session {id} is live — the gateway refused the prune")]
439 SessionNotPrunable {
440 id: String,
442 endpoint: Option<String>,
444 },
445
446 #[error("rig error: {0}")]
449 Rig(String),
450
451 #[error(
461 "EAM is not configured as a controller on this gateway — every EAM runtime \
462 operation refuses until the module's installMode is flipped"
463 )]
464 EamNotController {
465 endpoint: Option<String>,
467 },
468
469 #[error(
480 "provider-root tag paths are not supported by the deployed route (the gateway needs an \
481 RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
482 )]
483 ProviderRootUnsupported {
484 endpoint: Option<String>,
486 },
487
488 #[error(
496 "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
497 are EXT-03 (v2) scope; run them from the EAM console)"
498 )]
499 EamTaskTypeRefused {
500 task_type: String,
502 },
503
504 #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
513 EamTaskInFlight {
514 task: String,
516 detail: String,
519 endpoint: Option<String>,
521 },
522
523 #[error(
533 "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
534 only via the explicit opt-in"
535 )]
536 ScriptExecNotConfigured {
537 profile: String,
539 },
540
541 #[error("ignition-lint is not installed (no executable found on PATH)")]
547 LintToolAbsent,
548
549 #[error("{tool} is not installed (no executable found on PATH)")]
559 NodeToolAbsent {
560 tool: String,
562 },
563
564 #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
576 GatewayClientError {
577 status: u16,
579 endpoint: String,
581 body: String,
584 },
585
586 #[error(
596 "no diagnostics bundle available (gateway reports state {state:?}) — run \
597 `ign diagnostics bundle generate` first; polling cannot change this state"
598 )]
599 BundleNotAvailable {
600 state: String,
603 },
604
605 #[error(
614 "module release feed at {url} is unreachable{source_note}",
615 source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default()
616 )]
617 ModuleFeedUnreachable {
618 url: String,
620 #[source]
621 source: Option<reqwest::Error>,
622 },
623
624 #[error("module {module:?} version {version:?} not found at {url}: {detail}")]
631 ModuleReleaseNotFound {
632 module: String,
634 version: String,
636 url: String,
638 detail: String,
641 },
642
643 #[error("{0}")]
656 ModuleDigestMismatch(Box<ModuleDigestMismatchDetails>),
657
658 #[error("module release feed at {url} is unusable: {detail}")]
666 ModuleFeedUnusable {
667 url: String,
669 detail: String,
671 },
672
673 #[error("{0}")]
687 ModuleDigestChanged(Box<ModuleDigestChangedDetails>),
688
689 #[error("module {id:?} is not registered (known modules: {known:?})")]
699 ModuleNotRegistered {
700 id: String,
702 known: Vec<String>,
704 },
705
706 #[error(
719 "gateway refused to uninstall module {module_id:?} ({gateway_module_id}): {}",
720 failed.join(", ")
721 )]
722 ModuleUninstallDenied {
723 module_id: String,
725 gateway_module_id: String,
728 failed: Vec<String>,
731 endpoint: Option<String>,
733 },
734
735 #[error(
742 "the {kind} scan-lock is held by {}",
743 actor.as_deref().unwrap_or("another actor")
744 )]
745 ScanLockHeld {
746 actor: Option<String>,
748 kind: String,
750 endpoint: Option<String>,
752 },
753
754 #[error("gateway refused the write to resource {name:?}: {reason} signature")]
761 ResourceSignatureRejected {
762 name: String,
764 reason: SignatureReason,
766 endpoint: Option<String>,
768 },
769
770 #[error("resource {name:?} already exists")]
776 ResourceExists {
777 name: String,
779 endpoint: Option<String>,
781 },
782
783 #[error("deployment mode {name:?} is already defined")]
787 ModeExists {
788 name: String,
790 endpoint: Option<String>,
792 },
793
794 #[error("deploy tree invalid: {0}")]
801 DeployTreeInvalid(Box<DeployTreeInvalidDetails>),
802
803 #[error("refusing to render an embedded or plaintext secret: {0}")]
807 EmbeddedSecret(Box<EmbeddedSecretDetails>),
808
809 #[error("transport failed ({operation}) on {node}: {detail}")]
817 TransportFailed {
818 operation: String,
820 node: String,
822 detail: String,
824 },
825
826 #[error("declared-names ledger {location} is unusable: {problem}")]
834 DeployLedgerInvalid {
835 location: String,
837 problem: String,
839 },
840
841 #[error("plan changed since review: expected plan_sha256 {expected}, now {actual} ({detail})")]
846 PlanStale {
847 expected: String,
849 actual: String,
851 detail: String,
853 },
854
855 #[error("{}", destructive_change_refused_text(.gate, .keys, *.count))]
860 DestructiveChangeRefused {
861 gate: String,
863 keys: Vec<String>,
865 count: usize,
867 },
868
869 #[error("{}", deploy_blocked_text(.keys))]
873 DeployBlocked {
874 keys: Vec<String>,
876 },
877
878 #[error("{}", deploy_unreadable_text(.node, .keys))]
887 DeployUnreadable {
888 node: String,
890 keys: Vec<String>,
893 },
894
895 #[error("{}", deploy_preflight_failed_text(.failures))]
899 DeployPreflightFailed {
900 failures: Box<[PreflightFailure]>,
902 },
903
904 #[error(
907 "an interrupted deploy left the journal {location} for run {run_id}; \
908 recover it with: ign deploy rollback --run {run_id} --env {env}"
909 )]
910 DeployJournalPresent {
911 run_id: String,
913 env: String,
915 location: String,
917 },
918
919 #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
923 DeployLiveChanged {
924 run_id: String,
926 paths: Vec<String>,
928 count: usize,
930 },
931
932 #[error("deploy run {run_id} is unusable: {problem}")]
935 DeployRunInvalid {
936 run_id: String,
938 problem: String,
940 },
941
942 #[error("{0}")]
945 RollbackFailed(Box<RollbackFailure>),
946
947 #[error(
955 "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
956 )]
957 ProjectGitManaged {
958 project: String,
960 location: String,
962 kind: String,
964 },
965
966 #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
973 ProjectParentInvalid {
974 project: String,
976 parent: String,
978 reason: String,
980 },
981
982 #[error(
990 "artifact hash mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded"
991 )]
992 ProjectHashMismatch {
993 project: String,
995 expected: String,
997 actual: String,
999 },
1000}
1001
1002pub const APPLY_LIST_SHOWN: usize = 10;
1005
1006#[derive(Debug, Clone, PartialEq, Eq)]
1009pub struct PreflightFailure {
1010 pub check: String,
1012 pub detail: String,
1014}
1015
1016#[derive(Debug, Clone, PartialEq, Eq)]
1018pub struct RollbackFailure {
1019 pub run_id: String,
1021 pub env: String,
1023 pub run_dir: Option<String>,
1025 pub journal: String,
1027 pub cause: String,
1029}
1030
1031impl RollbackFailure {
1032 pub fn snapshot_dir(&self) -> String {
1035 match self.journal.rsplit_once('/') {
1036 Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1037 None => format!("snapshots/{}/", self.run_id),
1038 }
1039 }
1040}
1041
1042impl std::fmt::Display for RollbackFailure {
1043 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1044 write!(
1049 f,
1050 "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1051 Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1052 and the snapshot are still on the host). If the retry reports a lost or damaged \
1053 snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1054 the on-box snapshot {snapshot}, then delete the journal by hand. \
1055 run directory: {run_dir}",
1056 run = self.run_id,
1057 cause = self.cause,
1058 env = self.env,
1059 journal = self.journal,
1060 snapshot = self.snapshot_dir(),
1061 run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1062 )
1063 }
1064}
1065
1066fn listed(shown: &[String], total: usize) -> String {
1069 let take = shown.len().min(APPLY_LIST_SHOWN);
1070 let mut out = shown[..take].join(", ");
1071 if total > take {
1072 out.push_str(&format!(" and {} more", total - take));
1073 }
1074 out
1075}
1076
1077fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1078 let (flag, verb) = match gate {
1079 "drift" => ("--allow-drift <reason>", "apply"),
1080 "downgrade" => ("--allow-downgrade", "deploy projects"),
1081 "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1082 _ => ("--allow-destructive", "apply"),
1083 };
1084 format!(
1085 "{gate} change refused for {count} entr{}: {}; {verb} needs {flag}",
1086 if count == 1 { "y" } else { "ies" },
1087 listed(keys, count)
1088 )
1089}
1090
1091fn deploy_blocked_text(keys: &[String]) -> String {
1092 format!(
1093 "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1094 shadows them, so deleting them from the mode collection cannot take effect: {}",
1095 keys.len(),
1096 listed(keys, keys.len())
1097 )
1098}
1099
1100fn deploy_unreadable_text(node: &str, keys: &[String]) -> String {
1101 format!(
1102 "deploy refused: {} unit(s) the deploy would write or delete have a live path the \
1103 {node} host's sftp server cannot read, so their live copy is unknown: {}; nothing was \
1104 written",
1105 keys.len(),
1106 listed(keys, keys.len())
1107 )
1108}
1109
1110fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1111 let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1112 for failure in failures {
1113 out.push_str(&format!("\n {}: {}", failure.check, failure.detail));
1114 }
1115 out
1116}
1117
1118fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1119 format!(
1120 "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1121 snapshot would overwrite them",
1122 listed(paths, count)
1123 )
1124}
1125
1126pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1132pub struct DeployFinding {
1133 pub file: String,
1135 pub line: Option<usize>,
1137 pub column: Option<usize>,
1139 pub message: String,
1141}
1142
1143impl std::fmt::Display for DeployFinding {
1144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1145 match (self.line, self.column) {
1146 (Some(line), Some(column)) => {
1147 write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1148 }
1149 _ => write!(f, "{}: {}", self.file, self.message),
1150 }
1151 }
1152}
1153
1154#[derive(Debug, Clone, PartialEq, Eq)]
1157pub struct DeployTreeInvalidDetails {
1158 pub findings: Vec<DeployFinding>,
1160}
1161
1162impl std::fmt::Display for DeployTreeInvalidDetails {
1163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1164 if let [only] = self.findings.as_slice() {
1165 return write!(f, "{only}");
1166 }
1167 write!(f, "{} findings: ", self.findings.len())?;
1168 write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1169 }
1170}
1171
1172fn write_findings(
1175 f: &mut std::fmt::Formatter<'_>,
1176 findings: &[DeployFinding],
1177 shown: usize,
1178) -> std::fmt::Result {
1179 for (i, finding) in findings.iter().take(shown).enumerate() {
1180 if i > 0 {
1181 f.write_str("; ")?;
1182 }
1183 write!(f, "{finding}")?;
1184 }
1185 if findings.len() > shown {
1186 write!(f, "; and {} more", findings.len() - shown)?;
1187 }
1188 Ok(())
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq)]
1195pub struct EmbeddedSecretDetails {
1196 pub paths: Vec<String>,
1198 pub other_findings: Vec<DeployFinding>,
1200}
1201
1202impl std::fmt::Display for EmbeddedSecretDetails {
1203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1204 write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1205 let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1206 f.write_str(&self.paths[..shown_paths].join("; "))?;
1207 if self.paths.len() > shown_paths {
1208 write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1209 }
1210 if !self.other_findings.is_empty() {
1211 write!(
1212 f,
1213 "; also {} other tree finding(s): ",
1214 self.other_findings.len()
1215 )?;
1216 let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1218 write_findings(f, &self.other_findings, room)?;
1219 }
1220 Ok(())
1221 }
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1227pub enum SignatureReason {
1228 Stale,
1231 Missing,
1235}
1236
1237impl std::fmt::Display for SignatureReason {
1238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1239 f.write_str(match self {
1240 Self::Stale => "stale",
1241 Self::Missing => "missing",
1242 })
1243 }
1244}
1245
1246#[derive(Debug, Clone, PartialEq, Eq)]
1249pub struct ModuleDigestMismatchDetails {
1250 pub module: String,
1252 pub version: String,
1254 pub url: String,
1256 pub expected: String,
1258 pub actual: String,
1260}
1261
1262#[derive(Debug, Clone, PartialEq, Eq)]
1265pub struct ModuleDigestChangedDetails {
1266 pub module: String,
1268 pub version: String,
1270 pub cached_digest: String,
1272 pub upstream_digest: String,
1274 pub cached_path: String,
1276}
1277
1278impl std::fmt::Display for ModuleDigestMismatchDetails {
1279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1280 write!(
1281 f,
1282 "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1283 self.module, self.version, self.url, self.expected, self.actual
1284 )
1285 }
1286}
1287
1288impl std::fmt::Display for ModuleDigestChangedDetails {
1289 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1290 write!(
1291 f,
1292 "module {:?} version {:?} digest changed upstream: cached {}, \
1293 upstream now reports {} (cached artifact kept at {})",
1294 self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1295 )
1296 }
1297}
1298
1299impl CoreError {
1300 pub fn code(&self) -> &'static str {
1302 match self {
1303 Self::Internal(_) => "internal",
1304 Self::ConfirmationRequired { .. } => "confirmation_required",
1305 Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1306 Self::InvalidImportFile { .. } => "invalid_import_file",
1307 Self::ImportDenied { .. } => "import_denied",
1308 Self::InvalidInput { .. } => "invalid_input",
1309 Self::ProfileNotFound { .. } => "profile_not_found",
1310 Self::NoActiveProfile => "no_active_profile",
1311 Self::SecretUnavailable { .. } => "secret_unavailable",
1312 Self::PasswordUnavailable { .. } => "password_unavailable",
1313 Self::ConfigInvalid { .. } => "config_invalid",
1314 Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1315 Self::Network { .. } => "network_error",
1316 Self::Auth { .. } => "auth_rejected",
1317 Self::GatewayTooOld { .. } => "gateway_too_old",
1318 Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1319 Self::GatewayRestarting { .. } => "gateway_restarting",
1320 Self::NotFound { .. } => "not_found",
1321 Self::ProjectExists { .. } => "project_exists",
1322 Self::ResourceBinary { .. } => "resource_binary",
1323 Self::TrialNotExpired { .. } => "trial_not_expired",
1324 Self::ProviderNotFound { .. } => "provider_not_found",
1325 Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1326 Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1327 Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1328 Self::WebdevRouteError { .. } => "webdev_route_error",
1329 Self::TagCollision { .. } => "tag_collision",
1330 Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1331 Self::SessionNotPrunable { .. } => "session_not_prunable",
1332 Self::Rig(_) => "rig_error",
1333 Self::EamNotController { .. } => "eam_not_controller",
1334 Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1335 Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1336 Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1337 Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1338 Self::LintToolAbsent => "lint_tool_absent",
1339 Self::NodeToolAbsent { .. } => "node_tool_absent",
1340 Self::GatewayClientError { .. } => "gateway_client_error",
1341 Self::BundleNotAvailable { .. } => "bundle_not_available",
1342 Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1343 Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1344 Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1345 Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1346 Self::ModuleDigestChanged(..) => "module_digest_changed",
1347 Self::ModuleNotRegistered { .. } => "module_not_registered",
1348 Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1349 Self::ScanLockHeld { .. } => "scan_lock_held",
1350 Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1351 Self::ResourceExists { .. } => "resource_exists",
1352 Self::ModeExists { .. } => "mode_exists",
1353 Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1354 Self::EmbeddedSecret(..) => "embedded_secret",
1355 Self::TransportFailed { .. } => "transport_failed",
1356 Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1357 Self::PlanStale { .. } => "plan_stale",
1358 Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1359 Self::DeployBlocked { .. } => "deploy_blocked",
1360 Self::DeployUnreadable { .. } => "deploy_unreadable",
1361 Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1362 Self::DeployJournalPresent { .. } => "deploy_journal_present",
1363 Self::DeployLiveChanged { .. } => "deploy_live_changed",
1364 Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1365 Self::RollbackFailed(..) => "rollback_failed",
1366 Self::ProjectGitManaged { .. } => "project_git_managed",
1367 Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1368 Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1369 }
1370 }
1371
1372 pub fn exit_code(&self) -> u8 {
1374 match self {
1375 Self::Internal(_) => 1,
1376 Self::ConfirmationRequired { .. }
1377 | Self::ProfilingRiskNotAccepted { .. }
1378 | Self::InvalidImportFile { .. }
1379 | Self::InvalidInput { .. }
1380 | Self::GatewayClientError { .. }
1381 | Self::DeployTreeInvalid(..)
1382 | Self::EmbeddedSecret(..)
1383 | Self::DestructiveChangeRefused { .. } => 2,
1384 Self::ProfileNotFound { .. }
1385 | Self::NoActiveProfile
1386 | Self::SecretUnavailable { .. }
1387 | Self::PasswordUnavailable { .. }
1388 | Self::ConfigInvalid { .. }
1389 | Self::PollIntervalTooSmall { .. }
1390 | Self::ModuleNotRegistered { .. } => 3,
1391 Self::Network { .. }
1392 | Self::ModuleFeedUnreachable { .. }
1393 | Self::TransportFailed { .. } => 4,
1394 Self::Auth { .. } => 5,
1395 Self::GatewayTooOld { .. }
1396 | Self::GatewayNotCommissioned { .. }
1397 | Self::GatewayRestarting { .. }
1398 | Self::NotFound { .. }
1399 | Self::ProjectExists { .. }
1400 | Self::ResourceBinary { .. }
1401 | Self::TrialNotExpired { .. }
1402 | Self::ProviderNotFound { .. }
1403 | Self::RoutesNotDeployed { .. }
1404 | Self::WebdevUnlicensed { .. }
1405 | Self::RouteVersionMismatch { .. }
1406 | Self::WebdevRouteError { .. }
1407 | Self::TagCollision { .. }
1408 | Self::AlarmJournalMissing { .. }
1409 | Self::SessionNotPrunable { .. }
1410 | Self::ImportDenied { .. }
1411 | Self::EamNotController { .. }
1412 | Self::ProviderRootUnsupported { .. }
1413 | Self::EamTaskTypeRefused { .. }
1414 | Self::EamTaskInFlight { .. }
1415 | Self::ScriptExecNotConfigured { .. }
1416 | Self::LintToolAbsent
1417 | Self::NodeToolAbsent { .. }
1418 | Self::BundleNotAvailable { .. }
1419 | Self::ModuleReleaseNotFound { .. }
1420 | Self::ModuleDigestMismatch(..)
1421 | Self::ModuleFeedUnusable { .. }
1422 | Self::ModuleDigestChanged(..)
1423 | Self::ModuleUninstallDenied { .. }
1424 | Self::ScanLockHeld { .. }
1425 | Self::ResourceSignatureRejected { .. }
1426 | Self::ResourceExists { .. }
1427 | Self::ModeExists { .. }
1428 | Self::DeployLedgerInvalid { .. }
1429 | Self::PlanStale { .. }
1430 | Self::DeployBlocked { .. }
1431 | Self::DeployUnreadable { .. }
1432 | Self::DeployPreflightFailed { .. }
1433 | Self::DeployJournalPresent { .. }
1434 | Self::DeployLiveChanged { .. }
1435 | Self::DeployRunInvalid { .. }
1436 | Self::RollbackFailed(..)
1437 | Self::ProjectGitManaged { .. }
1438 | Self::ProjectParentInvalid { .. }
1439 | Self::ProjectHashMismatch { .. } => 6,
1440 Self::Rig(_) => 7,
1441 }
1442 }
1443
1444 pub fn hint(&self) -> Option<String> {
1446 match self {
1447 Self::Internal(_) => Some(
1448 "internal errors are bugs; re-run with -vv and report the \
1449 diagnostics output"
1450 .to_string(),
1451 ),
1452 Self::ConfirmationRequired { .. } => Some(
1453 "this operation is destructive; re-run with --yes or set \
1454 IGNITION_YES=1"
1455 .to_string(),
1456 ),
1457 Self::ProfilingRiskNotAccepted { .. } => Some(
1458 "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1459 adds CPU overhead proportional to thread count x frequency, \
1460 and flight recordings buffer in JVM memory and write to \
1461 gateway disk. On a PRODUCTION server this belongs inside an \
1462 approved change window with a rollback plan — never as a \
1463 casual probe. --yes deliberately does NOT satisfy this gate; \
1464 re-run with --i-accept-the-risk after confirming that"
1465 .to_string(),
1466 ),
1467 Self::InvalidImportFile { .. } => Some(
1468 "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1469 most 512 MB — pass a file produced by `ign project export` \
1470 via --file (or `-` to pipe one on stdin)"
1471 .to_string(),
1472 ),
1473 Self::ImportDenied { problem, .. } => Some(format!(
1474 "the gateway refused the import over a 200 answer — the problem \
1475 text above is the gateway's own; `ign project export` of the \
1476 current state is the honest baseline for hand-editing ({problem})"
1477 )),
1478 Self::InvalidInput { reason } => Some(
1479 if reason == TUI_TTY_REFUSAL_REASON {
1480 "run `ign tui` in an interactive terminal (the cockpit \
1484 needs a TTY on stdout — not a pipe or redirect)"
1485 } else if reason == TOP_TTY_REFUSAL_REASON {
1486 "run `ign top` in an interactive terminal (the monitor \
1490 needs a TTY on stdout — not a pipe or redirect)"
1491 } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1492 "the loss report above names what this import would drop or \
1497 coerce — re-run with --yes to import anyway"
1498 } else if reason.starts_with("--plan-sha256 ")
1499 || reason.starts_with("--allow-drift ")
1500 || reason.starts_with("--artifact-sha256 ")
1501 || reason.starts_with("--release ")
1502 || reason.starts_with("--allow-first-change ")
1503 || reason.starts_with("deploy projects needs --artifact-sha256")
1504 {
1505 "correct the flag value named above and rerun; no host or gateway \
1509 was contacted"
1510 } else {
1511 "fix the input source — a readable file path via --file, or `-` \
1512 to pipe the content on stdin"
1513 }
1514 .to_string(),
1515 ),
1516 Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1517 "no profiles configured yet; run `ign profile add` to create \
1518 one"
1519 .to_string()
1520 } else {
1521 format!(
1522 "known profiles: {}; run `ign profile add` to add another",
1523 known.join(", ")
1524 )
1525 }),
1526 Self::NoActiveProfile => Some(
1527 "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1528 active with `ign profile use`"
1529 .to_string(),
1530 ),
1531 Self::SecretUnavailable { profile } => Some(format!(
1532 "set IGNITION_TOKEN (or token_env in the profile), or store a \
1533 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1534 )),
1535 Self::PasswordUnavailable { .. } => Some(
1536 "ign adopt logs in with the gateway admin password — export \
1537 IGNITION_PASSWORD (env-only, never a flag)"
1538 .to_string(),
1539 ),
1540 Self::ConfigInvalid { .. } => Some(
1541 "verify the config file is valid TOML with [profiles.NAME] \
1542 tables; `ign profile add` writes a known-good one"
1543 .to_string(),
1544 ),
1545 Self::PollIntervalTooSmall { profile } => Some(format!(
1546 "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1547 or remove the key to use the default cadence"
1548 )),
1549 Self::Network {
1550 url,
1551 observation,
1552 ..
1553 } => Some(match observation {
1554 Some(observation) => format!(
1555 "the gateway answered but no terminal state arrived before the deadline — \
1556 the last observation was: {observation}; address that state, not the \
1557 connection ({url})"
1558 ),
1559 None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1560 }),
1561 Self::Auth { status, .. } => Some(match status {
1562 401 => {
1563 "token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"
1567 }
1568 403 => {
1569 "token recognized but under-permitted — Ignition token setup is three parts: (1) token holds an adequate security level, (2) gateway read/write permissions include that level, (3) 'Require secure connections' is unchecked for http gateways; run `ign doctor` for a diagnosis"
1572 }
1573 _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1574 }
1575 .to_string()),
1576 Self::GatewayTooOld { minimum, .. } => {
1577 Some(format!("upgrade the gateway to at least {minimum}"))
1578 }
1579 Self::GatewayNotCommissioned { .. } => Some(
1580 "open http://<host>:<port>/welcome in a browser and complete the \
1581 commissioning wizard"
1582 .to_string(),
1583 ),
1584 Self::GatewayRestarting { .. } => Some(
1585 "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1586 ),
1587 Self::NotFound { .. } => Some(
1588 "check the id/path; a 404 JSON 'No route match' body can also mean \
1589 a pre-8.3 gateway"
1590 .to_string(),
1591 ),
1592 Self::ProjectExists { .. } => Some(
1593 "the default collision policy refuses to overwrite; re-run with \
1594 --collision-policy overwrite to replace it — overwrite \
1595 REPLACES the ENTIRE project (resources absent from the ZIP \
1596 are deleted; merge is Designer-only)"
1597 .to_string(),
1598 ),
1599 Self::ResourceBinary { .. } => Some(
1600 "resource content is binary — use `ign project export`/`import` \
1601 for data.bin-class resources"
1602 .to_string(),
1603 ),
1604 Self::TrialNotExpired { .. } => Some(
1605 "wait for the trial to expire (watch `ign rig trial status`), or \
1606 run `ign rig reset --yes` for a completely fresh trial volume"
1607 .to_string(),
1608 ),
1609 Self::RoutesNotDeployed { route, .. } => Some(
1610 if route.starts_with(TESTING_ROUTE_PREFIX) {
1618 "run `ign webdev deploy --with-testing` to install the gateway \
1619 testing bundle into the project (or bootstrap it in one shot \
1620 with `ign adopt --project NAME --testing`), then retry"
1621 } else {
1622 "run `ign webdev deploy` to install the CLI's WebDev routes into \
1623 the gateway, then retry"
1624 }
1625 .to_string(),
1626 ),
1627 Self::WebdevUnlicensed { .. } => Some(
1628 "license the gateway — the WebDev module answers 402 while \
1629 unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1630 expired trial)"
1631 .to_string(),
1632 ),
1633 Self::RouteVersionMismatch { deployed, expected, .. } => {
1634 let newer = semver::Version::parse(deployed)
1639 .ok()
1640 .zip(semver::Version::parse(expected).ok())
1641 .is_some_and(|(deployed, expected)| deployed > expected);
1642 Some(if newer {
1643 "the deployed routes are NEWER than this CLI — update ign \
1644 (the route bundle travels with the binary)"
1645 .to_string()
1646 } else {
1647 "run `ign webdev deploy` to redeploy the route version \
1648 this CLI expects"
1649 .to_string()
1650 })
1651 }
1652 Self::ProviderNotFound { .. } => Some(
1653 "check the provider name; `ign tags provider list` shows the \
1654 gateway's tag providers"
1655 .to_string(),
1656 ),
1657 Self::TagCollision { .. } => Some(
1658 "re-run with --collision-policy overwrite to replace the \
1659 existing tags (destructive: requires --yes)"
1660 .to_string(),
1661 ),
1662 Self::AlarmJournalMissing { .. } => Some(
1663 "alarm history needs a journal profile — provision a database \
1664 connection + alarm-journal profile on the gateway (and point \
1665 the general-alarm-settings singleton at it), then retry; see \
1666 the README 'Alarm history' section"
1667 .to_string(),
1668 ),
1669 Self::SessionNotPrunable { .. } => Some(
1670 "close the Designer first — prune removes stale entries only".to_string(),
1671 ),
1672 Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1673 "the scriptExec route is secret-gated — deploy it with `ign \
1674 webdev deploy --with-script-exec` (the secret is generated \
1675 and stored in the profile config at 0600); a mismatch means \
1676 the route was deployed with a different secret: redeploy or \
1677 pass --rotate-secret"
1678 .to_string()
1679 } else {
1680 "the deployed route refused the action — the code and message \
1681 are the route's stable contract; `ign webdev status` \
1682 diagnoses the deployment"
1683 .to_string()
1684 }),
1685 Self::EamNotController { .. } => Some(
1686 "flip the gateway's EAM role: config-resource PUT on \
1687 com.inductiveautomation.eam/module-settings with \
1688 installMode \"Controller\" (array body carrying the current \
1689 signature) — a manual gateway-role decision this CLI \
1690 deliberately does not automate; see the README 'EAM tasks' \
1691 section"
1692 .to_string(),
1693 ),
1694 Self::ProviderRootUnsupported { .. } => Some(
1695 "target a subtree path like [provider]folder — provider-ROOT \
1696 forms ([default] alone, or a bare provider name) need an \
1697 RPC context WebDev threads don't carry (8.3.3); subtree \
1698 paths are the supported form"
1699 .to_string(),
1700 ),
1701 Self::EamTaskTypeRefused { .. } => Some(
1702 "restore/install/upgrade tasks dispatch fleet-wide (every \
1703 agent target); run them from the Ignition EAM console — \
1704 EXT-03 (v2) will scope them into the CLI"
1705 .to_string(),
1706 ),
1707 Self::EamTaskInFlight { .. } => Some(
1708 "complete or delete the leftover '(forced)' run from the EAM \
1709 console — no ign verb deletes runs; the slot frees once the \
1710 run is resolved"
1711 .to_string(),
1712 ),
1713 Self::ScriptExecNotConfigured { .. } => Some(
1714 "run `ign webdev deploy --with-script-exec` to deploy the route and \
1715 generate + persist its secret (the deploy flag IS the opt-in — \
1716 `ign script run` has no --yes by design)"
1717 .to_string(),
1718 ),
1719 Self::LintToolAbsent => Some(
1720 "install the linter: `uv tool install ignition-lint-toolkit` \
1721 (or `pip install ignition-lint-toolkit`) — \
1722 github.com/TheThoughtagen/ignition-lint; then re-run with \
1723 ignition-lint on PATH"
1724 .to_string(),
1725 ),
1726 Self::NodeToolAbsent { .. } => {
1730 Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1731 }
1732 Self::GatewayClientError { .. } => Some(
1733 "the gateway rejected this request — the body above is the \
1734 gateway's own answer; fix the path/method/body, or use a \
1735 curated `ign` command when one exists"
1736 .to_string(),
1737 ),
1738 Self::BundleNotAvailable { .. } => Some(
1739 "generate a fresh bundle with `ign diagnostics bundle generate`, \
1740 then wait again — the gateway reports no current bundle and \
1741 polling cannot produce one"
1742 .to_string(),
1743 ),
1744 Self::ModuleFeedUnreachable { .. } => Some(
1745 "check network reachability to the module release feed — an \
1746 already-cached version works fully offline, so this only \
1747 blocks a first-time fetch of a new version"
1748 .to_string(),
1749 ),
1750 Self::ModuleReleaseNotFound {
1751 module, version, ..
1752 } => Some(format!(
1753 "check that {module} version {version} exists as a release upstream \
1754 (the tag and asset name must match exactly) — this CLI never falls \
1755 back to \"latest\""
1756 )),
1757 Self::ModuleDigestMismatch(d) => Some(format!(
1758 "the downloaded bytes for {} version {} do not match \
1759 the feed's published digest — do not trust this artifact; nothing \
1760 was cached, so re-running the fetch tries again from scratch",
1761 d.module,
1762 d.version
1763 )),
1764 Self::ModuleFeedUnusable { .. } => Some(
1765 "the module release feed answered but was not usable — if the \
1766 detail names a rate limit, wait and retry; otherwise the feed's \
1767 response itself needs investigation"
1768 .to_string(),
1769 ),
1770 Self::ModuleDigestChanged(d) => Some(format!(
1771 "upstream re-released this version with different bytes — the cached \
1772 artifact at {} is still there and usable offline; re-run with \
1773 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1774 new bytes",
1775 d.cached_path
1776 )),
1777 Self::Rig(_) => Some(
1778 "check Docker is running and inspect the rig containers \
1779 (docker ps)"
1780 .to_string(),
1781 ),
1782 Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1783 "no modules are registered in this build of ign".to_string()
1784 } else {
1785 format!(
1786 "known module ids: {} — check the id passed to --with-module \
1787 or declared under [rigs.NAME.modules.*]",
1788 known.join(", ")
1789 )
1790 }),
1791 Self::ModuleUninstallDenied { .. } => Some(
1792 "the gateway refuses to uninstall a module whose .modl is still \
1793 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1794 so the mount goes, then retry; a successful uninstall is NOT \
1795 undone by re-declaring the module — recovery today means \
1796 `ign rig reset --yes`, which destroys the rig's data volume"
1797 .to_string(),
1798 ),
1799 Self::ScanLockHeld { .. } => Some(
1800 "the scan-lock is released when the holder's scan runs or its \
1801 holdTimeout expires — wait for that, then retry"
1802 .to_string(),
1803 ),
1804 Self::ResourceSignatureRejected { .. } => Some(
1805 "re-read the resource for its current signature and retry — \
1806 signatures change on every write"
1807 .to_string(),
1808 ),
1809 Self::ResourceExists { .. } => Some(
1810 "a resource of that name is already present — read it for its \
1811 current signature and update it, or choose another name"
1812 .to_string(),
1813 ),
1814 Self::ModeExists { .. } => Some(
1815 "that deployment mode is already defined — list the modes and \
1816 skip the create"
1817 .to_string(),
1818 ),
1819 Self::DeployTreeInvalid(..) => Some(
1820 "fix each file named above in the declared gateway tree; render \
1821 refuses until every finding is resolved"
1822 .to_string(),
1823 ),
1824 Self::EmbeddedSecret(..) => Some(
1825 "store the value in the gateway's secret provider and reference it \
1826 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
1827 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
1828 .to_string(),
1829 ),
1830 Self::TransportFailed { .. } => Some(
1831 "check the node's host access in targets/<env>.yaml: for `local`, that \
1832 data_dir exists and is readable; for `ssh`, the pinned host key, the \
1833 key and reachability. Nothing was read as absent, so no plan was made"
1834 .to_string(),
1835 ),
1836 Self::DeployLedgerInvalid { .. } => Some(
1837 "the delete set is never assumed empty, so no plan was made; inspect or \
1838 restore the ledger file named above (only an apply writes it) and rerun"
1839 .to_string(),
1840 ),
1841 Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
1842 "the live project changed after the plan was drawn; run the check again, \
1843 review what it reports and rerun the deploy, nothing was imported"
1844 .to_string(),
1845 ),
1846 Self::PlanStale { .. } => Some(
1847 "the plan changed after it was reviewed; review the new plan and rerun \
1848 with its plan_sha256, nothing was written"
1849 .to_string(),
1850 ),
1851 Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
1852 "drift" => "live files differ from both the base render and the declared tree; \
1853 review the drift, then rerun with --allow-drift and a reason of at most \
1854 500 characters"
1855 .to_string(),
1856 "downgrade" => "the gateway last received a newer release of this project; \
1857 deploy the newer release, or rerun with --allow-downgrade when the older \
1858 release must replace it"
1859 .to_string(),
1860 "first_deploy" => "this is the first deploy of the project by this tool and it \
1861 would change what the gateway runs; release what the gateway runs first, \
1862 or rerun with --allow-first-change and a reason"
1863 .to_string(),
1864 _ => "review the destructive entries named above, then rerun with \
1865 --allow-destructive and the plan_sha256 you reviewed"
1866 .to_string(),
1867 }),
1868 Self::DeployBlocked { .. } => Some(
1869 "a core copy still shadows each unit named above, so no flag permits the \
1870 removal; keep the unit in the declared tree, or remove the core copy on \
1871 the gateway first"
1872 .to_string(),
1873 ),
1874 Self::DeployUnreadable { .. } => Some(
1875 "the live path of each unit named above is too long for the host's sftp \
1876 server; shorten it on the gateway (rename the folder), or, when the gateway \
1877 owns the provider, list the provider in exclude_tag_providers and stop \
1878 declaring it; nothing was written"
1879 .to_string(),
1880 ),
1881 Self::DeployPreflightFailed { .. } => Some(
1882 "fix each failed check named above and rerun; no gateway file was written"
1883 .to_string(),
1884 ),
1885 Self::DeployJournalPresent { .. } => Some(
1886 "an earlier deploy was interrupted and its journal is still on the box; run \
1887 the recovery command in the message, then rerun; plan and apply refuse \
1888 until the journal is cleared"
1889 .to_string(),
1890 ),
1891 Self::DeployLiveChanged { .. } => Some(
1892 "someone changed the files named above after the run wrote them; review \
1893 those changes, then rerun the rollback with --discard-live-changes to \
1894 restore the snapshot over them"
1895 .to_string(),
1896 ),
1897 Self::DeployRunInvalid { .. } => Some(
1898 "the run directory or the on-box record is missing or damaged; restore the \
1899 files from the .gwbk backup or the run-directory snapshot instead"
1900 .to_string(),
1901 ),
1902 Self::RollbackFailed(..) => Some(
1903 "do not apply again until the files are restored: run the retry command in \
1904 the message first, because the journal and the snapshot are usually intact; \
1905 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
1906 or the on-box snapshot named there, and then delete the journal at the path \
1907 in the message by hand"
1908 .to_string(),
1909 ),
1910 Self::ProjectGitManaged { .. } => Some(
1911 "follow the README cutover runbook: remove this project's .git on the \
1912 gateway (the deploy tool never deletes it), then run the check again"
1913 .to_string(),
1914 ),
1915 Self::ProjectParentInvalid { reason, .. } => Some(
1916 match reason.as_str() {
1917 "missing" => "deploy the parent project from its own repo first, then run \
1918 the check again",
1919 "cycle" => "fix the parent in project.json so the inheritance has no cycle",
1920 _ => "the parent is not inheritable; fix the parent in project.json or \
1921 make the parent inheritable",
1922 }
1923 .to_string(),
1924 ),
1925 Self::ProjectHashMismatch { .. } => Some(
1926 "the checkout is not the reviewed one; rerun the check on this commit and \
1927 pass its artifact_sha256"
1928 .to_string(),
1929 ),
1930 }
1931 }
1932
1933 pub fn endpoint(&self) -> Option<String> {
1936 match self {
1937 Self::Network { url, .. } => Some(url.clone()),
1938 Self::Auth { endpoint, .. } => endpoint.clone(),
1939 Self::GatewayTooOld { endpoint, .. }
1940 | Self::GatewayNotCommissioned { endpoint }
1941 | Self::GatewayRestarting { endpoint }
1942 | Self::NotFound { endpoint }
1943 | Self::ProjectExists { endpoint, .. }
1944 | Self::ResourceBinary { endpoint, .. }
1945 | Self::TrialNotExpired { endpoint, .. }
1946 | Self::ProviderNotFound { endpoint, .. }
1947 | Self::RoutesNotDeployed { endpoint, .. }
1948 | Self::WebdevUnlicensed { endpoint }
1949 | Self::RouteVersionMismatch { endpoint, .. }
1950 | Self::WebdevRouteError { endpoint, .. }
1951 | Self::TagCollision { endpoint, .. }
1952 | Self::AlarmJournalMissing { endpoint }
1953 | Self::SessionNotPrunable { endpoint, .. }
1954 | Self::ImportDenied { endpoint, .. }
1955 | Self::EamNotController { endpoint }
1956 | Self::ProviderRootUnsupported { endpoint }
1957 | Self::EamTaskInFlight { endpoint, .. }
1958 | Self::ModuleUninstallDenied { endpoint, .. }
1959 | Self::ScanLockHeld { endpoint, .. }
1960 | Self::ResourceSignatureRejected { endpoint, .. }
1961 | Self::ResourceExists { endpoint, .. }
1962 | Self::ModeExists { endpoint, .. } => endpoint.clone(),
1963 Self::ModuleFeedUnreachable { url, .. }
1964 | Self::ModuleReleaseNotFound { url, .. }
1965 | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
1966 Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
1968 _ => None,
1969 }
1970 }
1971
1972 pub const DEPLOY_SLUGS: &'static [&'static str] = &[
1974 "deploy_tree_invalid",
1975 "embedded_secret",
1976 "transport_failed",
1977 "deploy_ledger_invalid",
1978 "plan_stale",
1979 "destructive_change_refused",
1980 "deploy_blocked",
1981 "deploy_preflight_failed",
1982 "deploy_journal_present",
1983 "deploy_live_changed",
1984 "deploy_run_invalid",
1985 "rollback_failed",
1986 "project_git_managed",
1987 "project_parent_invalid",
1988 "project_hash_mismatch",
1989 "deploy_unreadable",
1990 ];
1991
1992 pub fn deploy_tree_invalid(
1995 file: impl Into<String>,
1996 line: Option<usize>,
1997 column: Option<usize>,
1998 message: impl Into<String>,
1999 ) -> Self {
2000 Self::deploy_findings(vec![DeployFinding {
2001 file: file.into(),
2002 line,
2003 column,
2004 message: message.into(),
2005 }])
2006 }
2007
2008 pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
2012 debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
2013 let findings = if findings.is_empty() {
2014 vec![DeployFinding {
2015 file: String::new(),
2016 line: None,
2017 column: None,
2018 message: "the declared tree is invalid".to_string(),
2019 }]
2020 } else {
2021 findings
2022 };
2023 Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
2024 }
2025
2026 pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
2030 Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
2031 paths,
2032 other_findings,
2033 }))
2034 }
2035
2036 pub fn tui_tty_refusal() -> Self {
2041 Self::InvalidInput {
2042 reason: TUI_TTY_REFUSAL_REASON.to_string(),
2043 }
2044 }
2045
2046 pub fn top_tty_refusal() -> Self {
2050 Self::InvalidInput {
2051 reason: TOP_TTY_REFUSAL_REASON.to_string(),
2052 }
2053 }
2054
2055 pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2059 ErrorEnvelope {
2060 ok: false,
2061 profile,
2062 error: ErrorBody {
2063 code: self.code(),
2064 message: self.to_string(),
2065 endpoint: self.endpoint(),
2066 hint: self.hint(),
2067 },
2068 }
2069 }
2070}
2071
2072#[derive(Debug, Serialize)]
2075pub struct ErrorEnvelope<'a> {
2076 pub ok: bool,
2078 pub profile: Option<&'a str>,
2081 pub error: ErrorBody,
2083}
2084
2085#[derive(Debug, Serialize)]
2088pub struct ErrorBody {
2089 pub code: &'static str,
2091 pub message: String,
2093 pub endpoint: Option<String>,
2095 pub hint: Option<String>,
2097}
2098
2099#[cfg(test)]
2100mod tests {
2101 use super::{
2102 CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2103 GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2104 ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2105 SignatureReason, TESTING_ROUTE_PREFIX, truncate_api_body,
2106 };
2107
2108 fn network_error() -> CoreError {
2112 crate::client::install_crypto_provider();
2119 let rt = tokio::runtime::Builder::new_current_thread()
2120 .enable_all()
2121 .build()
2122 .expect("test runtime");
2123 let url = "http://127.0.0.1:1";
2124 let source = rt
2125 .block_on(reqwest::get(url))
2126 .expect_err("request to an unroutable port must fail");
2127 CoreError::Network {
2128 url: url.to_string(),
2129 source: Some(source),
2130 observation: None,
2131 }
2132 }
2133
2134 fn module_feed_unreachable_error() -> CoreError {
2138 crate::client::install_crypto_provider();
2139 let rt = tokio::runtime::Builder::new_current_thread()
2140 .enable_all()
2141 .build()
2142 .expect("test runtime");
2143 let url = "http://127.0.0.1:1";
2144 let source = rt
2145 .block_on(reqwest::get(url))
2146 .expect_err("request to an unroutable port must fail");
2147 CoreError::ModuleFeedUnreachable {
2148 url: url.to_string(),
2149 source: Some(source),
2150 }
2151 }
2152
2153 #[test]
2157 fn exit_code_mapping_enumerated() {
2158 let cases: Vec<(CoreError, u8, &'static str)> = vec![
2159 (CoreError::Internal("boom".into()), 1, "internal"),
2160 (
2161 CoreError::ConfirmationRequired {
2162 operation: "project download".into(),
2163 },
2164 2,
2165 "confirmation_required",
2166 ),
2167 (
2168 CoreError::ProfilingRiskNotAccepted {
2169 operation: "jvm sample".into(),
2170 },
2171 2,
2172 "profiling_risk_not_accepted",
2173 ),
2174 (
2175 CoreError::InvalidImportFile {
2176 reason: "missing ZIP magic".into(),
2177 },
2178 2,
2179 "invalid_import_file",
2180 ),
2181 (
2182 CoreError::InvalidInput {
2183 reason: "cannot read put.json".into(),
2184 },
2185 2,
2186 "invalid_input",
2187 ),
2188 (
2189 CoreError::ProfileNotFound {
2190 name: "nope".into(),
2191 known: vec!["dev".into()],
2192 },
2193 3,
2194 "profile_not_found",
2195 ),
2196 (CoreError::NoActiveProfile, 3, "no_active_profile"),
2197 (
2198 CoreError::SecretUnavailable {
2199 profile: "dev".into(),
2200 },
2201 3,
2202 "secret_unavailable",
2203 ),
2204 (
2205 CoreError::PasswordUnavailable {
2206 profile: "dev".into(),
2207 },
2208 3,
2209 "password_unavailable",
2210 ),
2211 (
2212 CoreError::ConfigInvalid {
2213 reason: "bad toml".into(),
2214 },
2215 3,
2216 "config_invalid",
2217 ),
2218 (
2219 CoreError::PollIntervalTooSmall {
2220 profile: "dev".into(),
2221 },
2222 3,
2223 "poll_interval_too_small",
2224 ),
2225 (network_error(), 4, "network_error"),
2226 (
2227 CoreError::Auth {
2228 status: 401,
2229 endpoint: None,
2230 },
2231 5,
2232 "auth_rejected",
2233 ),
2234 (
2235 CoreError::GatewayTooOld {
2236 found: "8.1.0".into(),
2237 minimum: "8.3.1".into(),
2238 endpoint: None,
2239 },
2240 6,
2241 "gateway_too_old",
2242 ),
2243 (
2244 CoreError::GatewayNotCommissioned {
2245 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2246 },
2247 6,
2248 "gateway_not_commissioned",
2249 ),
2250 (
2251 CoreError::GatewayRestarting {
2252 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2253 },
2254 6,
2255 "gateway_restarting",
2256 ),
2257 (
2258 CoreError::NotFound {
2259 endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2260 },
2261 6,
2262 "not_found",
2263 ),
2264 (
2265 CoreError::ProjectExists {
2266 name: "PlantFloor".into(),
2267 endpoint: None,
2268 },
2269 6,
2270 "project_exists",
2271 ),
2272 (
2273 CoreError::ResourceBinary {
2274 path: "com.x/perspective/session-permissions".into(),
2275 endpoint: None,
2276 },
2277 6,
2278 "resource_binary",
2279 ),
2280 (
2281 CoreError::TrialNotExpired {
2282 remaining_s: 6590,
2283 endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2284 },
2285 6,
2286 "trial_not_expired",
2287 ),
2288 (
2289 CoreError::ProviderNotFound {
2290 name: "nope".into(),
2291 endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2292 },
2293 6,
2294 "provider_not_found",
2295 ),
2296 (
2297 CoreError::RoutesNotDeployed {
2298 project: "ign-cli".into(),
2299 route: "tags".into(),
2300 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2301 },
2302 6,
2303 "routes_not_deployed",
2304 ),
2305 (
2306 CoreError::WebdevUnlicensed {
2307 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2308 },
2309 6,
2310 "webdev_unlicensed",
2311 ),
2312 (
2313 CoreError::RouteVersionMismatch {
2314 route: "tags".into(),
2315 deployed: "0.9.0".into(),
2316 expected: "1.0.0".into(),
2317 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2318 },
2319 6,
2320 "route_version_mismatch",
2321 ),
2322 (
2323 CoreError::WebdevRouteError {
2324 code: "route_error".into(),
2325 message: "boom".into(),
2326 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2327 },
2328 6,
2329 "webdev_route_error",
2330 ),
2331 (
2332 CoreError::TagCollision {
2333 provider: "p5import".into(),
2334 names: vec!["T1".into(), "P5".into()],
2335 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2336 },
2337 6,
2338 "tag_collision",
2339 ),
2340 (
2341 CoreError::AlarmJournalMissing {
2342 endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2343 },
2344 6,
2345 "alarm_journal_missing",
2346 ),
2347 (
2348 CoreError::ImportDenied {
2349 project: "PlantFloor".into(),
2350 problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2351 endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2352 },
2353 6,
2354 "import_denied",
2355 ),
2356 (
2357 CoreError::SessionNotPrunable {
2358 id: "d-live-1".into(),
2359 endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2360 },
2361 6,
2362 "session_not_prunable",
2363 ),
2364 (
2365 CoreError::EamNotController {
2366 endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2367 },
2368 6,
2369 "eam_not_controller",
2370 ),
2371 (
2372 CoreError::ProviderRootUnsupported {
2373 endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2374 },
2375 6,
2376 "provider_root_unsupported",
2377 ),
2378 (
2379 CoreError::EamTaskTypeRefused {
2380 task_type: "eam_remoteUpgrade".into(),
2381 },
2382 6,
2383 "eam_task_type_refused",
2384 ),
2385 (
2386 CoreError::EamTaskInFlight {
2387 task: "cli-research-backup".into(),
2388 detail: "Task 'cli-research-backup (forced)' already exists! It must be completed or deleted before another task of this type can be force executed.".into(),
2389 endpoint: Some(
2390 "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2391 .into(),
2392 ),
2393 },
2394 6,
2395 "eam_task_in_flight",
2396 ),
2397 (
2398 CoreError::ScriptExecNotConfigured {
2399 profile: "dev".into(),
2400 },
2401 6,
2402 "script_exec_not_configured",
2403 ),
2404 (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2405 (
2406 CoreError::NodeToolAbsent {
2407 tool: "node".into(),
2408 },
2409 6,
2410 "node_tool_absent",
2411 ),
2412 (
2413 CoreError::BundleNotAvailable {
2414 state: "Invalid".into(),
2415 },
2416 6,
2417 "bundle_not_available",
2418 ),
2419 (
2420 CoreError::GatewayClientError {
2421 status: 400,
2422 endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2423 body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2424 },
2425 2,
2426 "gateway_client_error",
2427 ),
2428 (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2429 (
2430 module_feed_unreachable_error(),
2431 4,
2432 "module_feed_unreachable",
2433 ),
2434 (
2435 CoreError::ModuleReleaseNotFound {
2436 module: "git".into(),
2437 version: "99.99.99".into(),
2438 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2439 detail: "no release exists for tag \"v99.99.99\"".into(),
2440 },
2441 6,
2442 "module_release_not_found",
2443 ),
2444 (
2445 CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2446 module: "git".into(),
2447 version: "2.3.4".into(),
2448 url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2449 expected: "b".repeat(64),
2450 actual: "c".repeat(64),
2451 })),
2452 6,
2453 "module_digest_mismatch",
2454 ),
2455 (
2456 CoreError::ModuleFeedUnusable {
2457 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2458 detail: "rate limit exceeded".into(),
2459 },
2460 6,
2461 "module_feed_unusable",
2462 ),
2463 (
2464 CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2465 module: "git".into(),
2466 version: "2.3.4".into(),
2467 cached_digest: "a".repeat(64),
2468 upstream_digest: "b".repeat(64),
2469 cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2470 })),
2471 6,
2472 "module_digest_changed",
2473 ),
2474 (
2475 CoreError::ModuleNotRegistered {
2476 id: "bogus".into(),
2477 known: vec!["git".into()],
2478 },
2479 3,
2480 "module_not_registered",
2481 ),
2482 (
2483 CoreError::ModuleUninstallDenied {
2484 module_id: "git".into(),
2485 gateway_module_id: "com.axone_io.ignition.git".into(),
2486 failed: vec!["com.axone_io.ignition.git".into()],
2487 endpoint: Some(
2488 "http://gw:8088/data/api/v1/modules/uninstall".into(),
2489 ),
2490 },
2491 6,
2492 "module_uninstall_denied",
2493 ),
2494 (
2495 CoreError::ScanLockHeld {
2496 actor: Some("api-token:cap-a:172.17.0.1".into()),
2497 kind: "config".into(),
2498 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2499 },
2500 6,
2501 "scan_lock_held",
2502 ),
2503 (
2504 CoreError::ResourceSignatureRejected {
2505 name: "DB_A".into(),
2506 reason: SignatureReason::Stale,
2507 endpoint: Some(
2508 "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2509 .into(),
2510 ),
2511 },
2512 6,
2513 "resource_signature_rejected",
2514 ),
2515 (
2516 CoreError::ResourceExists {
2517 name: "DB_A".into(),
2518 endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2519 },
2520 6,
2521 "resource_exists",
2522 ),
2523 (
2524 CoreError::ModeExists {
2525 name: "production".into(),
2526 endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2527 },
2528 6,
2529 "mode_exists",
2530 ),
2531 (
2532 CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2533 2,
2534 "deploy_tree_invalid",
2535 ),
2536 (
2537 CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2538 2,
2539 "embedded_secret",
2540 ),
2541 (
2542 CoreError::TransportFailed {
2543 operation: "read".into(),
2544 node: "nodes.master".into(),
2545 detail: "cannot read directory config/resources (NotFound)".into(),
2546 },
2547 4,
2548 "transport_failed",
2549 ),
2550 (
2551 CoreError::DeployLedgerInvalid {
2552 location: "ign-deploy/gw/dev/ledger.json".into(),
2553 problem: "files is not byte-sorted".into(),
2554 },
2555 6,
2556 "deploy_ledger_invalid",
2557 ),
2558 (
2559 CoreError::PlanStale {
2560 expected: "a".repeat(64),
2561 actual: "b".repeat(64),
2562 detail: "review hash".into(),
2563 },
2564 6,
2565 "plan_stale",
2566 ),
2567 (
2568 CoreError::DestructiveChangeRefused {
2569 gate: "destructive".into(),
2570 keys: vec!["ignition/a/x".into()],
2571 count: 1,
2572 },
2573 2,
2574 "destructive_change_refused",
2575 ),
2576 (
2577 CoreError::DeployBlocked {
2578 keys: vec!["ignition/a/x".into()],
2579 },
2580 6,
2581 "deploy_blocked",
2582 ),
2583 (
2584 CoreError::DeployPreflightFailed {
2585 failures: vec![PreflightFailure {
2586 check: "mode_master".into(),
2587 detail: "the gateway is not in the declared mode".into(),
2588 }]
2589 .into(),
2590 },
2591 6,
2592 "deploy_preflight_failed",
2593 ),
2594 (
2595 CoreError::DeployJournalPresent {
2596 run_id: "r1".into(),
2597 env: "prod".into(),
2598 location: "ign-deploy/gw/m/journal.json".into(),
2599 },
2600 6,
2601 "deploy_journal_present",
2602 ),
2603 (
2604 CoreError::DeployLiveChanged {
2605 run_id: "r1".into(),
2606 paths: vec!["a/b.json".into()],
2607 count: 1,
2608 },
2609 6,
2610 "deploy_live_changed",
2611 ),
2612 (
2613 CoreError::DeployRunInvalid {
2614 run_id: "r1".into(),
2615 problem: "record.json is missing".into(),
2616 },
2617 6,
2618 "deploy_run_invalid",
2619 ),
2620 (
2621 CoreError::RollbackFailed(Box::new(RollbackFailure {
2622 run_id: "r1".into(),
2623 env: "dev".into(),
2624 run_dir: Some(".ign/deploy/r1".into()),
2625 journal: "ign-deploy/gw/m/journal.json".into(),
2626 cause: "write_failed".into(),
2627 })),
2628 6,
2629 "rollback_failed",
2630 ),
2631 (
2632 CoreError::ProjectGitManaged {
2633 project: "CapApp".into(),
2634 location: "projects/CapApp/.git".into(),
2635 kind: "directory".into(),
2636 },
2637 6,
2638 "project_git_managed",
2639 ),
2640 (
2641 CoreError::ProjectParentInvalid {
2642 project: "CapApp".into(),
2643 parent: "WHK-Global".into(),
2644 reason: "missing".into(),
2645 },
2646 6,
2647 "project_parent_invalid",
2648 ),
2649 (
2650 CoreError::ProjectHashMismatch {
2651 project: "CapApp".into(),
2652 expected: "0".repeat(64),
2653 actual: "1".repeat(64),
2654 },
2655 6,
2656 "project_hash_mismatch",
2657 ),
2658 (
2659 CoreError::DeployUnreadable {
2660 node: "master".into(),
2661 keys: vec!["ignition/tag-definition/default/Deep".into()],
2662 },
2663 6,
2664 "deploy_unreadable",
2665 ),
2666 ];
2667 for (err, code, slug) in cases {
2668 assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2669 assert_eq!(err.code(), slug, "unstable slug for: {err}");
2670 }
2671 }
2672
2673 const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
2682 (1, "internal"),
2683 (2, "confirmation_required"),
2684 (2, "profiling_risk_not_accepted"),
2685 (2, "invalid_import_file"),
2686 (2, "invalid_input"),
2687 (2, "gateway_client_error"),
2688 (2, "deploy_tree_invalid"),
2689 (2, "embedded_secret"),
2690 (2, "destructive_change_refused"),
2691 (3, "profile_not_found"),
2692 (3, "no_active_profile"),
2693 (3, "secret_unavailable"),
2694 (3, "password_unavailable"),
2695 (3, "config_invalid"),
2696 (3, "poll_interval_too_small"),
2697 (3, "module_not_registered"),
2698 (4, "network_error"),
2699 (5, "auth_rejected"),
2700 (6, "gateway_too_old"),
2701 (6, "gateway_not_commissioned"),
2702 (6, "gateway_restarting"),
2703 (6, "not_found"),
2704 (6, "project_exists"),
2705 (6, "resource_binary"),
2706 (6, "trial_not_expired"),
2707 (6, "provider_not_found"),
2708 (6, "routes_not_deployed"),
2709 (6, "webdev_unlicensed"),
2710 (6, "route_version_mismatch"),
2711 (6, "webdev_route_error"),
2712 (6, "tag_collision"),
2713 (6, "alarm_journal_missing"),
2714 (6, "import_denied"),
2715 (6, "session_not_prunable"),
2716 (6, "eam_not_controller"),
2717 (6, "eam_task_type_refused"),
2718 (6, "eam_task_in_flight"),
2719 (6, "script_exec_not_configured"),
2720 (6, "lint_tool_absent"),
2721 (6, "node_tool_absent"),
2722 (6, "provider_root_unsupported"),
2723 (6, "bundle_not_available"),
2724 (4, "module_feed_unreachable"),
2725 (4, "transport_failed"),
2726 (6, "module_release_not_found"),
2727 (6, "module_digest_mismatch"),
2728 (6, "module_feed_unusable"),
2729 (6, "module_digest_changed"),
2730 (6, "module_uninstall_denied"),
2731 (6, "scan_lock_held"),
2732 (6, "resource_signature_rejected"),
2733 (6, "resource_exists"),
2734 (6, "mode_exists"),
2735 (6, "deploy_ledger_invalid"),
2736 (6, "plan_stale"),
2737 (6, "deploy_blocked"),
2738 (6, "deploy_preflight_failed"),
2739 (6, "deploy_journal_present"),
2740 (6, "deploy_live_changed"),
2741 (6, "deploy_run_invalid"),
2742 (6, "rollback_failed"),
2743 (6, "project_git_managed"),
2744 (6, "project_parent_invalid"),
2745 (6, "project_hash_mismatch"),
2746 (6, "deploy_unreadable"),
2747 (7, "rig_error"),
2748 ];
2749
2750 #[test]
2753 fn deploy_slugs_never_carry_signature_or_problem_text() {
2754 const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
2755
2756 let held = CoreError::ScanLockHeld {
2757 actor: Some("api-token:cap-a:172.17.0.1".into()),
2758 kind: "config".into(),
2759 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2760 };
2761 assert_eq!(held.code(), "scan_lock_held");
2762 assert_eq!(held.exit_code(), 6);
2763 let shown = held.to_string();
2764 assert!(shown.contains("api-token:cap-a:172.17.0.1"), "{shown}");
2765 assert!(shown.contains("config"), "{shown}");
2766 assert_eq!(
2767 held.endpoint().as_deref(),
2768 Some("http://gw:8088/data/api/v1/scan-lock/config")
2769 );
2770
2771 let anonymous = CoreError::ScanLockHeld {
2772 actor: None,
2773 kind: "config".into(),
2774 endpoint: None,
2775 };
2776 let shown = anonymous.to_string();
2777 assert!(shown.contains("another actor"), "{shown}");
2778 assert!(!shown.contains("None"), "{shown}");
2779
2780 for reason in [SignatureReason::Stale, SignatureReason::Missing] {
2781 let err = CoreError::ResourceSignatureRejected {
2782 name: "DB_A".into(),
2783 reason,
2784 endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
2785 };
2786 assert_eq!(err.code(), "resource_signature_rejected");
2787 assert_eq!(err.exit_code(), 6);
2788 let shown = err.to_string();
2789 assert!(shown.contains("DB_A"), "{shown}");
2790 assert!(shown.contains(&reason.to_string()), "{shown}");
2791 assert!(!shown.contains(SAMPLE_SIG), "{shown}");
2792 let hint = err.hint().expect("hint required");
2793 assert!(!hint.contains(SAMPLE_SIG), "{hint}");
2794 let endpoint = err.endpoint().expect("endpoint");
2795 assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
2796 }
2797 assert_eq!(SignatureReason::Stale.to_string(), "stale");
2798 assert_eq!(SignatureReason::Missing.to_string(), "missing");
2799 }
2800
2801 fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
2810 let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
2811 let mut rows = Vec::new();
2812 let table_lines = section
2813 .lines()
2814 .skip_while(|line| !line.trim_start().starts_with('|'));
2815 for line in table_lines {
2816 if !line.trim_start().starts_with('|') {
2817 break; }
2819 let cells: Vec<&str> = line.split('|').map(str::trim).collect();
2820 let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
2821 continue;
2822 };
2823 if !(1..=7).contains(&exit) {
2824 continue;
2825 }
2826 let slug_cell = cells
2827 .iter()
2828 .rev()
2829 .find(|cell| !cell.is_empty())
2830 .copied()
2831 .unwrap_or_default();
2832 let slugs: Vec<String> = slug_cell
2833 .split('`')
2834 .enumerate()
2835 .filter(|(idx, _)| idx % 2 == 1)
2836 .map(|(_, token)| token.trim().to_string())
2837 .filter(|token| {
2838 token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
2839 })
2840 .collect();
2841 rows.push((exit, slugs));
2842 }
2843 rows
2844 }
2845
2846 #[test]
2857 fn readme_exit_table_agreement() {
2858 let readme = include_str!("../../../README.md");
2859 let rows = parse_readme_exit_table(readme);
2860 assert!(
2861 rows.len() >= 7,
2862 "README exit-code table not found — the parser must see all 7 \
2863 failure-class rows (found {})",
2864 rows.len()
2865 );
2866
2867 for (exit, slug) in EXIT_SLUG_LITERALS {
2870 let row = rows
2871 .iter()
2872 .find(|(readme_exit, _)| readme_exit == exit)
2873 .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
2874 assert!(
2875 row.1.iter().any(|s| s == slug),
2876 "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
2877 row.1
2878 );
2879 }
2880
2881 for (exit, slugs) in &rows {
2884 for slug in slugs {
2885 assert!(
2886 EXIT_SLUG_LITERALS
2887 .iter()
2888 .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
2889 "README exit-{exit} row carries slug {slug:?} that no \
2890 CoreError variant emits — stale table row or slug/exit \
2891 drift between README and error.rs"
2892 );
2893 }
2894 }
2895 }
2896
2897 #[test]
2900 fn hints_are_actionable_for_config_auth_target_state() {
2901 let profile_not_found = CoreError::ProfileNotFound {
2902 name: "x".into(),
2903 known: vec!["dev".into(), "prod".into()],
2904 };
2905 let hint = profile_not_found.hint().expect("hint required");
2906 assert!(
2907 hint.contains("dev"),
2908 "hint must list known profiles: {hint}"
2909 );
2910 assert!(
2911 hint.contains("ign profile add"),
2912 "hint must name the fix: {hint}"
2913 );
2914
2915 let auth = CoreError::Auth {
2916 status: 403,
2917 endpoint: None,
2918 };
2919 let hint = auth.hint().expect("hint required");
2920 assert!(
2921 hint.contains("three parts"),
2922 "auth hint must name the three-part token setup: {hint}"
2923 );
2924
2925 let unauthorized = CoreError::Auth {
2928 status: 401,
2929 endpoint: None,
2930 };
2931 let hint = unauthorized.hint().expect("hint required");
2932 assert!(
2933 hint.contains("name:key"),
2934 "401 hint must name the full name:key token format: {hint}"
2935 );
2936 assert!(
2937 hint.contains("API token"),
2938 "401 hint must say Basic cannot work: {hint}"
2939 );
2940 let hint403 = auth.hint().expect("hint required");
2941 assert!(
2942 hint403.contains("secure connections"),
2943 "403 hint must name the secure-channel part: {hint403}"
2944 );
2945
2946 let too_old = CoreError::GatewayTooOld {
2947 found: "8.1.0".into(),
2948 minimum: "8.3.1".into(),
2949 endpoint: None,
2950 };
2951 let hint = too_old.hint().expect("hint required");
2952 assert!(
2953 hint.contains("8.3.1"),
2954 "target-state hint must name the minimum: {hint}"
2955 );
2956
2957 let undeployed = CoreError::RoutesNotDeployed {
2961 project: "ign-cli".into(),
2962 route: "tags".into(),
2963 endpoint: None,
2964 };
2965 let hint = undeployed.hint().expect("hint required");
2966 assert!(
2967 hint.contains("ign webdev deploy"),
2968 "absent-routes hint must name the fix: {hint}"
2969 );
2970
2971 let older = CoreError::RouteVersionMismatch {
2972 route: "tags".into(),
2973 deployed: "0.9.0".into(),
2974 expected: "1.0.0".into(),
2975 endpoint: None,
2976 };
2977 let hint = older.hint().expect("hint required");
2978 assert!(
2979 hint.contains("ign webdev deploy") && !hint.contains("update ign"),
2980 "older-route hint says redeploy: {hint}"
2981 );
2982
2983 let newer = CoreError::RouteVersionMismatch {
2984 route: "tags".into(),
2985 deployed: "1.1.0".into(),
2986 expected: "1.0.0".into(),
2987 endpoint: None,
2988 };
2989 let hint = newer.hint().expect("hint required");
2990 assert!(
2991 hint.contains("update ign") && !hint.contains("ign webdev deploy"),
2992 "newer-route hint says update ign: {hint}"
2993 );
2994
2995 let secret_gate = CoreError::WebdevRouteError {
2996 code: "secret_required".into(),
2997 message: "missing x-ignition-cli-secret header".into(),
2998 endpoint: None,
2999 };
3000 let hint = secret_gate.hint().expect("hint required");
3001 assert!(
3002 hint.contains("--with-script-exec"),
3003 "secret-gate hint names the deploy flag: {hint}"
3004 );
3005
3006 let journal = CoreError::AlarmJournalMissing { endpoint: None };
3009 let hint = journal.hint().expect("hint required");
3010 assert!(
3011 hint.contains("journal profile") && hint.contains("database connection"),
3012 "journal hint names the chain: {hint}"
3013 );
3014 assert!(
3015 hint.contains("README"),
3016 "journal hint points at the README section: {hint}"
3017 );
3018
3019 let prunable = CoreError::SessionNotPrunable {
3022 id: "d-live-1".into(),
3023 endpoint: None,
3024 };
3025 let hint = prunable.hint().expect("hint required");
3026 assert!(
3027 hint.contains("close the Designer"),
3028 "prune hint must name the action: {hint}"
3029 );
3030 assert!(
3031 hint.contains("stale entries"),
3032 "prune hint must name stale-only semantics: {hint}"
3033 );
3034
3035 let held = CoreError::ScanLockHeld {
3038 actor: None,
3039 kind: "config".into(),
3040 endpoint: None,
3041 };
3042 let hint = held.hint().expect("hint required");
3043 assert!(
3044 hint.contains("holdTimeout") && hint.contains("scan runs"),
3045 "scan-lock hint names the release conditions: {hint}"
3046 );
3047 assert!(
3048 !hint.contains("ign "),
3049 "scan-lock hint names no ign command: {hint}"
3050 );
3051
3052 let rejected = CoreError::ResourceSignatureRejected {
3054 name: "DB_A".into(),
3055 reason: SignatureReason::Stale,
3056 endpoint: None,
3057 };
3058 let hint = rejected.hint().expect("hint required");
3059 assert!(
3060 hint.contains("re-read") && hint.contains("signatures change on every write"),
3061 "signature hint names the action: {hint}"
3062 );
3063
3064 let exists = CoreError::ResourceExists {
3067 name: "DB_A".into(),
3068 endpoint: None,
3069 };
3070 let hint = exists.hint().expect("hint required");
3071 assert!(
3072 hint.contains("update it"),
3073 "resource_exists hint names the action: {hint}"
3074 );
3075 assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3076 let mode_exists = CoreError::ModeExists {
3077 name: "production".into(),
3078 endpoint: None,
3079 };
3080 let hint = mode_exists.hint().expect("hint required");
3081 assert!(
3082 hint.contains("skip the create"),
3083 "mode_exists hint names the action: {hint}"
3084 );
3085
3086 let tty = CoreError::tui_tty_refusal();
3091 assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3092 assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3093 let hint = tty.hint().expect("hint required");
3094 assert!(
3095 hint.contains("interactive terminal"),
3096 "TTY hint must name the fix: {hint}"
3097 );
3098 assert!(
3099 !hint.contains("--file"),
3100 "TTY hint must not carry the resource-put hint: {hint}"
3101 );
3102
3103 let top = CoreError::top_tty_refusal();
3108 assert_eq!(top.code(), "invalid_input", "slug unchanged");
3109 assert_eq!(top.exit_code(), 2, "usage class unchanged");
3110 let top_hint = top.hint().expect("hint required");
3111 assert!(
3112 top_hint.contains("`ign top`"),
3113 "the top refusal's hint names ign top: {top_hint}"
3114 );
3115 assert!(
3116 !top_hint.contains("ign tui"),
3117 "the top refusal never names the cockpit command: {top_hint}"
3118 );
3119 assert!(
3120 top_hint.contains("interactive terminal"),
3121 "the top refusal keeps the terminal-contextual fix: {top_hint}"
3122 );
3123 let put = CoreError::InvalidInput {
3124 reason: "cannot read put.json".into(),
3125 };
3126 let hint = put.hint().expect("hint required");
3127 assert!(
3128 hint.contains("--file") && hint.contains("stdin"),
3129 "resource-put hint unchanged: {hint}"
3130 );
3131
3132 let controller = CoreError::EamNotController { endpoint: None };
3136 let hint = controller.hint().expect("hint required");
3137 assert!(
3138 hint.contains("installMode"),
3139 "controller hint names the flip: {hint}"
3140 );
3141 assert!(
3142 hint.contains("Controller"),
3143 "controller hint names the target role: {hint}"
3144 );
3145 assert!(
3146 hint.contains("README"),
3147 "controller hint points at the README section: {hint}"
3148 );
3149
3150 let refused = CoreError::EamTaskTypeRefused {
3153 task_type: "eam_restoreBackup".into(),
3154 };
3155 assert_eq!(refused.exit_code(), 6, "target state");
3156 let hint = refused.hint().expect("hint required");
3157 assert!(
3158 hint.contains("EAM console"),
3159 "refused hint names where to run it: {hint}"
3160 );
3161 assert!(
3162 hint.contains("fleet-wide") || hint.contains("EXT-03"),
3163 "refused hint names the fleet consequence / scope: {hint}"
3164 );
3165
3166 let in_flight = CoreError::EamTaskInFlight {
3170 task: "cli-research-backup".into(),
3171 detail: "the previous '(forced)' run must be completed or deleted first".into(),
3172 endpoint: None,
3173 };
3174 assert_eq!(in_flight.exit_code(), 6, "target state");
3175 let hint = in_flight.hint().expect("hint required");
3176 assert!(
3177 hint.contains("EAM console"),
3178 "in-flight hint names where to resolve it: {hint}"
3179 );
3180 assert!(
3181 hint.contains("complete or delete"),
3182 "in-flight hint names the resolution: {hint}"
3183 );
3184
3185 let unconfigured = CoreError::ScriptExecNotConfigured {
3189 profile: "dev".into(),
3190 };
3191 assert_eq!(unconfigured.exit_code(), 6, "target state");
3192 let hint = unconfigured.hint().expect("hint required");
3193 assert!(
3194 hint.contains("ign webdev deploy --with-script-exec"),
3195 "the hint names the deploy flag verbatim: {hint}"
3196 );
3197
3198 let no_active = CoreError::NoActiveProfile;
3200 assert!(no_active.hint().is_some());
3201 }
3202
3203 #[test]
3208 fn error_envelope_locked_shape_and_endpoint() {
3209 let auth = CoreError::Auth {
3210 status: 401,
3211 endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3212 };
3213 let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3214 let json = serde_json::to_string(&envelope).expect("serialize envelope");
3215
3216 assert_eq!(
3217 json,
3218 concat!(
3219 r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3220 r#""message":"gateway rejected credentials (HTTP 401)","#,
3221 r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3222 r#""hint":"token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"}}"#
3223 )
3224 );
3225
3226 let no_request = CoreError::NoActiveProfile;
3228 let body: &ErrorBody = &no_request.envelope(None).error;
3229 assert_eq!(body.endpoint, None);
3230 }
3231
3232 #[test]
3238 fn truncates_at_cap_with_marker() {
3239 let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3241 assert_eq!(truncate_api_body(short), short);
3242 assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3243
3244 let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3247 assert_eq!(truncate_api_body(&exact), exact);
3248
3249 let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3251 let truncated = truncate_api_body(&over);
3252 assert!(
3253 truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3254 "truncated body must end with the explicit marker"
3255 );
3256 assert!(
3257 truncated.len()
3258 <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3259 "truncated body must stay within cap + marker: {}",
3260 truncated.len()
3261 );
3262
3263 let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); let truncated = truncate_api_body(&multibyte);
3268 assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3269 assert!(
3270 truncated
3271 .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3272 );
3273 }
3274
3275 #[test]
3284 fn loss_gate_hint_override_and_neighbors_unchanged() {
3285 let loss_gate = CoreError::InvalidInput {
3288 reason: format!(
3289 "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3290 with --yes to import anyway",
3291 LOSS_GATE_REFUSAL_REASON_PREFIX
3292 ),
3293 };
3294 assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3295 assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3296 let hint = loss_gate.hint().expect("hint required");
3297 assert!(
3298 hint.contains("--yes"),
3299 "loss-gate hint must name the --yes re-run: {hint}"
3300 );
3301 assert!(
3302 hint.contains("loss report above"),
3303 "loss-gate hint must point back at the report: {hint}"
3304 );
3305 assert!(
3306 !hint.contains("fix the input source"),
3307 "the file-read default must never leak onto the loss-gate path: {hint}"
3308 );
3309
3310 let generic = CoreError::InvalidInput {
3313 reason: "x is not valid JSON: expected value at line 1".into(),
3314 };
3315 let hint = generic.hint().expect("hint required");
3316 assert!(
3317 hint.contains("--file") && hint.contains("stdin"),
3318 "resource-put hint unchanged: {hint}"
3319 );
3320 assert!(
3321 !hint.contains("--yes"),
3322 "the --yes hint must not leak onto generic reasons: {hint}"
3323 );
3324
3325 let tty = CoreError::tui_tty_refusal();
3327 let hint = tty.hint().expect("hint required");
3328 assert!(
3329 hint.contains("interactive terminal"),
3330 "TTY hint unchanged: {hint}"
3331 );
3332 assert!(
3333 !hint.contains("--yes"),
3334 "the --yes hint must not leak onto the TTY path: {hint}"
3335 );
3336 }
3337
3338 #[test]
3346 fn routes_not_deployed_hint_is_testing_scoped() {
3347 let testing = CoreError::RoutesNotDeployed {
3348 project: "Flux".into(),
3349 route: format!("{TESTING_ROUTE_PREFIX}run"),
3350 endpoint: None,
3351 };
3352 assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3353 assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3354 let hint = testing.hint().expect("hint required");
3355 assert!(
3356 hint.contains("ign webdev deploy --with-testing"),
3357 "testing hint must name the deploy flag: {hint}"
3358 );
3359 assert!(
3360 hint.contains("ign adopt") && hint.contains("--testing"),
3361 "testing hint must name the adopt path: {hint}"
3362 );
3363
3364 let generic = CoreError::RoutesNotDeployed {
3366 project: "ign-cli".into(),
3367 route: "tags".into(),
3368 endpoint: None,
3369 };
3370 let hint = generic.hint().expect("hint required");
3371 assert_eq!(
3372 hint,
3373 "run `ign webdev deploy` to install the CLI's WebDev routes into \
3374 the gateway, then retry",
3375 "the generic branch is byte-identical to its pre-QUICK-p0g text"
3376 );
3377 }
3378
3379 fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3380 DeployFinding {
3381 file: file.into(),
3382 line,
3383 column,
3384 message: msg.into(),
3385 }
3386 }
3387
3388 #[test]
3389 fn deploy_finding_display_names_file_line_column() {
3390 let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3391 assert_eq!(
3392 positioned.to_string(),
3393 "targets/prod.yaml:3:1: unknown field `x`"
3394 );
3395 let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3396 assert_eq!(
3397 bare.to_string(),
3398 "targets/prod.yaml: missing field `gateway`"
3399 );
3400 let half = finding("m.yaml", Some(3), None, "m");
3402 assert_eq!(half.to_string(), "m.yaml: m");
3403 }
3404
3405 #[test]
3406 fn deploy_findings_cap_at_twenty() {
3407 let many: Vec<DeployFinding> = (0..25)
3408 .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3409 .collect();
3410 let shown = CoreError::deploy_findings(many).to_string();
3411 assert!(shown.contains("25 findings: "), "{shown}");
3412 assert!(shown.contains("problem 19"), "{shown}");
3413 assert!(!shown.contains("problem 20"), "{shown}");
3414 assert!(shown.ends_with("; and 5 more"), "{shown}");
3415
3416 let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3417 assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3418 }
3419
3420 #[test]
3421 fn embedded_secret_display_lists_paths_only() {
3422 let err = CoreError::embedded_secret(
3423 vec![
3424 "ignition/database-connection/A/config.json: settings.password".into(),
3425 "ignition/database-connection/B/config.json: settings.password".into(),
3426 ],
3427 vec![finding("manifest.yaml", None, None, "duplicate type")],
3428 );
3429 let shown = err.to_string();
3430 assert!(shown.contains("2 path(s)"), "{shown}");
3431 assert!(
3432 shown.contains("A/config.json: settings.password"),
3433 "{shown}"
3434 );
3435 assert!(
3436 shown.contains("B/config.json: settings.password"),
3437 "{shown}"
3438 );
3439 assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3440 assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3441
3442 let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3444 let shown =
3445 CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3446 assert!(shown.contains("25 path(s)"), "{shown}");
3447 assert!(shown.contains("and 5 more"), "{shown}");
3448 assert!(shown.contains("f19.json: a.b"), "{shown}");
3449 assert!(!shown.contains("f20.json: a.b"), "{shown}");
3450 }
3451
3452 #[test]
3453 fn deploy_slugs_have_their_exit_codes() {
3454 let errs = [
3455 (
3456 CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3457 2,
3458 ),
3459 (
3460 CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3461 2,
3462 ),
3463 (
3464 CoreError::TransportFailed {
3465 operation: "read".into(),
3466 node: "nodes.master".into(),
3467 detail: "data_dir is missing".into(),
3468 },
3469 4,
3470 ),
3471 (
3472 CoreError::DeployLedgerInvalid {
3473 location: "ign-deploy/gw/dev/ledger.json".into(),
3474 problem: "the file is empty".into(),
3475 },
3476 6,
3477 ),
3478 (
3479 CoreError::PlanStale {
3480 expected: "a".repeat(64),
3481 actual: "b".repeat(64),
3482 detail: "review hash".into(),
3483 },
3484 6,
3485 ),
3486 (
3487 CoreError::DestructiveChangeRefused {
3488 gate: "destructive".into(),
3489 keys: vec!["ignition/a/x".into()],
3490 count: 1,
3491 },
3492 2,
3493 ),
3494 (
3495 CoreError::DeployBlocked {
3496 keys: vec!["ignition/a/x".into()],
3497 },
3498 6,
3499 ),
3500 (
3501 CoreError::DeployPreflightFailed {
3502 failures: vec![PreflightFailure {
3503 check: "mode_master".into(),
3504 detail: "the gateway is not in the declared mode".into(),
3505 }]
3506 .into(),
3507 },
3508 6,
3509 ),
3510 (
3511 CoreError::DeployJournalPresent {
3512 run_id: "20261015T142233Z-a1b2c3".into(),
3513 env: "prod".into(),
3514 location: "ign-deploy/gw/m/journal.json".into(),
3515 },
3516 6,
3517 ),
3518 (
3519 CoreError::DeployLiveChanged {
3520 run_id: "r1".into(),
3521 paths: vec!["a/b.json".into()],
3522 count: 1,
3523 },
3524 6,
3525 ),
3526 (
3527 CoreError::DeployRunInvalid {
3528 run_id: "r1".into(),
3529 problem: "record.json is missing".into(),
3530 },
3531 6,
3532 ),
3533 (
3534 CoreError::RollbackFailed(Box::new(RollbackFailure {
3535 run_id: "r1".into(),
3536 env: "dev".into(),
3537 run_dir: Some(".ign/deploy/r1".into()),
3538 journal: "ign-deploy/gw/m/journal.json".into(),
3539 cause: "write_failed".into(),
3540 })),
3541 6,
3542 ),
3543 (
3544 CoreError::ProjectGitManaged {
3545 project: "CapApp".into(),
3546 location: "projects/CapApp/.git".into(),
3547 kind: "directory".into(),
3548 },
3549 6,
3550 ),
3551 (
3552 CoreError::ProjectParentInvalid {
3553 project: "CapApp".into(),
3554 parent: "WHK-Global".into(),
3555 reason: "cycle".into(),
3556 },
3557 6,
3558 ),
3559 (
3560 CoreError::ProjectHashMismatch {
3561 project: "CapApp".into(),
3562 expected: "0".repeat(64),
3563 actual: "1".repeat(64),
3564 },
3565 6,
3566 ),
3567 (
3568 CoreError::DeployUnreadable {
3569 node: "master".into(),
3570 keys: vec!["ignition/tag-definition/default/Deep".into()],
3571 },
3572 6,
3573 ),
3574 ];
3575 let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3576 assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3577 for (err, exit) in &errs {
3578 assert_eq!(err.exit_code(), *exit, "{err}");
3579 assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3580 let hint = err.hint().expect("hint required");
3581 assert!(
3582 !hint.contains("`ign ") && !hint.contains("ign "),
3583 "hint must not name an ign subcommand: {hint}"
3584 );
3585 }
3586 }
3587
3588 #[test]
3589 fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3590 for reason in [
3591 "--plan-sha256 must be 64 lowercase hex characters",
3592 "--allow-drift reason must not be empty",
3593 "--artifact-sha256 must be 64 lowercase hex characters",
3594 "--release <tag>: not a semver tag (for example v1.4.0)",
3595 "--allow-first-change <reason>: the reason is 1 to 500 characters",
3596 "deploy projects needs --artifact-sha256 when --json is given",
3597 ] {
3598 let hint = CoreError::InvalidInput {
3599 reason: reason.into(),
3600 }
3601 .hint()
3602 .expect("hint");
3603 assert!(!hint.contains("--file"), "{hint}");
3604 assert!(hint.contains("flag value"), "{hint}");
3605 }
3606 }
3607
3608 #[test]
3613 fn deploy_slugs_are_pinned_and_never_a_verdict() {
3614 assert_eq!(
3615 CoreError::DEPLOY_SLUGS,
3616 &[
3617 "deploy_tree_invalid",
3618 "embedded_secret",
3619 "transport_failed",
3620 "deploy_ledger_invalid",
3621 "plan_stale",
3622 "destructive_change_refused",
3623 "deploy_blocked",
3624 "deploy_preflight_failed",
3625 "deploy_journal_present",
3626 "deploy_live_changed",
3627 "deploy_run_invalid",
3628 "rollback_failed",
3629 "project_git_managed",
3630 "project_parent_invalid",
3631 "project_hash_mismatch",
3632 "deploy_unreadable",
3633 ]
3634 );
3635 assert_eq!(CoreError::DEPLOY_SLUGS.len(), 16);
3636 for verdict in [
3637 "deploy_rolled_back",
3638 "deploy_backup_unsynced",
3639 "deploy_project_unverified",
3640 ] {
3641 assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
3642 }
3643 }
3644
3645 #[test]
3648 fn project_git_managed_four_places() {
3649 let err = CoreError::ProjectGitManaged {
3650 project: "CapApp".into(),
3651 location: "projects/CapApp/.git".into(),
3652 kind: "directory".into(),
3653 };
3654 assert_eq!(err.code(), "project_git_managed");
3655 assert_eq!(err.exit_code(), 6);
3656 let message = err.to_string();
3657 for needle in [
3658 "CapApp",
3659 "projects/CapApp/.git",
3660 "directory",
3661 "administrator",
3662 ] {
3663 assert!(message.contains(needle), "{needle}: {message}");
3664 }
3665 let hint = err.hint().expect("hint required");
3666 assert!(!hint.contains("ign "), "{hint}");
3667 assert!(hint.contains(".git"), "{hint}");
3668 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
3669 }
3670
3671 #[test]
3675 fn project_parent_invalid_four_places() {
3676 for (reason, needle) in [
3677 ("missing", "its own repo"),
3678 ("cycle", "no cycle"),
3679 ("not_inheritable", "inheritable"),
3680 ] {
3681 let err = CoreError::ProjectParentInvalid {
3682 project: "CapApp".into(),
3683 parent: "WHK-Global".into(),
3684 reason: reason.into(),
3685 };
3686 assert_eq!(err.code(), "project_parent_invalid");
3687 assert_eq!(err.exit_code(), 6);
3688 let message = err.to_string();
3689 for part in ["CapApp", "WHK-Global", reason] {
3690 assert!(message.contains(part), "{part}: {message}");
3691 }
3692 let hint = err.hint().expect("hint required");
3693 assert!(!hint.contains("ign "), "{hint}");
3694 assert!(hint.contains(needle), "{reason}: {hint}");
3695 assert_eq!(err.endpoint(), None);
3696 }
3697 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
3698 }
3699
3700 #[test]
3704 fn project_hash_mismatch_four_places() {
3705 let err = CoreError::ProjectHashMismatch {
3706 project: "CapApp".into(),
3707 expected: "0".repeat(64),
3708 actual: "1".repeat(64),
3709 };
3710 assert_eq!(err.code(), "project_hash_mismatch");
3711 assert_eq!(err.exit_code(), 6);
3712 let message = err.to_string();
3713 for part in [
3714 "CapApp",
3715 "0000000000000000000000000000000000000000000000000000000000000000",
3716 "1111111111111111111111111111111111111111111111111111111111111111",
3717 "nothing was uploaded",
3718 ] {
3719 assert!(message.contains(part), "{part}: {message}");
3720 }
3721 let hint = err.hint().expect("hint required");
3722 assert!(!hint.contains("ign "), "{hint}");
3723 assert!(hint.contains("artifact_sha256"), "{hint}");
3724 assert_eq!(err.endpoint(), None);
3725 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
3726 }
3727
3728 #[test]
3733 fn deploy_unreadable_four_places() {
3734 let keys = vec![
3735 "ignition/database-translator/OLD".to_string(),
3736 "ignition/tag-definition/default/Deep".to_string(),
3737 ];
3738 let err = CoreError::DeployUnreadable {
3739 node: "backup".to_string(),
3740 keys: keys.clone(),
3741 };
3742 assert_eq!(err.code(), "deploy_unreadable");
3743 assert_eq!(err.exit_code(), 6);
3744 assert_eq!(err.endpoint(), None);
3745 let message = err.to_string();
3746 for part in keys.iter().map(String::as_str).chain([
3747 "backup host's sftp server cannot read",
3748 "nothing was written",
3749 "2 unit(s)",
3750 ]) {
3751 assert!(message.contains(part), "{part}: {message}");
3752 }
3753 let hint = err.hint().expect("hint required");
3754 assert!(!hint.contains("ign "), "{hint}");
3755 assert!(hint.contains("exclude_tag_providers"), "{hint}");
3756 assert!(hint.contains("shorten"), "{hint}");
3757 assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_unreadable"));
3758 }
3759
3760 #[test]
3763 fn plan_stale_hint_names_the_live_project_case() {
3764 let live = CoreError::PlanStale {
3765 expected: "a".repeat(64),
3766 actual: "b".repeat(64),
3767 detail: "live project changed since the plan".into(),
3768 };
3769 let hint = live.hint().expect("hint");
3770 assert!(hint.contains("live project"), "{hint}");
3771 assert!(hint.contains("nothing was imported"), "{hint}");
3772 assert!(!hint.contains("ign "), "{hint}");
3773 let review = CoreError::PlanStale {
3774 expected: "a".repeat(64),
3775 actual: "b".repeat(64),
3776 detail: "review hash".into(),
3777 };
3778 assert!(
3779 review.hint().expect("hint").contains("plan_sha256"),
3780 "the plan-hash case keeps its hint"
3781 );
3782 }
3783
3784 #[test]
3785 fn journal_present_message_carries_the_recovery_command() {
3786 let err = CoreError::DeployJournalPresent {
3787 run_id: "20261015T142233Z-a1b2c3".into(),
3788 env: "prod".into(),
3789 location: "ign-deploy/gw/m/journal.json".into(),
3790 };
3791 let shown = err.to_string();
3792 assert!(
3793 shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
3794 "{shown}"
3795 );
3796 assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
3797 let hint = err.hint().expect("hint");
3798 assert!(
3799 !hint.contains("ign deploy"),
3800 "D-09: the command lives in Display: {hint}"
3801 );
3802 }
3803
3804 #[test]
3805 fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
3806 let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
3807 run_id: "r-7".into(),
3808 env: "prod".into(),
3809 run_dir: Some(".ign/deploy/r-7".into()),
3810 journal: "ign-deploy/gw/m/journal.json".into(),
3811 cause: "write_failed".into(),
3812 }));
3813 let shown = err.to_string();
3814 for needle in [
3815 "r-7",
3816 ".ign/deploy/r-7",
3817 "ign-deploy/gw/m/journal.json",
3818 ".gwbk",
3819 "snapshot",
3820 "by hand",
3821 "ign deploy rollback --run r-7 --env prod",
3824 "ign-deploy/gw/m/snapshots/r-7/",
3825 ] {
3826 assert!(shown.contains(needle), "{needle} missing from: {shown}");
3827 }
3828 assert!(
3829 shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
3830 "the retry precedes the hand deletion: {shown}"
3831 );
3832 assert!(
3833 !shown.contains("run-directory snapshot"),
3834 "the run directory has no snapshot to offer: {shown}"
3835 );
3836 let hint = err.hint().expect("hint");
3837 assert!(
3838 hint.contains("retry") && !hint.contains("run-directory snapshot"),
3839 "{hint}"
3840 );
3841 let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
3842 run_id: "r-7".into(),
3843 env: "prod".into(),
3844 run_dir: None,
3845 journal: "j".into(),
3846 cause: "c".into(),
3847 }));
3848 assert!(!none.to_string().contains("None"), "{none}");
3849 }
3850
3851 #[test]
3852 fn preflight_failed_lists_every_failure_in_order() {
3853 let err = CoreError::DeployPreflightFailed {
3854 failures: vec![
3855 PreflightFailure {
3856 check: "mode_master".into(),
3857 detail: "first".into(),
3858 },
3859 PreflightFailure {
3860 check: "gwbk".into(),
3861 detail: "second".into(),
3862 },
3863 ]
3864 .into(),
3865 };
3866 let shown = err.to_string();
3867 let first = shown.find("mode_master: first").expect("first");
3868 let second = shown.find("gwbk: second").expect("second");
3869 assert!(first < second, "{shown}");
3870 assert_eq!(shown.lines().count(), 3, "{shown}");
3871 }
3872
3873 #[test]
3874 fn apply_refusal_lists_cap_at_ten() {
3875 let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
3876 let shown = CoreError::DestructiveChangeRefused {
3877 gate: "destructive".into(),
3878 keys: keys.clone(),
3879 count: 13,
3880 }
3881 .to_string();
3882 assert!(shown.contains("13 entries"), "{shown}");
3883 assert!(shown.contains("and 3 more"), "{shown}");
3884 assert!(shown.contains("--allow-destructive"), "{shown}");
3885 let drift = CoreError::DestructiveChangeRefused {
3886 gate: "drift".into(),
3887 keys: keys[..1].to_vec(),
3888 count: 1,
3889 }
3890 .to_string();
3891 assert!(drift.contains("--allow-drift"), "{drift}");
3892 assert!(drift.contains("1 entry"), "{drift}");
3893 let downgrade = CoreError::DestructiveChangeRefused {
3894 gate: "downgrade".into(),
3895 keys: vec!["CapApp".into()],
3896 count: 1,
3897 };
3898 let shown = downgrade.to_string();
3899 assert!(shown.contains("downgrade"), "{shown}");
3900 assert!(shown.contains("CapApp"), "{shown}");
3901 assert!(
3902 shown.contains("deploy projects needs --allow-downgrade"),
3903 "{shown}"
3904 );
3905 assert!(!shown.contains("apply needs"), "{shown}");
3906 let first = CoreError::DestructiveChangeRefused {
3907 gate: "first_deploy".into(),
3908 keys: vec!["CapApp".into()],
3909 count: 1,
3910 };
3911 let shown = first.to_string();
3912 assert!(shown.contains("first_deploy"), "{shown}");
3913 assert!(
3914 shown.contains("deploy projects needs --allow-first-change <reason>"),
3915 "{shown}"
3916 );
3917 let hints: Vec<String> = [&downgrade, &first]
3919 .iter()
3920 .map(|e| e.hint().expect("hint"))
3921 .collect();
3922 assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
3923 assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
3924 for hint in &hints {
3925 assert!(!hint.contains("ign "), "{hint}");
3926 assert!(!hint.contains("--allow-destructive"), "{hint}");
3927 }
3928
3929 let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
3930 let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
3931 assert!(blocked.contains("and 15 more"), "{blocked}");
3932 assert!(!blocked.contains("a/10"), "{blocked}");
3933 let live = CoreError::DeployLiveChanged {
3934 run_id: "r".into(),
3935 paths: many[..10].to_vec(),
3936 count: 12,
3937 }
3938 .to_string();
3939 assert!(live.contains("and 2 more"), "{live}");
3940 }
3941}