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 TAG_DELETE_DROP_REFUSAL_PREFIX: &str = "--with-tag-delete would drop provider(s)";
56
57pub const TESTING_ROUTE_PREFIX: &str = "testing/";
67
68pub const TAG_DELETE_ROUTE_NAME: &str = "tagDelete";
75
76pub const GATEWAY_CLIENT_BODY_CAP_BYTES: usize = 4096;
83
84pub const GATEWAY_CLIENT_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
89
90pub fn truncate_api_body(body: &str) -> String {
96 if body.len() <= GATEWAY_CLIENT_BODY_CAP_BYTES {
97 return body.to_string();
98 }
99 let mut end = GATEWAY_CLIENT_BODY_CAP_BYTES;
100 while !body.is_char_boundary(end) {
101 end -= 1;
102 }
103 let mut truncated = body[..end].to_string();
104 truncated.push_str(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER);
105 truncated
106}
107
108#[derive(Debug, thiserror::Error)]
111pub enum CoreError {
112 #[error("internal error: {0}")]
114 Internal(String),
115
116 #[error("{operation} is destructive; rerun with --yes to confirm")]
120 ConfirmationRequired { operation: String },
121
122 #[error(
130 "{operation} actively instruments the gateway JVM; rerun with --i-accept-the-risk to acknowledge the production risk"
131 )]
132 ProfilingRiskNotAccepted { operation: String },
133
134 #[error("invalid import file: {reason}")]
138 InvalidImportFile { reason: String },
139
140 #[error("gateway rejected the project import for {project:?}: {problem}")]
148 ImportDenied {
149 project: String,
151 problem: String,
153 endpoint: Option<String>,
155 },
156
157 #[error("invalid input: {reason}")]
162 InvalidInput { reason: String },
163
164 #[error("profile {name:?} not found (known profiles: {known:?})")]
166 ProfileNotFound { name: String, known: Vec<String> },
167
168 #[error("no active profile configured")]
172 NoActiveProfile,
173
174 #[error("secret unavailable for profile {profile:?}")]
177 SecretUnavailable { profile: String },
178
179 #[error("login password unavailable for profile {profile:?}")]
185 PasswordUnavailable { profile: String },
186
187 #[error("invalid configuration: {reason}")]
189 ConfigInvalid { reason: String },
190
191 #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
198 PollIntervalTooSmall {
199 profile: String,
201 },
202
203 #[error(
215 "{lead} at {url}{source_note}{observation_note}",
216 lead = if observation.is_some() {
217 "no terminal state"
218 } else {
219 "gateway unreachable"
220 },
221 source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
222 observation_note = observation
223 .as_deref()
224 .map(|obs| format!("; last observation: {obs}"))
225 .unwrap_or_default()
226 )]
227 Network {
228 url: String,
229 #[source]
230 source: Option<reqwest::Error>,
231 observation: Option<String>,
236 },
237
238 #[error("gateway rejected credentials (HTTP {status})")]
240 Auth {
241 status: u16,
242 endpoint: Option<String>,
244 },
245
246 #[error("gateway version {found} is below minimum {minimum}")]
250 GatewayTooOld {
251 found: String,
252 minimum: String,
253 endpoint: Option<String>,
255 },
256
257 #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
261 GatewayNotCommissioned {
262 endpoint: Option<String>,
264 },
265
266 #[error("gateway is restarting (webserver up, services down)")]
270 GatewayRestarting {
271 endpoint: Option<String>,
273 },
274
275 #[error("resource not found on the gateway")]
279 NotFound {
280 endpoint: Option<String>,
282 },
283
284 #[error("project {name} already exists on the gateway")]
291 ProjectExists {
292 name: String,
294 endpoint: Option<String>,
296 },
297
298 #[error("resource {path:?} has binary content — not editable via the resource loop")]
304 ResourceBinary {
305 path: String,
307 endpoint: Option<String>,
309 },
310
311 #[error(
320 "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
321 )]
322 TrialNotExpired {
323 remaining_s: i64,
325 endpoint: Option<String>,
327 },
328
329 #[error(
337 "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
338 )]
339 RoutesNotDeployed {
340 project: String,
342 route: String,
344 endpoint: Option<String>,
346 },
347
348 #[error(
353 "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
354 )]
355 WebdevUnlicensed {
356 endpoint: Option<String>,
358 },
359
360 #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
365 RouteVersionMismatch {
366 route: String,
368 deployed: String,
370 expected: String,
373 endpoint: Option<String>,
375 },
376
377 #[error("tag provider {name:?} not found on the gateway")]
384 ProviderNotFound {
385 name: String,
387 endpoint: Option<String>,
389 },
390
391 #[error("webdev route denied the call ({code}): {message}")]
397 WebdevRouteError {
398 code: String,
400 message: String,
402 endpoint: Option<String>,
404 },
405
406 #[error(
413 "tag collision importing into provider {provider:?}: {} already exist(s)",
414 names.join(", ")
415 )]
416 TagCollision {
417 provider: String,
419 names: Vec<String>,
421 endpoint: Option<String>,
423 },
424
425 #[error(
436 "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
437 )]
438 AlarmJournalMissing {
439 endpoint: Option<String>,
441 },
442
443 #[error("designer session {id} is live — the gateway refused the prune")]
452 SessionNotPrunable {
453 id: String,
455 endpoint: Option<String>,
457 },
458
459 #[error("rig error: {0}")]
462 Rig(String),
463
464 #[error(
474 "EAM is not configured as a controller on this gateway — every EAM runtime \
475 operation refuses until the module's installMode is flipped"
476 )]
477 EamNotController {
478 endpoint: Option<String>,
480 },
481
482 #[error(
493 "provider-root tag paths are not supported by the deployed route (the gateway needs an \
494 RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
495 )]
496 ProviderRootUnsupported {
497 endpoint: Option<String>,
499 },
500
501 #[error(
509 "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
510 are EXT-03 (v2) scope; run them from the EAM console)"
511 )]
512 EamTaskTypeRefused {
513 task_type: String,
515 },
516
517 #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
526 EamTaskInFlight {
527 task: String,
529 detail: String,
532 endpoint: Option<String>,
534 },
535
536 #[error(
546 "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
547 only via the explicit opt-in"
548 )]
549 ScriptExecNotConfigured {
550 profile: String,
552 },
553
554 #[error("ignition-lint is not installed (no executable found on PATH)")]
560 LintToolAbsent,
561
562 #[error("{tool} is not installed (no executable found on PATH)")]
572 NodeToolAbsent {
573 tool: String,
575 },
576
577 #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
589 GatewayClientError {
590 status: u16,
592 endpoint: String,
594 body: String,
597 },
598
599 #[error(
609 "no diagnostics bundle available (gateway reports state {state:?}) — run \
610 `ign diagnostics bundle generate` first; polling cannot change this state"
611 )]
612 BundleNotAvailable {
613 state: String,
616 },
617
618 #[error(
627 "module release feed at {url} is unreachable{source_note}",
628 source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default()
629 )]
630 ModuleFeedUnreachable {
631 url: String,
633 #[source]
634 source: Option<reqwest::Error>,
635 },
636
637 #[error("module {module:?} version {version:?} not found at {url}: {detail}")]
644 ModuleReleaseNotFound {
645 module: String,
647 version: String,
649 url: String,
651 detail: String,
654 },
655
656 #[error("{0}")]
669 ModuleDigestMismatch(Box<ModuleDigestMismatchDetails>),
670
671 #[error("module release feed at {url} is unusable: {detail}")]
679 ModuleFeedUnusable {
680 url: String,
682 detail: String,
684 },
685
686 #[error("{0}")]
700 ModuleDigestChanged(Box<ModuleDigestChangedDetails>),
701
702 #[error("module {id:?} is not registered (known modules: {known:?})")]
712 ModuleNotRegistered {
713 id: String,
715 known: Vec<String>,
717 },
718
719 #[error(
732 "gateway refused to uninstall module {module_id:?} ({gateway_module_id}): {}",
733 failed.join(", ")
734 )]
735 ModuleUninstallDenied {
736 module_id: String,
738 gateway_module_id: String,
741 failed: Vec<String>,
744 endpoint: Option<String>,
746 },
747
748 #[error(
755 "the {kind} scan-lock is held by {}",
756 actor.as_deref().unwrap_or("another actor")
757 )]
758 ScanLockHeld {
759 actor: Option<String>,
761 kind: String,
763 endpoint: Option<String>,
765 },
766
767 #[error("gateway refused the write to resource {name:?}: {reason} signature")]
774 ResourceSignatureRejected {
775 name: String,
777 reason: SignatureReason,
779 endpoint: Option<String>,
781 },
782
783 #[error("resource {name:?} already exists")]
789 ResourceExists {
790 name: String,
792 endpoint: Option<String>,
794 },
795
796 #[error("deployment mode {name:?} is already defined")]
800 ModeExists {
801 name: String,
803 endpoint: Option<String>,
805 },
806
807 #[error("deploy tree invalid: {0}")]
814 DeployTreeInvalid(Box<DeployTreeInvalidDetails>),
815
816 #[error("refusing to render an embedded or plaintext secret: {0}")]
820 EmbeddedSecret(Box<EmbeddedSecretDetails>),
821
822 #[error("transport failed ({operation}) on {node}: {detail}")]
830 TransportFailed {
831 operation: String,
833 node: String,
835 detail: String,
837 },
838
839 #[error("declared-names ledger {location} is unusable: {problem}")]
847 DeployLedgerInvalid {
848 location: String,
850 problem: String,
852 },
853
854 #[error("plan changed since review: expected plan_sha256 {expected}, now {actual} ({detail})")]
859 PlanStale {
860 expected: String,
862 actual: String,
864 detail: String,
866 },
867
868 #[error("{}", destructive_change_refused_text(.gate, .keys, *.count))]
873 DestructiveChangeRefused {
874 gate: String,
880 keys: Vec<String>,
882 count: usize,
884 },
885
886 #[error("{}", deploy_blocked_text(.keys))]
890 DeployBlocked {
891 keys: Vec<String>,
893 },
894
895 #[error("{}", deploy_unreadable_text(.node, .keys))]
904 DeployUnreadable {
905 node: String,
907 keys: Vec<String>,
910 },
911
912 #[error("{}", deploy_pair_tags_refused_text(.mode, .keys))]
927 DeployPairTagsRefused {
928 mode: String,
930 keys: Vec<String>,
932 },
933
934 #[error("{}", deploy_preflight_failed_text(.failures))]
938 DeployPreflightFailed {
939 failures: Box<[PreflightFailure]>,
941 },
942
943 #[error(
949 "an interrupted deploy left the journal {location} for run {run_id}; \
950 recover it with: {}",
951 journal_recovery(.run_id, .env, .recover_with.as_deref())
952 )]
953 DeployJournalPresent {
954 run_id: String,
956 env: String,
958 location: String,
960 recover_with: Option<String>,
962 },
963
964 #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
968 DeployLiveChanged {
969 run_id: String,
971 paths: Vec<String>,
973 count: usize,
975 },
976
977 #[error("deploy run {run_id} is unusable: {problem}")]
980 DeployRunInvalid {
981 run_id: String,
983 problem: String,
985 },
986
987 #[error("{0}")]
990 RollbackFailed(Box<RollbackFailure>),
991
992 #[error(
1000 "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
1001 )]
1002 ProjectGitManaged {
1003 project: String,
1005 location: String,
1007 kind: String,
1009 },
1010
1011 #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
1018 ProjectParentInvalid {
1019 project: String,
1021 parent: String,
1023 reason: String,
1025 },
1026
1027 #[error(
1036 "{} mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded",
1037 hash_mismatch_label(.hash)
1038 )]
1039 ProjectHashMismatch {
1040 project: String,
1042 hash: &'static str,
1045 expected: String,
1048 actual: String,
1050 },
1051
1052 #[error(
1059 "tag provider {provider} is already deployed by {claimed_by}; a provider has one owner"
1060 )]
1061 TagProviderClaimed {
1062 provider: String,
1064 claimed_by: String,
1067 },
1068
1069 #[error(
1076 "the tagDelete route is not configured for profile {profile:?} (missing or refused: {}); \
1077 nothing was sent",
1078 missing.join("; ")
1079 )]
1080 TagDeleteNotConfigured {
1081 profile: String,
1083 missing: Vec<String>,
1088 },
1089}
1090
1091pub const APPLY_LIST_SHOWN: usize = 10;
1094
1095#[derive(Debug, Clone, PartialEq, Eq)]
1098pub struct PreflightFailure {
1099 pub check: String,
1101 pub detail: String,
1103}
1104
1105#[derive(Debug, Clone, PartialEq, Eq)]
1107pub struct RollbackFailure {
1108 pub run_id: String,
1110 pub env: String,
1112 pub run_dir: Option<String>,
1114 pub journal: String,
1116 pub cause: String,
1118 pub scope: RollbackScope,
1121}
1122
1123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1125pub enum RollbackScope {
1126 Files,
1129 Projects,
1134}
1135
1136impl RollbackFailure {
1137 pub fn is_undo(&self) -> bool {
1141 self.scope == RollbackScope::Projects && self.cause.starts_with("rollback_run:")
1142 }
1143
1144 pub fn snapshot_dir(&self) -> String {
1147 match self.journal.rsplit_once('/') {
1148 Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1149 None => format!("snapshots/{}/", self.run_id),
1150 }
1151 }
1152}
1153
1154impl std::fmt::Display for RollbackFailure {
1155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1156 if self.is_undo() {
1157 return write!(
1158 f,
1159 "the undo (--rollback-run) {run} did not finish: {cause}. The gateway is partly \
1160 restored; put back what is left by hand from the pre-images the gateway host \
1161 keeps in {journal} (the tag providers' exports, the settings of the providers \
1162 the run modified and the project). ign deploy rollback does not apply to this \
1163 run. run directory: {run_dir}",
1164 run = self.run_id,
1165 cause = self.cause,
1166 journal = self.journal,
1167 run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1168 );
1169 }
1170 if self.scope == RollbackScope::Projects {
1171 return write!(
1174 f,
1175 "the automatic rollback of run {run} did not finish: {cause}. The gateway is \
1176 partly restored; put back what is left by hand from the pre-images in \
1177 {journal} (the tag providers' exports, the settings of the providers the run \
1178 modified and the project). ign deploy rollback does not apply to this run. \
1179 run directory: {run_dir}",
1180 run = self.run_id,
1181 cause = self.cause,
1182 journal = self.journal,
1183 run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1184 );
1185 }
1186 write!(
1191 f,
1192 "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1193 Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1194 and the snapshot are still on the host). If the retry reports a lost or damaged \
1195 snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1196 the on-box snapshot {snapshot}, then delete the journal by hand. \
1197 run directory: {run_dir}",
1198 run = self.run_id,
1199 cause = self.cause,
1200 env = self.env,
1201 journal = self.journal,
1202 snapshot = self.snapshot_dir(),
1203 run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1204 )
1205 }
1206}
1207
1208fn listed(shown: &[String], total: usize) -> String {
1211 let take = shown.len().min(APPLY_LIST_SHOWN);
1212 let mut out = shown[..take].join(", ");
1213 if total > take {
1214 out.push_str(&format!(" and {} more", total - take));
1215 }
1216 out
1217}
1218
1219fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1220 let entries = format!(
1221 "{gate} change refused for {count} entr{}: {}",
1222 if count == 1 { "y" } else { "ies" },
1223 listed(keys, count)
1224 );
1225 if gate == "provider_emptied" {
1227 return format!(
1228 "{entries}; deploy projects never removes every tag a provider's record \
1229 lists, and no flag permits it"
1230 );
1231 }
1232 let (flag, verb) = match gate {
1233 "drift" => ("--allow-drift <reason>", "apply"),
1234 "provider_wipe" => (
1236 "--allow-provider-wipe <provider> and --allow-destructive",
1237 "apply",
1238 ),
1239 "downgrade" => ("--allow-downgrade", "deploy projects"),
1240 "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1241 "tag_removal" => ("--allow-destructive", "deploy projects"),
1243 _ => ("--allow-destructive", "apply"),
1244 };
1245 format!("{entries}; {verb} needs {flag}")
1246}
1247
1248fn deploy_blocked_text(keys: &[String]) -> String {
1249 format!(
1250 "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1251 shadows them, so deleting them from the mode collection cannot take effect: {}",
1252 keys.len(),
1253 listed(keys, keys.len())
1254 )
1255}
1256
1257fn deploy_unreadable_text(node: &str, keys: &[String]) -> String {
1258 format!(
1259 "deploy refused: {} unit(s) the deploy would write or delete have a live path the \
1260 {node} host's sftp server cannot read, so their live copy is unknown: {}; nothing was \
1261 written",
1262 keys.len(),
1263 listed(keys, keys.len())
1264 )
1265}
1266
1267fn hash_mismatch_label(hash: &str) -> String {
1270 match hash {
1271 "artifact_sha256" => "artifact hash".to_string(),
1272 other => format!("owned tags hash ({other})"),
1273 }
1274}
1275
1276fn deploy_pair_tags_refused_text(mode: &str, keys: &[String]) -> String {
1277 format!(
1278 "deploy refused: {} tag unit(s) or folder(s) would leave files or folders in, or restore \
1279 files to, the {mode} collection of a redundant pair, which Ignition's redundancy sync \
1280 cannot carry (the backup stops syncing configuration): {}; \
1281 nothing was written to the {mode} or core collection",
1282 keys.len(),
1283 listed(keys, keys.len())
1284 )
1285}
1286
1287fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1288 let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1289 for failure in failures {
1290 out.push_str(&format!("\n {}: {}", failure.check, failure.detail));
1291 }
1292 out
1293}
1294
1295fn journal_recovery(run_id: &str, env: &str, recover_with: Option<&str>) -> String {
1297 match recover_with {
1298 Some(command) => command.to_string(),
1299 None => format!("ign deploy rollback --run {run_id} --env {env}"),
1300 }
1301}
1302
1303fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1304 format!(
1305 "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1306 snapshot would overwrite them",
1307 listed(paths, count)
1308 )
1309}
1310
1311pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1314
1315#[derive(Debug, Clone, PartialEq, Eq)]
1317pub struct DeployFinding {
1318 pub file: String,
1320 pub line: Option<usize>,
1322 pub column: Option<usize>,
1324 pub message: String,
1326}
1327
1328impl std::fmt::Display for DeployFinding {
1329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1330 match (self.line, self.column) {
1331 (Some(line), Some(column)) => {
1332 write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1333 }
1334 _ => write!(f, "{}: {}", self.file, self.message),
1335 }
1336 }
1337}
1338
1339#[derive(Debug, Clone, PartialEq, Eq)]
1342pub struct DeployTreeInvalidDetails {
1343 pub findings: Vec<DeployFinding>,
1345}
1346
1347impl std::fmt::Display for DeployTreeInvalidDetails {
1348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1349 if let [only] = self.findings.as_slice() {
1350 return write!(f, "{only}");
1351 }
1352 write!(f, "{} findings: ", self.findings.len())?;
1353 write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1354 }
1355}
1356
1357fn write_findings(
1360 f: &mut std::fmt::Formatter<'_>,
1361 findings: &[DeployFinding],
1362 shown: usize,
1363) -> std::fmt::Result {
1364 for (i, finding) in findings.iter().take(shown).enumerate() {
1365 if i > 0 {
1366 f.write_str("; ")?;
1367 }
1368 write!(f, "{finding}")?;
1369 }
1370 if findings.len() > shown {
1371 write!(f, "; and {} more", findings.len() - shown)?;
1372 }
1373 Ok(())
1374}
1375
1376#[derive(Debug, Clone, PartialEq, Eq)]
1380pub struct EmbeddedSecretDetails {
1381 pub paths: Vec<String>,
1383 pub other_findings: Vec<DeployFinding>,
1385}
1386
1387impl std::fmt::Display for EmbeddedSecretDetails {
1388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1389 write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1390 let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1391 f.write_str(&self.paths[..shown_paths].join("; "))?;
1392 if self.paths.len() > shown_paths {
1393 write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1394 }
1395 if !self.other_findings.is_empty() {
1396 write!(
1397 f,
1398 "; also {} other tree finding(s): ",
1399 self.other_findings.len()
1400 )?;
1401 let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1403 write_findings(f, &self.other_findings, room)?;
1404 }
1405 Ok(())
1406 }
1407}
1408
1409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1412pub enum SignatureReason {
1413 Stale,
1416 Missing,
1420}
1421
1422impl std::fmt::Display for SignatureReason {
1423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1424 f.write_str(match self {
1425 Self::Stale => "stale",
1426 Self::Missing => "missing",
1427 })
1428 }
1429}
1430
1431#[derive(Debug, Clone, PartialEq, Eq)]
1434pub struct ModuleDigestMismatchDetails {
1435 pub module: String,
1437 pub version: String,
1439 pub url: String,
1441 pub expected: String,
1443 pub actual: String,
1445}
1446
1447#[derive(Debug, Clone, PartialEq, Eq)]
1450pub struct ModuleDigestChangedDetails {
1451 pub module: String,
1453 pub version: String,
1455 pub cached_digest: String,
1457 pub upstream_digest: String,
1459 pub cached_path: String,
1461}
1462
1463impl std::fmt::Display for ModuleDigestMismatchDetails {
1464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1465 write!(
1466 f,
1467 "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1468 self.module, self.version, self.url, self.expected, self.actual
1469 )
1470 }
1471}
1472
1473impl std::fmt::Display for ModuleDigestChangedDetails {
1474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1475 write!(
1476 f,
1477 "module {:?} version {:?} digest changed upstream: cached {}, \
1478 upstream now reports {} (cached artifact kept at {})",
1479 self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1480 )
1481 }
1482}
1483
1484impl CoreError {
1485 pub fn code(&self) -> &'static str {
1487 match self {
1488 Self::Internal(_) => "internal",
1489 Self::ConfirmationRequired { .. } => "confirmation_required",
1490 Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1491 Self::InvalidImportFile { .. } => "invalid_import_file",
1492 Self::ImportDenied { .. } => "import_denied",
1493 Self::InvalidInput { .. } => "invalid_input",
1494 Self::ProfileNotFound { .. } => "profile_not_found",
1495 Self::NoActiveProfile => "no_active_profile",
1496 Self::SecretUnavailable { .. } => "secret_unavailable",
1497 Self::PasswordUnavailable { .. } => "password_unavailable",
1498 Self::ConfigInvalid { .. } => "config_invalid",
1499 Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1500 Self::Network { .. } => "network_error",
1501 Self::Auth { .. } => "auth_rejected",
1502 Self::GatewayTooOld { .. } => "gateway_too_old",
1503 Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1504 Self::GatewayRestarting { .. } => "gateway_restarting",
1505 Self::NotFound { .. } => "not_found",
1506 Self::ProjectExists { .. } => "project_exists",
1507 Self::ResourceBinary { .. } => "resource_binary",
1508 Self::TrialNotExpired { .. } => "trial_not_expired",
1509 Self::ProviderNotFound { .. } => "provider_not_found",
1510 Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1511 Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1512 Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1513 Self::WebdevRouteError { .. } => "webdev_route_error",
1514 Self::TagCollision { .. } => "tag_collision",
1515 Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1516 Self::SessionNotPrunable { .. } => "session_not_prunable",
1517 Self::Rig(_) => "rig_error",
1518 Self::EamNotController { .. } => "eam_not_controller",
1519 Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1520 Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1521 Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1522 Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1523 Self::LintToolAbsent => "lint_tool_absent",
1524 Self::NodeToolAbsent { .. } => "node_tool_absent",
1525 Self::GatewayClientError { .. } => "gateway_client_error",
1526 Self::BundleNotAvailable { .. } => "bundle_not_available",
1527 Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1528 Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1529 Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1530 Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1531 Self::ModuleDigestChanged(..) => "module_digest_changed",
1532 Self::ModuleNotRegistered { .. } => "module_not_registered",
1533 Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1534 Self::ScanLockHeld { .. } => "scan_lock_held",
1535 Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1536 Self::ResourceExists { .. } => "resource_exists",
1537 Self::ModeExists { .. } => "mode_exists",
1538 Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1539 Self::EmbeddedSecret(..) => "embedded_secret",
1540 Self::TransportFailed { .. } => "transport_failed",
1541 Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1542 Self::PlanStale { .. } => "plan_stale",
1543 Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1544 Self::DeployBlocked { .. } => "deploy_blocked",
1545 Self::DeployUnreadable { .. } => "deploy_unreadable",
1546 Self::DeployPairTagsRefused { .. } => "deploy_pair_tags_refused",
1547 Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1548 Self::DeployJournalPresent { .. } => "deploy_journal_present",
1549 Self::DeployLiveChanged { .. } => "deploy_live_changed",
1550 Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1551 Self::RollbackFailed(..) => "rollback_failed",
1552 Self::ProjectGitManaged { .. } => "project_git_managed",
1553 Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1554 Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1555 Self::TagProviderClaimed { .. } => "tag_provider_claimed",
1556 Self::TagDeleteNotConfigured { .. } => "tag_delete_not_configured",
1557 }
1558 }
1559
1560 pub fn exit_code(&self) -> u8 {
1562 match self {
1563 Self::Internal(_) => 1,
1564 Self::ConfirmationRequired { .. }
1565 | Self::ProfilingRiskNotAccepted { .. }
1566 | Self::InvalidImportFile { .. }
1567 | Self::InvalidInput { .. }
1568 | Self::GatewayClientError { .. }
1569 | Self::DeployTreeInvalid(..)
1570 | Self::EmbeddedSecret(..)
1571 | Self::DestructiveChangeRefused { .. } => 2,
1572 Self::ProfileNotFound { .. }
1573 | Self::NoActiveProfile
1574 | Self::SecretUnavailable { .. }
1575 | Self::PasswordUnavailable { .. }
1576 | Self::ConfigInvalid { .. }
1577 | Self::PollIntervalTooSmall { .. }
1578 | Self::ModuleNotRegistered { .. } => 3,
1579 Self::Network { .. }
1580 | Self::ModuleFeedUnreachable { .. }
1581 | Self::TransportFailed { .. } => 4,
1582 Self::Auth { .. } => 5,
1583 Self::GatewayTooOld { .. }
1584 | Self::GatewayNotCommissioned { .. }
1585 | Self::GatewayRestarting { .. }
1586 | Self::NotFound { .. }
1587 | Self::ProjectExists { .. }
1588 | Self::ResourceBinary { .. }
1589 | Self::TrialNotExpired { .. }
1590 | Self::ProviderNotFound { .. }
1591 | Self::RoutesNotDeployed { .. }
1592 | Self::WebdevUnlicensed { .. }
1593 | Self::RouteVersionMismatch { .. }
1594 | Self::WebdevRouteError { .. }
1595 | Self::TagCollision { .. }
1596 | Self::AlarmJournalMissing { .. }
1597 | Self::SessionNotPrunable { .. }
1598 | Self::ImportDenied { .. }
1599 | Self::EamNotController { .. }
1600 | Self::ProviderRootUnsupported { .. }
1601 | Self::EamTaskTypeRefused { .. }
1602 | Self::EamTaskInFlight { .. }
1603 | Self::ScriptExecNotConfigured { .. }
1604 | Self::LintToolAbsent
1605 | Self::NodeToolAbsent { .. }
1606 | Self::BundleNotAvailable { .. }
1607 | Self::ModuleReleaseNotFound { .. }
1608 | Self::ModuleDigestMismatch(..)
1609 | Self::ModuleFeedUnusable { .. }
1610 | Self::ModuleDigestChanged(..)
1611 | Self::ModuleUninstallDenied { .. }
1612 | Self::ScanLockHeld { .. }
1613 | Self::ResourceSignatureRejected { .. }
1614 | Self::ResourceExists { .. }
1615 | Self::ModeExists { .. }
1616 | Self::DeployLedgerInvalid { .. }
1617 | Self::PlanStale { .. }
1618 | Self::DeployBlocked { .. }
1619 | Self::DeployUnreadable { .. }
1620 | Self::DeployPairTagsRefused { .. }
1621 | Self::DeployPreflightFailed { .. }
1622 | Self::DeployJournalPresent { .. }
1623 | Self::DeployLiveChanged { .. }
1624 | Self::DeployRunInvalid { .. }
1625 | Self::RollbackFailed(..)
1626 | Self::ProjectGitManaged { .. }
1627 | Self::ProjectParentInvalid { .. }
1628 | Self::ProjectHashMismatch { .. }
1629 | Self::TagProviderClaimed { .. }
1630 | Self::TagDeleteNotConfigured { .. } => 6,
1631 Self::Rig(_) => 7,
1632 }
1633 }
1634
1635 pub fn hint(&self) -> Option<String> {
1637 match self {
1638 Self::Internal(_) => Some(
1639 "internal errors are bugs; re-run with -vv and report the \
1640 diagnostics output"
1641 .to_string(),
1642 ),
1643 Self::ConfirmationRequired { .. } => Some(
1644 "this operation is destructive; re-run with --yes or set \
1645 IGNITION_YES=1"
1646 .to_string(),
1647 ),
1648 Self::ProfilingRiskNotAccepted { .. } => Some(
1649 "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1650 adds CPU overhead proportional to thread count x frequency, \
1651 and flight recordings buffer in JVM memory and write to \
1652 gateway disk. On a PRODUCTION server this belongs inside an \
1653 approved change window with a rollback plan — never as a \
1654 casual probe. --yes deliberately does NOT satisfy this gate; \
1655 re-run with --i-accept-the-risk after confirming that"
1656 .to_string(),
1657 ),
1658 Self::InvalidImportFile { .. } => Some(
1659 "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1660 most 512 MB — pass a file produced by `ign project export` \
1661 via --file (or `-` to pipe one on stdin)"
1662 .to_string(),
1663 ),
1664 Self::ImportDenied { problem, .. } => Some(format!(
1665 "the gateway refused the import over a 200 answer — the problem \
1666 text above is the gateway's own; `ign project export` of the \
1667 current state is the honest baseline for hand-editing ({problem})"
1668 )),
1669 Self::InvalidInput { reason } => Some(
1670 if reason == TUI_TTY_REFUSAL_REASON {
1671 "run `ign tui` in an interactive terminal (the cockpit \
1675 needs a TTY on stdout — not a pipe or redirect)"
1676 } else if reason == TOP_TTY_REFUSAL_REASON {
1677 "run `ign top` in an interactive terminal (the monitor \
1681 needs a TTY on stdout — not a pipe or redirect)"
1682 } else if reason.starts_with(TAG_DELETE_DROP_REFUSAL_PREFIX) {
1683 "name every repo whose providers the tagDelete route must keep with a \
1686 repeated --tag-delete-repo, or run `ign webdev deploy` without \
1687 --with-tag-delete first to replace the allowlist deliberately"
1688 } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1689 "the loss report above names what this import would drop or \
1694 coerce — re-run with --yes to import anyway"
1695 } else if reason.starts_with("--plan-sha256 ")
1696 || reason.starts_with("--allow-drift ")
1697 || reason.starts_with("--artifact-sha256 ")
1698 || reason.starts_with("--release ")
1699 || reason.starts_with("--allow-first-change ")
1700 || reason.starts_with("deploy projects needs --artifact-sha256")
1701 || reason.starts_with("--tags-sha256 ")
1702 || reason.starts_with("deploy projects needs --tags-sha256")
1703 {
1704 "correct the flag value named above and rerun; no host or gateway \
1708 was contacted"
1709 } else {
1710 "fix the input source — a readable file path via --file, or `-` \
1711 to pipe the content on stdin"
1712 }
1713 .to_string(),
1714 ),
1715 Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1716 "no profiles configured yet; run `ign profile add` to create \
1717 one"
1718 .to_string()
1719 } else {
1720 format!(
1721 "known profiles: {}; run `ign profile add` to add another",
1722 known.join(", ")
1723 )
1724 }),
1725 Self::NoActiveProfile => Some(
1726 "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1727 active with `ign profile use`"
1728 .to_string(),
1729 ),
1730 Self::SecretUnavailable { profile } => Some(format!(
1731 "set IGNITION_TOKEN (or token_env in the profile), or store a \
1732 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1733 )),
1734 Self::PasswordUnavailable { .. } => Some(
1735 "ign adopt logs in with the gateway admin password — export \
1736 IGNITION_PASSWORD (env-only, never a flag)"
1737 .to_string(),
1738 ),
1739 Self::ConfigInvalid { .. } => Some(
1740 "verify the config file is valid TOML with [profiles.NAME] \
1741 tables; `ign profile add` writes a known-good one"
1742 .to_string(),
1743 ),
1744 Self::PollIntervalTooSmall { profile } => Some(format!(
1745 "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1746 or remove the key to use the default cadence"
1747 )),
1748 Self::Network {
1749 url,
1750 observation,
1751 ..
1752 } => Some(match observation {
1753 Some(observation) => format!(
1754 "the gateway answered but no terminal state arrived before the deadline — \
1755 the last observation was: {observation}; address that state, not the \
1756 connection ({url})"
1757 ),
1758 None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1759 }),
1760 Self::Auth { status, .. } => Some(match status {
1761 401 => {
1762 "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"
1766 }
1767 403 => {
1768 "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"
1771 }
1772 _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1773 }
1774 .to_string()),
1775 Self::GatewayTooOld { minimum, .. } => {
1776 Some(format!("upgrade the gateway to at least {minimum}"))
1777 }
1778 Self::GatewayNotCommissioned { .. } => Some(
1779 "open http://<host>:<port>/welcome in a browser and complete the \
1780 commissioning wizard"
1781 .to_string(),
1782 ),
1783 Self::GatewayRestarting { .. } => Some(
1784 "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1785 ),
1786 Self::NotFound { .. } => Some(
1787 "check the id/path; a 404 JSON 'No route match' body can also mean \
1788 a pre-8.3 gateway"
1789 .to_string(),
1790 ),
1791 Self::ProjectExists { .. } => Some(
1792 "the default collision policy refuses to overwrite; re-run with \
1793 --collision-policy overwrite to replace it — overwrite \
1794 REPLACES the ENTIRE project (resources absent from the ZIP \
1795 are deleted; merge is Designer-only)"
1796 .to_string(),
1797 ),
1798 Self::ResourceBinary { .. } => Some(
1799 "resource content is binary — use `ign project export`/`import` \
1800 for data.bin-class resources"
1801 .to_string(),
1802 ),
1803 Self::TrialNotExpired { .. } => Some(
1804 "wait for the trial to expire (watch `ign rig trial status`), or \
1805 run `ign rig reset --yes` for a completely fresh trial volume"
1806 .to_string(),
1807 ),
1808 Self::RoutesNotDeployed { route, .. } => Some(
1809 if route.starts_with(TESTING_ROUTE_PREFIX) {
1817 "run `ign webdev deploy --with-testing` to install the gateway \
1818 testing bundle into the project (or bootstrap it in one shot \
1819 with `ign adopt --project NAME --testing`), then retry"
1820 } else if route == TAG_DELETE_ROUTE_NAME {
1821 "run `ign webdev deploy --with-tag-delete --tag-delete-repo <repo> \
1824 --tag-delete-user-source <name> --tag-delete-role <role>` to install \
1825 the tagDelete route (rig-only in this release; a deploy without \
1826 --with-tag-delete removes it), then retry"
1827 } else {
1828 "run `ign webdev deploy` to install the CLI's WebDev routes into \
1829 the gateway, then retry"
1830 }
1831 .to_string(),
1832 ),
1833 Self::WebdevUnlicensed { .. } => Some(
1834 "license the gateway — the WebDev module answers 402 while \
1835 unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1836 expired trial)"
1837 .to_string(),
1838 ),
1839 Self::RouteVersionMismatch {
1840 route,
1841 deployed,
1842 expected,
1843 ..
1844 } => {
1845 let newer = semver::Version::parse(deployed)
1850 .ok()
1851 .zip(semver::Version::parse(expected).ok())
1852 .is_some_and(|(deployed, expected)| deployed > expected);
1853 Some(if newer {
1854 "the deployed routes are NEWER than this CLI — update ign \
1855 (the route bundle travels with the binary)"
1856 .to_string()
1857 } else if route == TAG_DELETE_ROUTE_NAME {
1858 "run `ign webdev deploy --with-tag-delete --tag-delete-repo <repo> \
1860 --tag-delete-user-source <name> --tag-delete-role <role>` to \
1861 redeploy the tagDelete route version this CLI expects"
1862 .to_string()
1863 } else {
1864 "run `ign webdev deploy` to redeploy the route version \
1865 this CLI expects"
1866 .to_string()
1867 })
1868 }
1869 Self::ProviderNotFound { .. } => Some(
1870 "check the provider name; `ign tags provider list` shows the \
1871 gateway's tag providers"
1872 .to_string(),
1873 ),
1874 Self::TagCollision { .. } => Some(
1875 "re-run with --collision-policy overwrite to replace the \
1876 existing tags (destructive: requires --yes)"
1877 .to_string(),
1878 ),
1879 Self::AlarmJournalMissing { .. } => Some(
1880 "alarm history needs a journal profile — provision a database \
1881 connection + alarm-journal profile on the gateway (and point \
1882 the general-alarm-settings singleton at it), then retry; see \
1883 the README 'Alarm history' section"
1884 .to_string(),
1885 ),
1886 Self::SessionNotPrunable { .. } => Some(
1887 "close the Designer first — prune removes stale entries only".to_string(),
1888 ),
1889 Self::WebdevRouteError { code, endpoint, .. } => Some(if (code == "secret_required" || code == "secret_mismatch")
1890 && endpoint.as_deref().is_some_and(|endpoint| {
1891 endpoint.ends_with(&format!("/cli/{TAG_DELETE_ROUTE_NAME}"))
1892 })
1893 {
1894 "the tagDelete route is secret-gated — install it with `ign webdev \
1896 deploy --with-tag-delete` (the secret is generated and stored in the \
1897 profile config at 0600 as webdev_tag_delete_secret); a mismatch means \
1898 the route was deployed with a different secret: redeploy or pass \
1899 --rotate-tag-delete-secret"
1900 .to_string()
1901 } else if code == "secret_required" || code == "secret_mismatch" {
1902 "the scriptExec route is secret-gated — deploy it with `ign \
1903 webdev deploy --with-script-exec` (the secret is generated \
1904 and stored in the profile config at 0600); a mismatch means \
1905 the route was deployed with a different secret: redeploy or \
1906 pass --rotate-secret"
1907 .to_string()
1908 } else {
1909 "the deployed route refused the action — the code and message \
1910 are the route's stable contract; `ign webdev status` \
1911 diagnoses the deployment"
1912 .to_string()
1913 }),
1914 Self::EamNotController { .. } => Some(
1915 "flip the gateway's EAM role: config-resource PUT on \
1916 com.inductiveautomation.eam/module-settings with \
1917 installMode \"Controller\" (array body carrying the current \
1918 signature) — a manual gateway-role decision this CLI \
1919 deliberately does not automate; see the README 'EAM tasks' \
1920 section"
1921 .to_string(),
1922 ),
1923 Self::ProviderRootUnsupported { .. } => Some(
1924 "target a subtree path like [provider]folder — provider-ROOT \
1925 forms ([default] alone, or a bare provider name) need an \
1926 RPC context WebDev threads don't carry (8.3.3); subtree \
1927 paths are the supported form"
1928 .to_string(),
1929 ),
1930 Self::EamTaskTypeRefused { .. } => Some(
1931 "restore/install/upgrade tasks dispatch fleet-wide (every \
1932 agent target); run them from the Ignition EAM console — \
1933 EXT-03 (v2) will scope them into the CLI"
1934 .to_string(),
1935 ),
1936 Self::EamTaskInFlight { .. } => Some(
1937 "complete or delete the leftover '(forced)' run from the EAM \
1938 console — no ign verb deletes runs; the slot frees once the \
1939 run is resolved"
1940 .to_string(),
1941 ),
1942 Self::ScriptExecNotConfigured { .. } => Some(
1943 "run `ign webdev deploy --with-script-exec` to deploy the route and \
1944 generate + persist its secret (the deploy flag IS the opt-in — \
1945 `ign script run` has no --yes by design)"
1946 .to_string(),
1947 ),
1948 Self::LintToolAbsent => Some(
1949 "install the linter: `uv tool install ignition-lint-toolkit` \
1950 (or `pip install ignition-lint-toolkit`) — \
1951 github.com/TheThoughtagen/ignition-lint; then re-run with \
1952 ignition-lint on PATH"
1953 .to_string(),
1954 ),
1955 Self::NodeToolAbsent { .. } => {
1959 Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1960 }
1961 Self::GatewayClientError { .. } => Some(
1962 "the gateway rejected this request — the body above is the \
1963 gateway's own answer; fix the path/method/body, or use a \
1964 curated `ign` command when one exists"
1965 .to_string(),
1966 ),
1967 Self::BundleNotAvailable { .. } => Some(
1968 "generate a fresh bundle with `ign diagnostics bundle generate`, \
1969 then wait again — the gateway reports no current bundle and \
1970 polling cannot produce one"
1971 .to_string(),
1972 ),
1973 Self::ModuleFeedUnreachable { .. } => Some(
1974 "check network reachability to the module release feed — an \
1975 already-cached version works fully offline, so this only \
1976 blocks a first-time fetch of a new version"
1977 .to_string(),
1978 ),
1979 Self::ModuleReleaseNotFound {
1980 module, version, ..
1981 } => Some(format!(
1982 "check that {module} version {version} exists as a release upstream \
1983 (the tag and asset name must match exactly) — this CLI never falls \
1984 back to \"latest\""
1985 )),
1986 Self::ModuleDigestMismatch(d) => Some(format!(
1987 "the downloaded bytes for {} version {} do not match \
1988 the feed's published digest — do not trust this artifact; nothing \
1989 was cached, so re-running the fetch tries again from scratch",
1990 d.module,
1991 d.version
1992 )),
1993 Self::ModuleFeedUnusable { .. } => Some(
1994 "the module release feed answered but was not usable — if the \
1995 detail names a rate limit, wait and retry; otherwise the feed's \
1996 response itself needs investigation"
1997 .to_string(),
1998 ),
1999 Self::ModuleDigestChanged(d) => Some(format!(
2000 "upstream re-released this version with different bytes — the cached \
2001 artifact at {} is still there and usable offline; re-run with \
2002 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
2003 new bytes",
2004 d.cached_path
2005 )),
2006 Self::Rig(_) => Some(
2007 "check Docker is running and inspect the rig containers \
2008 (docker ps)"
2009 .to_string(),
2010 ),
2011 Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
2012 "no modules are registered in this build of ign".to_string()
2013 } else {
2014 format!(
2015 "known module ids: {} — check the id passed to --with-module \
2016 or declared under [rigs.NAME.modules.*]",
2017 known.join(", ")
2018 )
2019 }),
2020 Self::ModuleUninstallDenied { .. } => Some(
2021 "the gateway refuses to uninstall a module whose .modl is still \
2022 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
2023 so the mount goes, then retry; a successful uninstall is NOT \
2024 undone by re-declaring the module — recovery today means \
2025 `ign rig reset --yes`, which destroys the rig's data volume"
2026 .to_string(),
2027 ),
2028 Self::ScanLockHeld { .. } => Some(
2029 "the scan-lock is released when the holder's scan runs or its \
2030 holdTimeout expires — wait for that, then retry"
2031 .to_string(),
2032 ),
2033 Self::ResourceSignatureRejected { .. } => Some(
2034 "re-read the resource for its current signature and retry — \
2035 signatures change on every write"
2036 .to_string(),
2037 ),
2038 Self::ResourceExists { .. } => Some(
2039 "a resource of that name is already present — read it for its \
2040 current signature and update it, or choose another name"
2041 .to_string(),
2042 ),
2043 Self::ModeExists { .. } => Some(
2044 "that deployment mode is already defined — list the modes and \
2045 skip the create"
2046 .to_string(),
2047 ),
2048 Self::DeployTreeInvalid(..) => Some(
2049 "fix each file named above in the declared gateway tree; render \
2050 refuses until every finding is resolved"
2051 .to_string(),
2052 ),
2053 Self::EmbeddedSecret(..) => Some(
2054 "store the value in the gateway's secret provider and reference it \
2055 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
2056 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
2057 .to_string(),
2058 ),
2059 Self::TransportFailed { .. } => Some(
2060 "check the node's host access in targets/<env>.yaml: for `local`, that \
2061 data_dir exists and is readable; for `ssh`, the pinned host key, the \
2062 key and reachability. Nothing was read as absent, so no plan was made"
2063 .to_string(),
2064 ),
2065 Self::DeployLedgerInvalid { .. } => Some(
2066 "the delete set is never assumed empty, so no plan was made; inspect or \
2067 restore the ledger file named above (only an apply writes it) and rerun"
2068 .to_string(),
2069 ),
2070 Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
2071 "the live project changed after the plan was drawn; run the check again, \
2072 review what it reports and rerun the deploy, nothing was imported"
2073 .to_string(),
2074 ),
2075 Self::PlanStale { .. } => Some(
2076 "the plan changed after it was reviewed; review the new plan and rerun \
2077 with its plan_sha256, nothing was written"
2078 .to_string(),
2079 ),
2080 Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
2081 "drift" => "live files differ from both the base render and the declared tree; \
2082 review the drift, then rerun with --allow-drift and a reason of at most \
2083 500 characters"
2084 .to_string(),
2085 "provider_wipe" => "the plan deletes every tag definition the ledger lists \
2086 for each tag provider named above while the manifest still lists it in \
2087 tag_providers; to hand a provider to a project repo, drop it from \
2088 tag_providers in the same commit that deletes its tree (its files are then \
2089 left alone); to really delete them, rerun with --allow-provider-wipe naming \
2090 each provider, --allow-destructive and the plan_sha256 you reviewed"
2091 .to_string(),
2092 "downgrade" => "the gateway last received a newer release of this project; \
2093 deploy the newer release, or rerun with --allow-downgrade when the older \
2094 release must replace it"
2095 .to_string(),
2096 "first_deploy" => "this is the first deploy of the project, or of an owned tag \
2097 provider, by this tool and it would change what the gateway runs; release \
2098 what the gateway runs first, or rerun with --allow-first-change and a reason"
2099 .to_string(),
2100 "tag_removal" => "the previous deploy of this project placed the tags named \
2101 above and the repo no longer holds them; review them with --check, then \
2102 rerun with --allow-destructive to delete them; the gateway needs the \
2103 tag-delete route, which webdev deploy --with-tag-delete installs; nothing \
2104 was written"
2105 .to_string(),
2106 "provider_emptied" => "the plan would remove every tag this project's record \
2107 lists for the provider; drop the provider from tag_providers in \
2108 deploy/project.yaml, or keep at least one of its nodes in the repo; \
2109 nothing was written"
2110 .to_string(),
2111 _ => "review the destructive entries named above, then rerun with \
2112 --allow-destructive and the plan_sha256 you reviewed"
2113 .to_string(),
2114 }),
2115 Self::DeployBlocked { .. } => Some(
2116 "a core copy still shadows each unit named above, so no flag permits the \
2117 removal; keep the unit in the declared tree, or remove the core copy on \
2118 the gateway first"
2119 .to_string(),
2120 ),
2121 Self::DeployUnreadable { .. } => Some(
2122 "the live path of each unit named above is too long for the host's sftp \
2123 server; shorten it on the gateway (rename the folder), or, when the gateway \
2124 owns the provider, list the provider in exclude_tag_providers and stop \
2125 declaring it; nothing was written"
2126 .to_string(),
2127 ),
2128 Self::DeployPairTagsRefused { .. } => Some(
2129 "on a redundant pair, tag units are deployed to the gateway's core collection, \
2130 which syncs to the backup, and a deploy moves the files its ledger lists out \
2131 of the mode collection; the units named above have mode-collection files the \
2132 ledger does not list (or the delete set skips), or a rollback would put them \
2133 back; a file or folder in an `ignition/tag-definition` or \
2134 `ignition/tag-type-definition` folder of the mode collection that the deploy \
2135 does not read (an empty folder included), even beside files the deploy \
2136 deletes, counts too; remove those files and their emptied folders by hand \
2137 from the master's mode collection (README runbook T6e), then plan again; \
2138 instead of rolling back a run that moved tags, apply again; \
2139 nothing was written to the mode or core collection"
2140 .to_string(),
2141 ),
2142 Self::DeployPreflightFailed { .. } => Some(
2143 "fix each failed check named above and rerun; no gateway file was written"
2144 .to_string(),
2145 ),
2146 Self::DeployJournalPresent { .. } => Some(
2147 "an earlier deploy was interrupted and its journal is still on the box; run \
2148 the recovery command in the message, then rerun; plan and apply refuse \
2149 until the journal is cleared"
2150 .to_string(),
2151 ),
2152 Self::DeployLiveChanged { .. } => Some(
2153 "someone changed the files named above after the run wrote them; review \
2154 those changes, then rerun the rollback with --discard-live-changes to \
2155 restore the snapshot over them"
2156 .to_string(),
2157 ),
2158 Self::DeployRunInvalid { .. } => Some(
2159 "the run directory or the on-box record is missing or damaged; restore the \
2160 files from the .gwbk backup or the run-directory snapshot instead"
2161 .to_string(),
2162 ),
2163 Self::RollbackFailed(failure) if failure.is_undo() => Some(
2164 "do not deploy again until what the message lists is restored: compare the \
2165 owned tag providers and the project with the pre-images the gateway host \
2166 keeps and put back by hand what the undo could not"
2167 .to_string(),
2168 ),
2169 Self::RollbackFailed(failure) if failure.scope == RollbackScope::Projects => Some(
2170 "do not deploy again until what the message lists is restored: compare the \
2171 owned tag providers and the project with the pre-images in the run \
2172 directory and put back by hand what the rollback could not"
2173 .to_string(),
2174 ),
2175 Self::RollbackFailed(..) => Some(
2176 "do not apply again until the files are restored: run the retry command in \
2177 the message first, because the journal and the snapshot are usually intact; \
2178 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
2179 or the on-box snapshot named there, and then delete the journal at the path \
2180 in the message by hand"
2181 .to_string(),
2182 ),
2183 Self::ProjectGitManaged { .. } => Some(
2184 "follow the README cutover runbook: remove this project's .git on the \
2185 gateway (the deploy tool never deletes it), then run the check again"
2186 .to_string(),
2187 ),
2188 Self::ProjectParentInvalid { reason, .. } => Some(
2189 match reason.as_str() {
2190 "missing" => "deploy the parent project from its own repo first, then run \
2191 the check again",
2192 "cycle" => "fix the parent in project.json so the inheritance has no cycle",
2193 _ => "the parent is not inheritable; fix the parent in project.json or \
2194 make the parent inheritable",
2195 }
2196 .to_string(),
2197 ),
2198 Self::ProjectHashMismatch { hash, .. } => Some(format!(
2199 "the checkout is not the reviewed one; rerun the check on this commit and \
2200 pass its {}",
2201 if *hash == "tags_sha256" {
2202 "data.tags.tags_sha256"
2203 } else {
2204 hash
2205 }
2206 )),
2207 Self::TagProviderClaimed { .. } => Some(
2208 "hand the provider over first: remove it from the gateway-config manifest's \
2209 tag_providers (and its tags/ tree there), or drop it from the project's \
2210 tag_providers; nothing was contacted"
2211 .to_string(),
2212 ),
2213 Self::TagDeleteNotConfigured { profile, .. } => {
2214 let (user, password) = crate::config::secret::tag_delete_env_names(profile);
2215 Some(format!(
2216 "set {user} and {password} in the environment (env only) to a user of the \
2217 route's user source holding its role; store the route secret with `ign \
2218 webdev deploy --with-tag-delete --tag-delete-repo <repo> \
2219 --tag-delete-user-source <name> --tag-delete-role <role>`; and use an \
2220 https gateway URL with ssl_verify on (plain http, or ssl_verify off, \
2221 only to a loopback host)"
2222 ))
2223 }
2224 }
2225 }
2226
2227 pub fn endpoint(&self) -> Option<String> {
2230 match self {
2231 Self::Network { url, .. } => Some(url.clone()),
2232 Self::Auth { endpoint, .. } => endpoint.clone(),
2233 Self::GatewayTooOld { endpoint, .. }
2234 | Self::GatewayNotCommissioned { endpoint }
2235 | Self::GatewayRestarting { endpoint }
2236 | Self::NotFound { endpoint }
2237 | Self::ProjectExists { endpoint, .. }
2238 | Self::ResourceBinary { endpoint, .. }
2239 | Self::TrialNotExpired { endpoint, .. }
2240 | Self::ProviderNotFound { endpoint, .. }
2241 | Self::RoutesNotDeployed { endpoint, .. }
2242 | Self::WebdevUnlicensed { endpoint }
2243 | Self::RouteVersionMismatch { endpoint, .. }
2244 | Self::WebdevRouteError { endpoint, .. }
2245 | Self::TagCollision { endpoint, .. }
2246 | Self::AlarmJournalMissing { endpoint }
2247 | Self::SessionNotPrunable { endpoint, .. }
2248 | Self::ImportDenied { endpoint, .. }
2249 | Self::EamNotController { endpoint }
2250 | Self::ProviderRootUnsupported { endpoint }
2251 | Self::EamTaskInFlight { endpoint, .. }
2252 | Self::ModuleUninstallDenied { endpoint, .. }
2253 | Self::ScanLockHeld { endpoint, .. }
2254 | Self::ResourceSignatureRejected { endpoint, .. }
2255 | Self::ResourceExists { endpoint, .. }
2256 | Self::ModeExists { endpoint, .. } => endpoint.clone(),
2257 Self::ModuleFeedUnreachable { url, .. }
2258 | Self::ModuleReleaseNotFound { url, .. }
2259 | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
2260 Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
2262 _ => None,
2263 }
2264 }
2265
2266 pub const DEPLOY_SLUGS: &'static [&'static str] = &[
2268 "deploy_tree_invalid",
2269 "embedded_secret",
2270 "transport_failed",
2271 "deploy_ledger_invalid",
2272 "plan_stale",
2273 "destructive_change_refused",
2274 "deploy_blocked",
2275 "deploy_preflight_failed",
2276 "deploy_journal_present",
2277 "deploy_live_changed",
2278 "deploy_run_invalid",
2279 "rollback_failed",
2280 "project_git_managed",
2281 "project_parent_invalid",
2282 "project_hash_mismatch",
2283 "deploy_unreadable",
2284 "deploy_pair_tags_refused",
2285 "tag_provider_claimed",
2286 "tag_delete_not_configured",
2287 ];
2288
2289 pub fn deploy_tree_invalid(
2292 file: impl Into<String>,
2293 line: Option<usize>,
2294 column: Option<usize>,
2295 message: impl Into<String>,
2296 ) -> Self {
2297 Self::deploy_findings(vec![DeployFinding {
2298 file: file.into(),
2299 line,
2300 column,
2301 message: message.into(),
2302 }])
2303 }
2304
2305 pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
2309 debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
2310 let findings = if findings.is_empty() {
2311 vec![DeployFinding {
2312 file: String::new(),
2313 line: None,
2314 column: None,
2315 message: "the declared tree is invalid".to_string(),
2316 }]
2317 } else {
2318 findings
2319 };
2320 Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
2321 }
2322
2323 pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
2327 Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
2328 paths,
2329 other_findings,
2330 }))
2331 }
2332
2333 pub fn tui_tty_refusal() -> Self {
2338 Self::InvalidInput {
2339 reason: TUI_TTY_REFUSAL_REASON.to_string(),
2340 }
2341 }
2342
2343 pub fn top_tty_refusal() -> Self {
2347 Self::InvalidInput {
2348 reason: TOP_TTY_REFUSAL_REASON.to_string(),
2349 }
2350 }
2351
2352 pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2356 ErrorEnvelope {
2357 ok: false,
2358 profile,
2359 error: ErrorBody {
2360 code: self.code(),
2361 message: self.to_string(),
2362 endpoint: self.endpoint(),
2363 hint: self.hint(),
2364 },
2365 }
2366 }
2367}
2368
2369#[derive(Debug, Serialize)]
2372pub struct ErrorEnvelope<'a> {
2373 pub ok: bool,
2375 pub profile: Option<&'a str>,
2378 pub error: ErrorBody,
2380}
2381
2382#[derive(Debug, Serialize)]
2385pub struct ErrorBody {
2386 pub code: &'static str,
2388 pub message: String,
2390 pub endpoint: Option<String>,
2392 pub hint: Option<String>,
2394}
2395
2396#[cfg(test)]
2397mod tests {
2398 use super::{
2399 CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2400 GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2401 ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2402 RollbackScope, SignatureReason, TAG_DELETE_ROUTE_NAME, TESTING_ROUTE_PREFIX,
2403 truncate_api_body,
2404 };
2405
2406 fn network_error() -> CoreError {
2410 crate::client::install_crypto_provider();
2417 let rt = tokio::runtime::Builder::new_current_thread()
2418 .enable_all()
2419 .build()
2420 .expect("test runtime");
2421 let url = "http://127.0.0.1:1";
2422 let source = rt
2423 .block_on(reqwest::get(url))
2424 .expect_err("request to an unroutable port must fail");
2425 CoreError::Network {
2426 url: url.to_string(),
2427 source: Some(source),
2428 observation: None,
2429 }
2430 }
2431
2432 fn module_feed_unreachable_error() -> CoreError {
2436 crate::client::install_crypto_provider();
2437 let rt = tokio::runtime::Builder::new_current_thread()
2438 .enable_all()
2439 .build()
2440 .expect("test runtime");
2441 let url = "http://127.0.0.1:1";
2442 let source = rt
2443 .block_on(reqwest::get(url))
2444 .expect_err("request to an unroutable port must fail");
2445 CoreError::ModuleFeedUnreachable {
2446 url: url.to_string(),
2447 source: Some(source),
2448 }
2449 }
2450
2451 #[test]
2455 fn exit_code_mapping_enumerated() {
2456 let cases: Vec<(CoreError, u8, &'static str)> = vec![
2457 (CoreError::Internal("boom".into()), 1, "internal"),
2458 (
2459 CoreError::ConfirmationRequired {
2460 operation: "project download".into(),
2461 },
2462 2,
2463 "confirmation_required",
2464 ),
2465 (
2466 CoreError::ProfilingRiskNotAccepted {
2467 operation: "jvm sample".into(),
2468 },
2469 2,
2470 "profiling_risk_not_accepted",
2471 ),
2472 (
2473 CoreError::InvalidImportFile {
2474 reason: "missing ZIP magic".into(),
2475 },
2476 2,
2477 "invalid_import_file",
2478 ),
2479 (
2480 CoreError::InvalidInput {
2481 reason: "cannot read put.json".into(),
2482 },
2483 2,
2484 "invalid_input",
2485 ),
2486 (
2487 CoreError::ProfileNotFound {
2488 name: "nope".into(),
2489 known: vec!["dev".into()],
2490 },
2491 3,
2492 "profile_not_found",
2493 ),
2494 (CoreError::NoActiveProfile, 3, "no_active_profile"),
2495 (
2496 CoreError::SecretUnavailable {
2497 profile: "dev".into(),
2498 },
2499 3,
2500 "secret_unavailable",
2501 ),
2502 (
2503 CoreError::PasswordUnavailable {
2504 profile: "dev".into(),
2505 },
2506 3,
2507 "password_unavailable",
2508 ),
2509 (
2510 CoreError::ConfigInvalid {
2511 reason: "bad toml".into(),
2512 },
2513 3,
2514 "config_invalid",
2515 ),
2516 (
2517 CoreError::PollIntervalTooSmall {
2518 profile: "dev".into(),
2519 },
2520 3,
2521 "poll_interval_too_small",
2522 ),
2523 (network_error(), 4, "network_error"),
2524 (
2525 CoreError::Auth {
2526 status: 401,
2527 endpoint: None,
2528 },
2529 5,
2530 "auth_rejected",
2531 ),
2532 (
2533 CoreError::GatewayTooOld {
2534 found: "8.1.0".into(),
2535 minimum: "8.3.1".into(),
2536 endpoint: None,
2537 },
2538 6,
2539 "gateway_too_old",
2540 ),
2541 (
2542 CoreError::GatewayNotCommissioned {
2543 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2544 },
2545 6,
2546 "gateway_not_commissioned",
2547 ),
2548 (
2549 CoreError::GatewayRestarting {
2550 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2551 },
2552 6,
2553 "gateway_restarting",
2554 ),
2555 (
2556 CoreError::NotFound {
2557 endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2558 },
2559 6,
2560 "not_found",
2561 ),
2562 (
2563 CoreError::ProjectExists {
2564 name: "PlantFloor".into(),
2565 endpoint: None,
2566 },
2567 6,
2568 "project_exists",
2569 ),
2570 (
2571 CoreError::ResourceBinary {
2572 path: "com.x/perspective/session-permissions".into(),
2573 endpoint: None,
2574 },
2575 6,
2576 "resource_binary",
2577 ),
2578 (
2579 CoreError::TrialNotExpired {
2580 remaining_s: 6590,
2581 endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2582 },
2583 6,
2584 "trial_not_expired",
2585 ),
2586 (
2587 CoreError::ProviderNotFound {
2588 name: "nope".into(),
2589 endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2590 },
2591 6,
2592 "provider_not_found",
2593 ),
2594 (
2595 CoreError::RoutesNotDeployed {
2596 project: "ign-cli".into(),
2597 route: "tags".into(),
2598 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2599 },
2600 6,
2601 "routes_not_deployed",
2602 ),
2603 (
2604 CoreError::WebdevUnlicensed {
2605 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2606 },
2607 6,
2608 "webdev_unlicensed",
2609 ),
2610 (
2611 CoreError::RouteVersionMismatch {
2612 route: "tags".into(),
2613 deployed: "0.9.0".into(),
2614 expected: "1.0.0".into(),
2615 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2616 },
2617 6,
2618 "route_version_mismatch",
2619 ),
2620 (
2621 CoreError::WebdevRouteError {
2622 code: "route_error".into(),
2623 message: "boom".into(),
2624 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2625 },
2626 6,
2627 "webdev_route_error",
2628 ),
2629 (
2630 CoreError::TagCollision {
2631 provider: "p5import".into(),
2632 names: vec!["T1".into(), "P5".into()],
2633 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2634 },
2635 6,
2636 "tag_collision",
2637 ),
2638 (
2639 CoreError::AlarmJournalMissing {
2640 endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2641 },
2642 6,
2643 "alarm_journal_missing",
2644 ),
2645 (
2646 CoreError::ImportDenied {
2647 project: "PlantFloor".into(),
2648 problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2649 endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2650 },
2651 6,
2652 "import_denied",
2653 ),
2654 (
2655 CoreError::SessionNotPrunable {
2656 id: "d-live-1".into(),
2657 endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2658 },
2659 6,
2660 "session_not_prunable",
2661 ),
2662 (
2663 CoreError::EamNotController {
2664 endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2665 },
2666 6,
2667 "eam_not_controller",
2668 ),
2669 (
2670 CoreError::ProviderRootUnsupported {
2671 endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2672 },
2673 6,
2674 "provider_root_unsupported",
2675 ),
2676 (
2677 CoreError::EamTaskTypeRefused {
2678 task_type: "eam_remoteUpgrade".into(),
2679 },
2680 6,
2681 "eam_task_type_refused",
2682 ),
2683 (
2684 CoreError::EamTaskInFlight {
2685 task: "cli-research-backup".into(),
2686 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(),
2687 endpoint: Some(
2688 "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2689 .into(),
2690 ),
2691 },
2692 6,
2693 "eam_task_in_flight",
2694 ),
2695 (
2696 CoreError::ScriptExecNotConfigured {
2697 profile: "dev".into(),
2698 },
2699 6,
2700 "script_exec_not_configured",
2701 ),
2702 (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2703 (
2704 CoreError::NodeToolAbsent {
2705 tool: "node".into(),
2706 },
2707 6,
2708 "node_tool_absent",
2709 ),
2710 (
2711 CoreError::BundleNotAvailable {
2712 state: "Invalid".into(),
2713 },
2714 6,
2715 "bundle_not_available",
2716 ),
2717 (
2718 CoreError::GatewayClientError {
2719 status: 400,
2720 endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2721 body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2722 },
2723 2,
2724 "gateway_client_error",
2725 ),
2726 (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2727 (
2728 module_feed_unreachable_error(),
2729 4,
2730 "module_feed_unreachable",
2731 ),
2732 (
2733 CoreError::ModuleReleaseNotFound {
2734 module: "git".into(),
2735 version: "99.99.99".into(),
2736 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2737 detail: "no release exists for tag \"v99.99.99\"".into(),
2738 },
2739 6,
2740 "module_release_not_found",
2741 ),
2742 (
2743 CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2744 module: "git".into(),
2745 version: "2.3.4".into(),
2746 url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2747 expected: "b".repeat(64),
2748 actual: "c".repeat(64),
2749 })),
2750 6,
2751 "module_digest_mismatch",
2752 ),
2753 (
2754 CoreError::ModuleFeedUnusable {
2755 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2756 detail: "rate limit exceeded".into(),
2757 },
2758 6,
2759 "module_feed_unusable",
2760 ),
2761 (
2762 CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2763 module: "git".into(),
2764 version: "2.3.4".into(),
2765 cached_digest: "a".repeat(64),
2766 upstream_digest: "b".repeat(64),
2767 cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2768 })),
2769 6,
2770 "module_digest_changed",
2771 ),
2772 (
2773 CoreError::ModuleNotRegistered {
2774 id: "bogus".into(),
2775 known: vec!["git".into()],
2776 },
2777 3,
2778 "module_not_registered",
2779 ),
2780 (
2781 CoreError::ModuleUninstallDenied {
2782 module_id: "git".into(),
2783 gateway_module_id: "com.axone_io.ignition.git".into(),
2784 failed: vec!["com.axone_io.ignition.git".into()],
2785 endpoint: Some(
2786 "http://gw:8088/data/api/v1/modules/uninstall".into(),
2787 ),
2788 },
2789 6,
2790 "module_uninstall_denied",
2791 ),
2792 (
2793 CoreError::ScanLockHeld {
2794 actor: Some("api-token:cap-a:198.51.100.1".into()),
2795 kind: "config".into(),
2796 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2797 },
2798 6,
2799 "scan_lock_held",
2800 ),
2801 (
2802 CoreError::ResourceSignatureRejected {
2803 name: "DB_A".into(),
2804 reason: SignatureReason::Stale,
2805 endpoint: Some(
2806 "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2807 .into(),
2808 ),
2809 },
2810 6,
2811 "resource_signature_rejected",
2812 ),
2813 (
2814 CoreError::ResourceExists {
2815 name: "DB_A".into(),
2816 endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2817 },
2818 6,
2819 "resource_exists",
2820 ),
2821 (
2822 CoreError::ModeExists {
2823 name: "production".into(),
2824 endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2825 },
2826 6,
2827 "mode_exists",
2828 ),
2829 (
2830 CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2831 2,
2832 "deploy_tree_invalid",
2833 ),
2834 (
2835 CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2836 2,
2837 "embedded_secret",
2838 ),
2839 (
2840 CoreError::TransportFailed {
2841 operation: "read".into(),
2842 node: "nodes.master".into(),
2843 detail: "cannot read directory config/resources (NotFound)".into(),
2844 },
2845 4,
2846 "transport_failed",
2847 ),
2848 (
2849 CoreError::DeployLedgerInvalid {
2850 location: "ign-deploy/gw/dev/ledger.json".into(),
2851 problem: "files is not byte-sorted".into(),
2852 },
2853 6,
2854 "deploy_ledger_invalid",
2855 ),
2856 (
2857 CoreError::PlanStale {
2858 expected: "a".repeat(64),
2859 actual: "b".repeat(64),
2860 detail: "review hash".into(),
2861 },
2862 6,
2863 "plan_stale",
2864 ),
2865 (
2866 CoreError::DestructiveChangeRefused {
2867 gate: "destructive".into(),
2868 keys: vec!["ignition/a/x".into()],
2869 count: 1,
2870 },
2871 2,
2872 "destructive_change_refused",
2873 ),
2874 (
2875 CoreError::DeployBlocked {
2876 keys: vec!["ignition/a/x".into()],
2877 },
2878 6,
2879 "deploy_blocked",
2880 ),
2881 (
2882 CoreError::DeployPreflightFailed {
2883 failures: vec![PreflightFailure {
2884 check: "mode_master".into(),
2885 detail: "the gateway is not in the declared mode".into(),
2886 }]
2887 .into(),
2888 },
2889 6,
2890 "deploy_preflight_failed",
2891 ),
2892 (
2893 CoreError::DeployJournalPresent {
2894 run_id: "r1".into(),
2895 env: "prod".into(),
2896 location: "ign-deploy/gw/m/journal.json".into(),
2897 recover_with: None,
2898 },
2899 6,
2900 "deploy_journal_present",
2901 ),
2902 (
2903 CoreError::DeployLiveChanged {
2904 run_id: "r1".into(),
2905 paths: vec!["a/b.json".into()],
2906 count: 1,
2907 },
2908 6,
2909 "deploy_live_changed",
2910 ),
2911 (
2912 CoreError::DeployRunInvalid {
2913 run_id: "r1".into(),
2914 problem: "record.json is missing".into(),
2915 },
2916 6,
2917 "deploy_run_invalid",
2918 ),
2919 (
2920 CoreError::RollbackFailed(Box::new(RollbackFailure {
2921 run_id: "r1".into(),
2922 env: "dev".into(),
2923 run_dir: Some(".ign/deploy/r1".into()),
2924 journal: "ign-deploy/gw/m/journal.json".into(),
2925 cause: "write_failed".into(),
2926 scope: RollbackScope::Files,
2927 })),
2928 6,
2929 "rollback_failed",
2930 ),
2931 (
2932 CoreError::ProjectGitManaged {
2933 project: "CapApp".into(),
2934 location: "projects/CapApp/.git".into(),
2935 kind: "directory".into(),
2936 },
2937 6,
2938 "project_git_managed",
2939 ),
2940 (
2941 CoreError::ProjectParentInvalid {
2942 project: "CapApp".into(),
2943 parent: "ParentProject".into(),
2944 reason: "missing".into(),
2945 },
2946 6,
2947 "project_parent_invalid",
2948 ),
2949 (
2950 CoreError::ProjectHashMismatch {
2951 project: "CapApp".into(),
2952 hash: "artifact_sha256",
2953 expected: "0".repeat(64),
2954 actual: "1".repeat(64),
2955 },
2956 6,
2957 "project_hash_mismatch",
2958 ),
2959 (
2960 CoreError::DeployUnreadable {
2961 node: "master".into(),
2962 keys: vec!["ignition/tag-definition/default/Deep".into()],
2963 },
2964 6,
2965 "deploy_unreadable",
2966 ),
2967 (
2968 CoreError::DeployPairTagsRefused {
2969 mode: "production".into(),
2970 keys: vec!["ignition/tag-definition/default/Deploy".into()],
2971 },
2972 6,
2973 "deploy_pair_tags_refused",
2974 ),
2975 (
2976 CoreError::TagProviderClaimed {
2977 provider: "PlantA".into(),
2978 claimed_by: "the gateway-config manifest gateways/capgw/manifest.yaml".into(),
2979 },
2980 6,
2981 "tag_provider_claimed",
2982 ),
2983 (
2984 CoreError::TagDeleteNotConfigured {
2985 profile: "dev".into(),
2986 missing: vec!["webdev_tag_delete_secret".into()],
2987 },
2988 6,
2989 "tag_delete_not_configured",
2990 ),
2991 ];
2992 for (err, code, slug) in cases {
2993 assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2994 assert_eq!(err.code(), slug, "unstable slug for: {err}");
2995 }
2996 }
2997
2998 const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
3007 (1, "internal"),
3008 (2, "confirmation_required"),
3009 (2, "profiling_risk_not_accepted"),
3010 (2, "invalid_import_file"),
3011 (2, "invalid_input"),
3012 (2, "gateway_client_error"),
3013 (2, "deploy_tree_invalid"),
3014 (2, "embedded_secret"),
3015 (2, "destructive_change_refused"),
3016 (3, "profile_not_found"),
3017 (3, "no_active_profile"),
3018 (3, "secret_unavailable"),
3019 (3, "password_unavailable"),
3020 (3, "config_invalid"),
3021 (3, "poll_interval_too_small"),
3022 (3, "module_not_registered"),
3023 (4, "network_error"),
3024 (5, "auth_rejected"),
3025 (6, "gateway_too_old"),
3026 (6, "gateway_not_commissioned"),
3027 (6, "gateway_restarting"),
3028 (6, "not_found"),
3029 (6, "project_exists"),
3030 (6, "resource_binary"),
3031 (6, "trial_not_expired"),
3032 (6, "provider_not_found"),
3033 (6, "routes_not_deployed"),
3034 (6, "webdev_unlicensed"),
3035 (6, "route_version_mismatch"),
3036 (6, "webdev_route_error"),
3037 (6, "tag_collision"),
3038 (6, "alarm_journal_missing"),
3039 (6, "import_denied"),
3040 (6, "session_not_prunable"),
3041 (6, "eam_not_controller"),
3042 (6, "eam_task_type_refused"),
3043 (6, "eam_task_in_flight"),
3044 (6, "script_exec_not_configured"),
3045 (6, "lint_tool_absent"),
3046 (6, "node_tool_absent"),
3047 (6, "provider_root_unsupported"),
3048 (6, "bundle_not_available"),
3049 (4, "module_feed_unreachable"),
3050 (4, "transport_failed"),
3051 (6, "module_release_not_found"),
3052 (6, "module_digest_mismatch"),
3053 (6, "module_feed_unusable"),
3054 (6, "module_digest_changed"),
3055 (6, "module_uninstall_denied"),
3056 (6, "scan_lock_held"),
3057 (6, "resource_signature_rejected"),
3058 (6, "resource_exists"),
3059 (6, "mode_exists"),
3060 (6, "deploy_ledger_invalid"),
3061 (6, "plan_stale"),
3062 (6, "deploy_blocked"),
3063 (6, "deploy_preflight_failed"),
3064 (6, "deploy_journal_present"),
3065 (6, "deploy_live_changed"),
3066 (6, "deploy_run_invalid"),
3067 (6, "rollback_failed"),
3068 (6, "project_git_managed"),
3069 (6, "project_parent_invalid"),
3070 (6, "project_hash_mismatch"),
3071 (6, "deploy_unreadable"),
3072 (6, "deploy_pair_tags_refused"),
3073 (6, "tag_provider_claimed"),
3074 (6, "tag_delete_not_configured"),
3075 (7, "rig_error"),
3076 ];
3077
3078 #[test]
3081 fn deploy_slugs_never_carry_signature_or_problem_text() {
3082 const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
3083
3084 let held = CoreError::ScanLockHeld {
3085 actor: Some("api-token:cap-a:198.51.100.1".into()),
3086 kind: "config".into(),
3087 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
3088 };
3089 assert_eq!(held.code(), "scan_lock_held");
3090 assert_eq!(held.exit_code(), 6);
3091 let shown = held.to_string();
3092 assert!(shown.contains("api-token:cap-a:198.51.100.1"), "{shown}");
3093 assert!(shown.contains("config"), "{shown}");
3094 assert_eq!(
3095 held.endpoint().as_deref(),
3096 Some("http://gw:8088/data/api/v1/scan-lock/config")
3097 );
3098
3099 let anonymous = CoreError::ScanLockHeld {
3100 actor: None,
3101 kind: "config".into(),
3102 endpoint: None,
3103 };
3104 let shown = anonymous.to_string();
3105 assert!(shown.contains("another actor"), "{shown}");
3106 assert!(!shown.contains("None"), "{shown}");
3107
3108 for reason in [SignatureReason::Stale, SignatureReason::Missing] {
3109 let err = CoreError::ResourceSignatureRejected {
3110 name: "DB_A".into(),
3111 reason,
3112 endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
3113 };
3114 assert_eq!(err.code(), "resource_signature_rejected");
3115 assert_eq!(err.exit_code(), 6);
3116 let shown = err.to_string();
3117 assert!(shown.contains("DB_A"), "{shown}");
3118 assert!(shown.contains(&reason.to_string()), "{shown}");
3119 assert!(!shown.contains(SAMPLE_SIG), "{shown}");
3120 let hint = err.hint().expect("hint required");
3121 assert!(!hint.contains(SAMPLE_SIG), "{hint}");
3122 let endpoint = err.endpoint().expect("endpoint");
3123 assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
3124 }
3125 assert_eq!(SignatureReason::Stale.to_string(), "stale");
3126 assert_eq!(SignatureReason::Missing.to_string(), "missing");
3127 }
3128
3129 fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
3138 let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
3139 let mut rows = Vec::new();
3140 let table_lines = section
3141 .lines()
3142 .skip_while(|line| !line.trim_start().starts_with('|'));
3143 for line in table_lines {
3144 if !line.trim_start().starts_with('|') {
3145 break; }
3147 let cells: Vec<&str> = line.split('|').map(str::trim).collect();
3148 let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
3149 continue;
3150 };
3151 if !(1..=7).contains(&exit) {
3152 continue;
3153 }
3154 let slug_cell = cells
3155 .iter()
3156 .rev()
3157 .find(|cell| !cell.is_empty())
3158 .copied()
3159 .unwrap_or_default();
3160 let slugs: Vec<String> = slug_cell
3161 .split('`')
3162 .enumerate()
3163 .filter(|(idx, _)| idx % 2 == 1)
3164 .map(|(_, token)| token.trim().to_string())
3165 .filter(|token| {
3166 token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
3167 })
3168 .collect();
3169 rows.push((exit, slugs));
3170 }
3171 rows
3172 }
3173
3174 #[test]
3185 fn readme_exit_table_agreement() {
3186 let readme = include_str!("../../../README.md");
3187 let rows = parse_readme_exit_table(readme);
3188 assert!(
3189 rows.len() >= 7,
3190 "README exit-code table not found — the parser must see all 7 \
3191 failure-class rows (found {})",
3192 rows.len()
3193 );
3194
3195 for (exit, slug) in EXIT_SLUG_LITERALS {
3198 let row = rows
3199 .iter()
3200 .find(|(readme_exit, _)| readme_exit == exit)
3201 .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
3202 assert!(
3203 row.1.iter().any(|s| s == slug),
3204 "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
3205 row.1
3206 );
3207 }
3208
3209 for (exit, slugs) in &rows {
3212 for slug in slugs {
3213 assert!(
3214 EXIT_SLUG_LITERALS
3215 .iter()
3216 .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
3217 "README exit-{exit} row carries slug {slug:?} that no \
3218 CoreError variant emits — stale table row or slug/exit \
3219 drift between README and error.rs"
3220 );
3221 }
3222 }
3223 }
3224
3225 #[test]
3228 fn hints_are_actionable_for_config_auth_target_state() {
3229 let profile_not_found = CoreError::ProfileNotFound {
3230 name: "x".into(),
3231 known: vec!["dev".into(), "prod".into()],
3232 };
3233 let hint = profile_not_found.hint().expect("hint required");
3234 assert!(
3235 hint.contains("dev"),
3236 "hint must list known profiles: {hint}"
3237 );
3238 assert!(
3239 hint.contains("ign profile add"),
3240 "hint must name the fix: {hint}"
3241 );
3242
3243 let auth = CoreError::Auth {
3244 status: 403,
3245 endpoint: None,
3246 };
3247 let hint = auth.hint().expect("hint required");
3248 assert!(
3249 hint.contains("three parts"),
3250 "auth hint must name the three-part token setup: {hint}"
3251 );
3252
3253 let unauthorized = CoreError::Auth {
3256 status: 401,
3257 endpoint: None,
3258 };
3259 let hint = unauthorized.hint().expect("hint required");
3260 assert!(
3261 hint.contains("name:key"),
3262 "401 hint must name the full name:key token format: {hint}"
3263 );
3264 assert!(
3265 hint.contains("API token"),
3266 "401 hint must say Basic cannot work: {hint}"
3267 );
3268 let hint403 = auth.hint().expect("hint required");
3269 assert!(
3270 hint403.contains("secure connections"),
3271 "403 hint must name the secure-channel part: {hint403}"
3272 );
3273
3274 let too_old = CoreError::GatewayTooOld {
3275 found: "8.1.0".into(),
3276 minimum: "8.3.1".into(),
3277 endpoint: None,
3278 };
3279 let hint = too_old.hint().expect("hint required");
3280 assert!(
3281 hint.contains("8.3.1"),
3282 "target-state hint must name the minimum: {hint}"
3283 );
3284
3285 let undeployed = CoreError::RoutesNotDeployed {
3289 project: "ign-cli".into(),
3290 route: "tags".into(),
3291 endpoint: None,
3292 };
3293 let hint = undeployed.hint().expect("hint required");
3294 assert!(
3295 hint.contains("ign webdev deploy"),
3296 "absent-routes hint must name the fix: {hint}"
3297 );
3298
3299 let older = CoreError::RouteVersionMismatch {
3300 route: "tags".into(),
3301 deployed: "0.9.0".into(),
3302 expected: "1.0.0".into(),
3303 endpoint: None,
3304 };
3305 let hint = older.hint().expect("hint required");
3306 assert!(
3307 hint.contains("ign webdev deploy") && !hint.contains("update ign"),
3308 "older-route hint says redeploy: {hint}"
3309 );
3310
3311 let newer = CoreError::RouteVersionMismatch {
3312 route: "tags".into(),
3313 deployed: "1.1.0".into(),
3314 expected: "1.0.0".into(),
3315 endpoint: None,
3316 };
3317 let hint = newer.hint().expect("hint required");
3318 assert!(
3319 hint.contains("update ign") && !hint.contains("ign webdev deploy"),
3320 "newer-route hint says update ign: {hint}"
3321 );
3322
3323 let secret_gate = CoreError::WebdevRouteError {
3324 code: "secret_required".into(),
3325 message: "missing x-ignition-cli-secret header".into(),
3326 endpoint: None,
3327 };
3328 let hint = secret_gate.hint().expect("hint required");
3329 assert!(
3330 hint.contains("--with-script-exec"),
3331 "secret-gate hint names the deploy flag: {hint}"
3332 );
3333
3334 let journal = CoreError::AlarmJournalMissing { endpoint: None };
3337 let hint = journal.hint().expect("hint required");
3338 assert!(
3339 hint.contains("journal profile") && hint.contains("database connection"),
3340 "journal hint names the chain: {hint}"
3341 );
3342 assert!(
3343 hint.contains("README"),
3344 "journal hint points at the README section: {hint}"
3345 );
3346
3347 let prunable = CoreError::SessionNotPrunable {
3350 id: "d-live-1".into(),
3351 endpoint: None,
3352 };
3353 let hint = prunable.hint().expect("hint required");
3354 assert!(
3355 hint.contains("close the Designer"),
3356 "prune hint must name the action: {hint}"
3357 );
3358 assert!(
3359 hint.contains("stale entries"),
3360 "prune hint must name stale-only semantics: {hint}"
3361 );
3362
3363 let held = CoreError::ScanLockHeld {
3366 actor: None,
3367 kind: "config".into(),
3368 endpoint: None,
3369 };
3370 let hint = held.hint().expect("hint required");
3371 assert!(
3372 hint.contains("holdTimeout") && hint.contains("scan runs"),
3373 "scan-lock hint names the release conditions: {hint}"
3374 );
3375 assert!(
3376 !hint.contains("ign "),
3377 "scan-lock hint names no ign command: {hint}"
3378 );
3379
3380 let rejected = CoreError::ResourceSignatureRejected {
3382 name: "DB_A".into(),
3383 reason: SignatureReason::Stale,
3384 endpoint: None,
3385 };
3386 let hint = rejected.hint().expect("hint required");
3387 assert!(
3388 hint.contains("re-read") && hint.contains("signatures change on every write"),
3389 "signature hint names the action: {hint}"
3390 );
3391
3392 let exists = CoreError::ResourceExists {
3395 name: "DB_A".into(),
3396 endpoint: None,
3397 };
3398 let hint = exists.hint().expect("hint required");
3399 assert!(
3400 hint.contains("update it"),
3401 "resource_exists hint names the action: {hint}"
3402 );
3403 assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3404 let mode_exists = CoreError::ModeExists {
3405 name: "production".into(),
3406 endpoint: None,
3407 };
3408 let hint = mode_exists.hint().expect("hint required");
3409 assert!(
3410 hint.contains("skip the create"),
3411 "mode_exists hint names the action: {hint}"
3412 );
3413
3414 let tty = CoreError::tui_tty_refusal();
3419 assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3420 assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3421 let hint = tty.hint().expect("hint required");
3422 assert!(
3423 hint.contains("interactive terminal"),
3424 "TTY hint must name the fix: {hint}"
3425 );
3426 assert!(
3427 !hint.contains("--file"),
3428 "TTY hint must not carry the resource-put hint: {hint}"
3429 );
3430
3431 let top = CoreError::top_tty_refusal();
3436 assert_eq!(top.code(), "invalid_input", "slug unchanged");
3437 assert_eq!(top.exit_code(), 2, "usage class unchanged");
3438 let top_hint = top.hint().expect("hint required");
3439 assert!(
3440 top_hint.contains("`ign top`"),
3441 "the top refusal's hint names ign top: {top_hint}"
3442 );
3443 assert!(
3444 !top_hint.contains("ign tui"),
3445 "the top refusal never names the cockpit command: {top_hint}"
3446 );
3447 assert!(
3448 top_hint.contains("interactive terminal"),
3449 "the top refusal keeps the terminal-contextual fix: {top_hint}"
3450 );
3451 let put = CoreError::InvalidInput {
3452 reason: "cannot read put.json".into(),
3453 };
3454 let hint = put.hint().expect("hint required");
3455 assert!(
3456 hint.contains("--file") && hint.contains("stdin"),
3457 "resource-put hint unchanged: {hint}"
3458 );
3459
3460 let controller = CoreError::EamNotController { endpoint: None };
3464 let hint = controller.hint().expect("hint required");
3465 assert!(
3466 hint.contains("installMode"),
3467 "controller hint names the flip: {hint}"
3468 );
3469 assert!(
3470 hint.contains("Controller"),
3471 "controller hint names the target role: {hint}"
3472 );
3473 assert!(
3474 hint.contains("README"),
3475 "controller hint points at the README section: {hint}"
3476 );
3477
3478 let refused = CoreError::EamTaskTypeRefused {
3481 task_type: "eam_restoreBackup".into(),
3482 };
3483 assert_eq!(refused.exit_code(), 6, "target state");
3484 let hint = refused.hint().expect("hint required");
3485 assert!(
3486 hint.contains("EAM console"),
3487 "refused hint names where to run it: {hint}"
3488 );
3489 assert!(
3490 hint.contains("fleet-wide") || hint.contains("EXT-03"),
3491 "refused hint names the fleet consequence / scope: {hint}"
3492 );
3493
3494 let in_flight = CoreError::EamTaskInFlight {
3498 task: "cli-research-backup".into(),
3499 detail: "the previous '(forced)' run must be completed or deleted first".into(),
3500 endpoint: None,
3501 };
3502 assert_eq!(in_flight.exit_code(), 6, "target state");
3503 let hint = in_flight.hint().expect("hint required");
3504 assert!(
3505 hint.contains("EAM console"),
3506 "in-flight hint names where to resolve it: {hint}"
3507 );
3508 assert!(
3509 hint.contains("complete or delete"),
3510 "in-flight hint names the resolution: {hint}"
3511 );
3512
3513 let unconfigured = CoreError::ScriptExecNotConfigured {
3517 profile: "dev".into(),
3518 };
3519 assert_eq!(unconfigured.exit_code(), 6, "target state");
3520 let hint = unconfigured.hint().expect("hint required");
3521 assert!(
3522 hint.contains("ign webdev deploy --with-script-exec"),
3523 "the hint names the deploy flag verbatim: {hint}"
3524 );
3525
3526 let no_active = CoreError::NoActiveProfile;
3528 assert!(no_active.hint().is_some());
3529 }
3530
3531 #[test]
3536 fn error_envelope_locked_shape_and_endpoint() {
3537 let auth = CoreError::Auth {
3538 status: 401,
3539 endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3540 };
3541 let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3542 let json = serde_json::to_string(&envelope).expect("serialize envelope");
3543
3544 assert_eq!(
3545 json,
3546 concat!(
3547 r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3548 r#""message":"gateway rejected credentials (HTTP 401)","#,
3549 r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3550 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"}}"#
3551 )
3552 );
3553
3554 let no_request = CoreError::NoActiveProfile;
3556 let body: &ErrorBody = &no_request.envelope(None).error;
3557 assert_eq!(body.endpoint, None);
3558 }
3559
3560 #[test]
3566 fn truncates_at_cap_with_marker() {
3567 let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3569 assert_eq!(truncate_api_body(short), short);
3570 assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3571
3572 let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3575 assert_eq!(truncate_api_body(&exact), exact);
3576
3577 let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3579 let truncated = truncate_api_body(&over);
3580 assert!(
3581 truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3582 "truncated body must end with the explicit marker"
3583 );
3584 assert!(
3585 truncated.len()
3586 <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3587 "truncated body must stay within cap + marker: {}",
3588 truncated.len()
3589 );
3590
3591 let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); let truncated = truncate_api_body(&multibyte);
3596 assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3597 assert!(
3598 truncated
3599 .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3600 );
3601 }
3602
3603 #[test]
3612 fn loss_gate_hint_override_and_neighbors_unchanged() {
3613 let loss_gate = CoreError::InvalidInput {
3616 reason: format!(
3617 "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3618 with --yes to import anyway",
3619 LOSS_GATE_REFUSAL_REASON_PREFIX
3620 ),
3621 };
3622 assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3623 assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3624 let hint = loss_gate.hint().expect("hint required");
3625 assert!(
3626 hint.contains("--yes"),
3627 "loss-gate hint must name the --yes re-run: {hint}"
3628 );
3629 assert!(
3630 hint.contains("loss report above"),
3631 "loss-gate hint must point back at the report: {hint}"
3632 );
3633 assert!(
3634 !hint.contains("fix the input source"),
3635 "the file-read default must never leak onto the loss-gate path: {hint}"
3636 );
3637
3638 let generic = CoreError::InvalidInput {
3641 reason: "x is not valid JSON: expected value at line 1".into(),
3642 };
3643 let hint = generic.hint().expect("hint required");
3644 assert!(
3645 hint.contains("--file") && hint.contains("stdin"),
3646 "resource-put hint unchanged: {hint}"
3647 );
3648 assert!(
3649 !hint.contains("--yes"),
3650 "the --yes hint must not leak onto generic reasons: {hint}"
3651 );
3652
3653 let tty = CoreError::tui_tty_refusal();
3655 let hint = tty.hint().expect("hint required");
3656 assert!(
3657 hint.contains("interactive terminal"),
3658 "TTY hint unchanged: {hint}"
3659 );
3660 assert!(
3661 !hint.contains("--yes"),
3662 "the --yes hint must not leak onto the TTY path: {hint}"
3663 );
3664 }
3665
3666 #[test]
3674 fn routes_not_deployed_hint_is_testing_scoped() {
3675 let testing = CoreError::RoutesNotDeployed {
3676 project: "Flux".into(),
3677 route: format!("{TESTING_ROUTE_PREFIX}run"),
3678 endpoint: None,
3679 };
3680 assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3681 assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3682 let hint = testing.hint().expect("hint required");
3683 assert!(
3684 hint.contains("ign webdev deploy --with-testing"),
3685 "testing hint must name the deploy flag: {hint}"
3686 );
3687 assert!(
3688 hint.contains("ign adopt") && hint.contains("--testing"),
3689 "testing hint must name the adopt path: {hint}"
3690 );
3691
3692 let generic = CoreError::RoutesNotDeployed {
3694 project: "ign-cli".into(),
3695 route: "tags".into(),
3696 endpoint: None,
3697 };
3698 let hint = generic.hint().expect("hint required");
3699 assert_eq!(
3700 hint,
3701 "run `ign webdev deploy` to install the CLI's WebDev routes into \
3702 the gateway, then retry",
3703 "the generic branch is byte-identical to its pre-QUICK-p0g text"
3704 );
3705 }
3706
3707 fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3708 DeployFinding {
3709 file: file.into(),
3710 line,
3711 column,
3712 message: msg.into(),
3713 }
3714 }
3715
3716 #[test]
3717 fn deploy_finding_display_names_file_line_column() {
3718 let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3719 assert_eq!(
3720 positioned.to_string(),
3721 "targets/prod.yaml:3:1: unknown field `x`"
3722 );
3723 let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3724 assert_eq!(
3725 bare.to_string(),
3726 "targets/prod.yaml: missing field `gateway`"
3727 );
3728 let half = finding("m.yaml", Some(3), None, "m");
3730 assert_eq!(half.to_string(), "m.yaml: m");
3731 }
3732
3733 #[test]
3734 fn deploy_findings_cap_at_twenty() {
3735 let many: Vec<DeployFinding> = (0..25)
3736 .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3737 .collect();
3738 let shown = CoreError::deploy_findings(many).to_string();
3739 assert!(shown.contains("25 findings: "), "{shown}");
3740 assert!(shown.contains("problem 19"), "{shown}");
3741 assert!(!shown.contains("problem 20"), "{shown}");
3742 assert!(shown.ends_with("; and 5 more"), "{shown}");
3743
3744 let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3745 assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3746 }
3747
3748 #[test]
3749 fn embedded_secret_display_lists_paths_only() {
3750 let err = CoreError::embedded_secret(
3751 vec![
3752 "ignition/database-connection/A/config.json: settings.password".into(),
3753 "ignition/database-connection/B/config.json: settings.password".into(),
3754 ],
3755 vec![finding("manifest.yaml", None, None, "duplicate type")],
3756 );
3757 let shown = err.to_string();
3758 assert!(shown.contains("2 path(s)"), "{shown}");
3759 assert!(
3760 shown.contains("A/config.json: settings.password"),
3761 "{shown}"
3762 );
3763 assert!(
3764 shown.contains("B/config.json: settings.password"),
3765 "{shown}"
3766 );
3767 assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3768 assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3769
3770 let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3772 let shown =
3773 CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3774 assert!(shown.contains("25 path(s)"), "{shown}");
3775 assert!(shown.contains("and 5 more"), "{shown}");
3776 assert!(shown.contains("f19.json: a.b"), "{shown}");
3777 assert!(!shown.contains("f20.json: a.b"), "{shown}");
3778 }
3779
3780 #[test]
3781 fn deploy_slugs_have_their_exit_codes() {
3782 let errs = [
3783 (
3784 CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3785 2,
3786 ),
3787 (
3788 CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3789 2,
3790 ),
3791 (
3792 CoreError::TransportFailed {
3793 operation: "read".into(),
3794 node: "nodes.master".into(),
3795 detail: "data_dir is missing".into(),
3796 },
3797 4,
3798 ),
3799 (
3800 CoreError::DeployLedgerInvalid {
3801 location: "ign-deploy/gw/dev/ledger.json".into(),
3802 problem: "the file is empty".into(),
3803 },
3804 6,
3805 ),
3806 (
3807 CoreError::PlanStale {
3808 expected: "a".repeat(64),
3809 actual: "b".repeat(64),
3810 detail: "review hash".into(),
3811 },
3812 6,
3813 ),
3814 (
3815 CoreError::DestructiveChangeRefused {
3816 gate: "destructive".into(),
3817 keys: vec!["ignition/a/x".into()],
3818 count: 1,
3819 },
3820 2,
3821 ),
3822 (
3823 CoreError::DeployBlocked {
3824 keys: vec!["ignition/a/x".into()],
3825 },
3826 6,
3827 ),
3828 (
3829 CoreError::DeployPreflightFailed {
3830 failures: vec![PreflightFailure {
3831 check: "mode_master".into(),
3832 detail: "the gateway is not in the declared mode".into(),
3833 }]
3834 .into(),
3835 },
3836 6,
3837 ),
3838 (
3839 CoreError::DeployJournalPresent {
3840 run_id: "20261015T142233Z-a1b2c3".into(),
3841 env: "prod".into(),
3842 location: "ign-deploy/gw/m/journal.json".into(),
3843 recover_with: None,
3844 },
3845 6,
3846 ),
3847 (
3848 CoreError::DeployLiveChanged {
3849 run_id: "r1".into(),
3850 paths: vec!["a/b.json".into()],
3851 count: 1,
3852 },
3853 6,
3854 ),
3855 (
3856 CoreError::DeployRunInvalid {
3857 run_id: "r1".into(),
3858 problem: "record.json is missing".into(),
3859 },
3860 6,
3861 ),
3862 (
3863 CoreError::RollbackFailed(Box::new(RollbackFailure {
3864 run_id: "r1".into(),
3865 env: "dev".into(),
3866 run_dir: Some(".ign/deploy/r1".into()),
3867 journal: "ign-deploy/gw/m/journal.json".into(),
3868 cause: "write_failed".into(),
3869 scope: RollbackScope::Files,
3870 })),
3871 6,
3872 ),
3873 (
3874 CoreError::ProjectGitManaged {
3875 project: "CapApp".into(),
3876 location: "projects/CapApp/.git".into(),
3877 kind: "directory".into(),
3878 },
3879 6,
3880 ),
3881 (
3882 CoreError::ProjectParentInvalid {
3883 project: "CapApp".into(),
3884 parent: "ParentProject".into(),
3885 reason: "cycle".into(),
3886 },
3887 6,
3888 ),
3889 (
3890 CoreError::ProjectHashMismatch {
3891 project: "CapApp".into(),
3892 hash: "artifact_sha256",
3893 expected: "0".repeat(64),
3894 actual: "1".repeat(64),
3895 },
3896 6,
3897 ),
3898 (
3899 CoreError::DeployUnreadable {
3900 node: "master".into(),
3901 keys: vec!["ignition/tag-definition/default/Deep".into()],
3902 },
3903 6,
3904 ),
3905 (
3906 CoreError::DeployPairTagsRefused {
3907 mode: "production".into(),
3908 keys: vec!["ignition/tag-definition/default/Deploy".into()],
3909 },
3910 6,
3911 ),
3912 (
3913 CoreError::TagProviderClaimed {
3914 provider: "PlantA".into(),
3915 claimed_by: "the gateway-config manifest gateways/capgw/manifest.yaml".into(),
3916 },
3917 6,
3918 ),
3919 (
3922 CoreError::TagDeleteNotConfigured {
3923 profile: "dev".into(),
3924 missing: vec!["webdev_tag_delete_secret".into()],
3925 },
3926 6,
3927 ),
3928 ];
3929 let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3930 assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3931 for (err, exit) in &errs {
3932 assert_eq!(err.exit_code(), *exit, "{err}");
3933 assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3934 let hint = err.hint().expect("hint required");
3935 if err.code() == "tag_delete_not_configured" {
3939 assert!(hint.contains("webdev deploy --with-tag-delete"), "{hint}");
3940 continue;
3941 }
3942 assert!(
3943 !hint.contains("`ign ") && !hint.contains("ign "),
3944 "hint must not name an ign subcommand: {hint}"
3945 );
3946 }
3947 }
3948
3949 #[test]
3950 fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3951 for reason in [
3952 "--plan-sha256 must be 64 lowercase hex characters",
3953 "--allow-drift reason must not be empty",
3954 "--artifact-sha256 must be 64 lowercase hex characters",
3955 "--release <tag>: not a semver tag (for example v1.4.0)",
3956 "--allow-first-change <reason>: the reason is 1 to 500 characters",
3957 "deploy projects needs --artifact-sha256 when --json is given",
3958 "--tags-sha256 must be 64 lowercase hex characters",
3959 "deploy projects needs --tags-sha256 when --json is given",
3960 ] {
3961 let hint = CoreError::InvalidInput {
3962 reason: reason.into(),
3963 }
3964 .hint()
3965 .expect("hint");
3966 assert!(!hint.contains("--file"), "{hint}");
3967 assert!(hint.contains("flag value"), "{hint}");
3968 }
3969 }
3970
3971 #[test]
3976 fn deploy_slugs_are_pinned_and_never_a_verdict() {
3977 assert_eq!(
3978 CoreError::DEPLOY_SLUGS,
3979 &[
3980 "deploy_tree_invalid",
3981 "embedded_secret",
3982 "transport_failed",
3983 "deploy_ledger_invalid",
3984 "plan_stale",
3985 "destructive_change_refused",
3986 "deploy_blocked",
3987 "deploy_preflight_failed",
3988 "deploy_journal_present",
3989 "deploy_live_changed",
3990 "deploy_run_invalid",
3991 "rollback_failed",
3992 "project_git_managed",
3993 "project_parent_invalid",
3994 "project_hash_mismatch",
3995 "deploy_unreadable",
3996 "deploy_pair_tags_refused",
3997 "tag_provider_claimed",
3998 "tag_delete_not_configured",
3999 ]
4000 );
4001 assert_eq!(CoreError::DEPLOY_SLUGS.len(), 19);
4002 for verdict in [
4003 "deploy_rolled_back",
4004 "deploy_backup_unsynced",
4005 "deploy_project_unverified",
4006 ] {
4007 assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
4008 }
4009 use crate::actions::deploy_projects::{ProjectOutcome, sample_result};
4014 let mut result = sample_result();
4015 for (outcome, exit) in [
4016 (ProjectOutcome::TagsRolledBack, Some(6)),
4017 (ProjectOutcome::RolledBack, Some(6)),
4018 (ProjectOutcome::ImportedUnverified, Some(6)),
4019 (ProjectOutcome::AppliedBackupUnsynced, Some(6)),
4020 (ProjectOutcome::Applied, None),
4021 (ProjectOutcome::Imported, None),
4022 (ProjectOutcome::Created, None),
4023 (ProjectOutcome::Recorded, None),
4024 (ProjectOutcome::Skipped, None),
4025 (ProjectOutcome::Checked, None),
4026 ] {
4027 result.outcome = outcome;
4028 assert_eq!(result.failure_exit_code(), exit, "{}", outcome.as_str());
4029 }
4030 assert_eq!(ProjectOutcome::TagsRolledBack.as_str(), "tags_rolled_back");
4031 assert_eq!(ProjectOutcome::RolledBack.as_str(), "rolled_back");
4032 assert_eq!(
4033 ProjectOutcome::AppliedBackupUnsynced.as_str(),
4034 "applied_backup_unsynced"
4035 );
4036 assert_eq!(crate::deploy::apply::ROLLED_BACK_SLUG, "deploy_rolled_back");
4037 }
4038
4039 #[test]
4042 fn project_git_managed_four_places() {
4043 let err = CoreError::ProjectGitManaged {
4044 project: "CapApp".into(),
4045 location: "projects/CapApp/.git".into(),
4046 kind: "directory".into(),
4047 };
4048 assert_eq!(err.code(), "project_git_managed");
4049 assert_eq!(err.exit_code(), 6);
4050 let message = err.to_string();
4051 for needle in [
4052 "CapApp",
4053 "projects/CapApp/.git",
4054 "directory",
4055 "administrator",
4056 ] {
4057 assert!(message.contains(needle), "{needle}: {message}");
4058 }
4059 let hint = err.hint().expect("hint required");
4060 assert!(!hint.contains("ign "), "{hint}");
4061 assert!(hint.contains(".git"), "{hint}");
4062 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
4063 }
4064
4065 #[test]
4069 fn project_parent_invalid_four_places() {
4070 for (reason, needle) in [
4071 ("missing", "its own repo"),
4072 ("cycle", "no cycle"),
4073 ("not_inheritable", "inheritable"),
4074 ] {
4075 let err = CoreError::ProjectParentInvalid {
4076 project: "CapApp".into(),
4077 parent: "ParentProject".into(),
4078 reason: reason.into(),
4079 };
4080 assert_eq!(err.code(), "project_parent_invalid");
4081 assert_eq!(err.exit_code(), 6);
4082 let message = err.to_string();
4083 for part in ["CapApp", "ParentProject", reason] {
4084 assert!(message.contains(part), "{part}: {message}");
4085 }
4086 let hint = err.hint().expect("hint required");
4087 assert!(!hint.contains("ign "), "{hint}");
4088 assert!(hint.contains(needle), "{reason}: {hint}");
4089 assert_eq!(err.endpoint(), None);
4090 }
4091 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
4092 }
4093
4094 #[test]
4098 fn project_hash_mismatch_four_places() {
4099 let err = CoreError::ProjectHashMismatch {
4100 project: "CapApp".into(),
4101 hash: "artifact_sha256",
4102 expected: "0".repeat(64),
4103 actual: "1".repeat(64),
4104 };
4105 assert_eq!(err.code(), "project_hash_mismatch");
4106 assert_eq!(err.exit_code(), 6);
4107 let message = err.to_string();
4108 for part in [
4109 "CapApp",
4110 "0000000000000000000000000000000000000000000000000000000000000000",
4111 "1111111111111111111111111111111111111111111111111111111111111111",
4112 "nothing was uploaded",
4113 ] {
4114 assert!(message.contains(part), "{part}: {message}");
4115 }
4116 let hint = err.hint().expect("hint required");
4117 assert!(!hint.contains("ign "), "{hint}");
4118 assert!(hint.contains("artifact_sha256"), "{hint}");
4119 assert_eq!(err.endpoint(), None);
4120 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
4121 assert!(
4122 message.starts_with("artifact hash mismatch for project CapApp"),
4123 "{message}"
4124 );
4125
4126 let err = CoreError::ProjectHashMismatch {
4129 project: "CapApp".into(),
4130 hash: "tags_sha256",
4131 expected: "0".repeat(64),
4132 actual: "1".repeat(64),
4133 };
4134 assert_eq!(err.code(), "project_hash_mismatch");
4135 assert_eq!(err.exit_code(), 6);
4136 let message = err.to_string();
4137 assert!(message.contains("tags_sha256"), "{message}");
4138 assert!(message.contains("nothing was uploaded"), "{message}");
4139 assert!(
4140 err.hint().expect("hint").contains("data.tags.tags_sha256"),
4141 "{:?}",
4142 err.hint()
4143 );
4144 }
4145
4146 #[test]
4151 fn deploy_unreadable_four_places() {
4152 let keys = vec![
4153 "ignition/database-translator/OLD".to_string(),
4154 "ignition/tag-definition/default/Deep".to_string(),
4155 ];
4156 let err = CoreError::DeployUnreadable {
4157 node: "backup".to_string(),
4158 keys: keys.clone(),
4159 };
4160 assert_eq!(err.code(), "deploy_unreadable");
4161 assert_eq!(err.exit_code(), 6);
4162 assert_eq!(err.endpoint(), None);
4163 let message = err.to_string();
4164 for part in keys.iter().map(String::as_str).chain([
4165 "backup host's sftp server cannot read",
4166 "nothing was written",
4167 "2 unit(s)",
4168 ]) {
4169 assert!(message.contains(part), "{part}: {message}");
4170 }
4171 let hint = err.hint().expect("hint required");
4172 assert!(!hint.contains("ign "), "{hint}");
4173 assert!(hint.contains("exclude_tag_providers"), "{hint}");
4174 assert!(hint.contains("shorten"), "{hint}");
4175 assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_unreadable"));
4176 }
4177
4178 #[test]
4183 fn deploy_pair_tags_refused_four_places() {
4184 let keys = vec![
4185 "ignition/tag-definition/default/Deploy".to_string(),
4186 "ignition/tag-type-definition/default/DeployTypes".to_string(),
4187 ];
4188 let err = CoreError::DeployPairTagsRefused {
4189 mode: "production".to_string(),
4190 keys: keys.clone(),
4191 };
4192 assert_eq!(err.code(), "deploy_pair_tags_refused");
4193 assert_eq!(err.exit_code(), 6);
4194 assert_eq!(err.endpoint(), None);
4195 let message = err.to_string();
4196 for part in keys.iter().map(String::as_str).chain([
4197 "2 tag unit(s)",
4198 "production collection of a redundant pair",
4199 "nothing was written to the production or core collection",
4200 ]) {
4201 assert!(message.contains(part), "{part}: {message}");
4202 }
4203 let hint = err.hint().expect("hint required");
4204 assert!(!hint.contains("ign "), "{hint}");
4205 assert!(!hint.contains("exclude_tag_providers"), "{hint}");
4206 assert!(hint.contains("ledger does not list"), "{hint}");
4207 assert!(hint.contains("core collection"), "{hint}");
4208 assert!(
4209 hint.contains("nothing was written to the mode or core collection"),
4210 "{hint}"
4211 );
4212 assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_pair_tags_refused"));
4213 }
4214
4215 #[test]
4221 fn tag_delete_not_configured_four_places() {
4222 let err = CoreError::TagDeleteNotConfigured {
4223 profile: "plant-a".to_string(),
4224 missing: vec![
4225 "webdev_tag_delete_secret".to_string(),
4226 "IGNITION_TAG_DELETE_PASSWORD_PLANT_A".to_string(),
4227 ],
4228 };
4229 assert_eq!(err.code(), "tag_delete_not_configured");
4230 assert_eq!(err.exit_code(), 6);
4231 assert_eq!(err.endpoint(), None);
4232 assert_eq!(
4233 err.to_string(),
4234 "the tagDelete route is not configured for profile \"plant-a\" (missing or \
4235 refused: webdev_tag_delete_secret; IGNITION_TAG_DELETE_PASSWORD_PLANT_A); \
4236 nothing was sent"
4237 );
4238 let hint = err.hint().expect("hint required");
4239 for part in [
4240 "IGNITION_TAG_DELETE_USER_PLANT_A",
4241 "IGNITION_TAG_DELETE_PASSWORD_PLANT_A",
4242 "ign webdev deploy --with-tag-delete",
4243 "https",
4244 "loopback",
4245 ] {
4246 assert!(hint.contains(part), "hint lacks {part}: {hint}");
4247 }
4248 }
4249
4250 #[test]
4254 fn tag_delete_hints_are_route_scoped() {
4255 let absent = CoreError::RoutesNotDeployed {
4256 project: "ign-cli".into(),
4257 route: TAG_DELETE_ROUTE_NAME.into(),
4258 endpoint: None,
4259 };
4260 assert_eq!(absent.code(), "routes_not_deployed");
4261 let hint = absent.hint().expect("hint");
4262 assert!(
4263 hint.contains(
4264 "ign webdev deploy --with-tag-delete --tag-delete-repo <repo> \
4265 --tag-delete-user-source <name> --tag-delete-role <role>"
4266 ),
4267 "{hint}"
4268 );
4269
4270 let older = CoreError::RouteVersionMismatch {
4271 route: TAG_DELETE_ROUTE_NAME.into(),
4272 deployed: "0.9.0".into(),
4273 expected: crate::webdev::ROUTE_BUNDLE_VERSION.into(),
4274 endpoint: None,
4275 };
4276 assert!(older.hint().expect("hint").contains("--with-tag-delete"));
4277 let other_older = CoreError::RouteVersionMismatch {
4278 route: "tags".into(),
4279 deployed: "0.9.0".into(),
4280 expected: crate::webdev::ROUTE_BUNDLE_VERSION.into(),
4281 endpoint: None,
4282 };
4283 assert!(!other_older.hint().expect("hint").contains("tag-delete"));
4284
4285 for code in ["secret_required", "secret_mismatch"] {
4286 let denied = CoreError::WebdevRouteError {
4287 code: code.into(),
4288 message: "m".into(),
4289 endpoint: Some("http://127.0.0.1:1/system/webdev/ign-cli/cli/tagDelete".into()),
4290 };
4291 let hint = denied.hint().expect("hint");
4292 assert!(hint.contains("--with-tag-delete"), "{hint}");
4293 assert!(hint.contains("--rotate-tag-delete-secret"), "{hint}");
4294 assert!(!hint.contains("scriptExec"), "{hint}");
4295 let script = CoreError::WebdevRouteError {
4296 code: code.into(),
4297 message: "m".into(),
4298 endpoint: Some("http://127.0.0.1:1/system/webdev/ign-cli/cli/scriptExec".into()),
4299 };
4300 assert!(script.hint().expect("hint").contains("--with-script-exec"));
4301 }
4302 }
4303
4304 #[test]
4308 fn tag_provider_claimed_four_places() {
4309 let err = CoreError::TagProviderClaimed {
4310 provider: "PlantA".to_string(),
4311 claimed_by: "the gateway-config manifest gateways/capgw/manifest.yaml".to_string(),
4312 };
4313 assert_eq!(err.code(), "tag_provider_claimed");
4314 assert_eq!(err.exit_code(), 6);
4315 assert_eq!(err.endpoint(), None);
4316 assert_eq!(
4317 err.to_string(),
4318 "tag provider PlantA is already deployed by the gateway-config manifest \
4319 gateways/capgw/manifest.yaml; a provider has one owner"
4320 );
4321 let hint = err.hint().expect("hint required");
4322 assert!(!hint.contains("ign "), "{hint}");
4323 assert!(hint.contains("tag_providers"), "{hint}");
4324 assert!(CoreError::DEPLOY_SLUGS.contains(&"tag_provider_claimed"));
4325 }
4326
4327 #[test]
4330 fn plan_stale_hint_names_the_live_project_case() {
4331 let live = CoreError::PlanStale {
4332 expected: "a".repeat(64),
4333 actual: "b".repeat(64),
4334 detail: "live project changed since the plan".into(),
4335 };
4336 let hint = live.hint().expect("hint");
4337 assert!(hint.contains("live project"), "{hint}");
4338 assert!(hint.contains("nothing was imported"), "{hint}");
4339 assert!(!hint.contains("ign "), "{hint}");
4340 let review = CoreError::PlanStale {
4341 expected: "a".repeat(64),
4342 actual: "b".repeat(64),
4343 detail: "review hash".into(),
4344 };
4345 assert!(
4346 review.hint().expect("hint").contains("plan_sha256"),
4347 "the plan-hash case keeps its hint"
4348 );
4349 }
4350
4351 #[test]
4352 fn journal_present_message_carries_the_recovery_command() {
4353 let err = CoreError::DeployJournalPresent {
4354 run_id: "20261015T142233Z-a1b2c3".into(),
4355 env: "prod".into(),
4356 location: "ign-deploy/gw/m/journal.json".into(),
4357 recover_with: None,
4358 };
4359 let shown = err.to_string();
4360 assert!(
4361 shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
4362 "{shown}"
4363 );
4364 assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
4365 let projects = CoreError::DeployJournalPresent {
4367 run_id: "20261015T142233Z-a1b2c3".into(),
4368 env: "prod".into(),
4369 location: "~/ign-deploy/gw/projects/CapApp/journal.json".into(),
4370 recover_with: Some(
4371 "ign deploy projects --env prod --rollback-run 20261015T142233Z-a1b2c3".into(),
4372 ),
4373 };
4374 let text = projects.to_string();
4375 assert!(
4376 text.ends_with(
4377 "recover it with: ign deploy projects --env prod --rollback-run \
4378 20261015T142233Z-a1b2c3"
4379 ),
4380 "{text}"
4381 );
4382 assert!(!text.contains("ign deploy rollback"), "{text}");
4383 assert_eq!(projects.code(), "deploy_journal_present");
4384 assert_eq!(projects.exit_code(), 6);
4385 let hint = err.hint().expect("hint");
4386 assert!(
4387 !hint.contains("ign deploy"),
4388 "D-09: the command lives in Display: {hint}"
4389 );
4390 }
4391
4392 #[test]
4396 fn rollback_failed_of_a_projects_run_names_what_is_left() {
4397 let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
4398 run_id: "r-8".into(),
4399 env: "dev".into(),
4400 run_dir: Some(".ign/deploy/gw/r-8".into()),
4401 journal: ".ign/deploy/gw/r-8/export".into(),
4402 cause: "tag_step_failed: x; left: [P]Line/New (created by the run)".into(),
4403 scope: RollbackScope::Projects,
4404 }));
4405 assert_eq!(err.code(), "rollback_failed");
4406 assert_eq!(err.exit_code(), 6);
4407 let shown = err.to_string();
4408 for needle in [
4409 "r-8",
4410 ".ign/deploy/gw/r-8",
4411 ".ign/deploy/gw/r-8/export",
4412 "[P]Line/New (created by the run)",
4413 "by hand",
4414 ] {
4415 assert!(shown.contains(needle), "{needle} missing from: {shown}");
4416 }
4417 assert!(
4418 !shown.contains("--run r-8"),
4419 "no file-engine retry: {shown}"
4420 );
4421 assert!(!shown.contains("snapshots/"), "no on-box snapshot: {shown}");
4422 let hint = err.hint().expect("hint");
4423 assert!(
4424 hint.contains("pre-images") && !hint.contains("journal"),
4425 "{hint}"
4426 );
4427 }
4428
4429 #[test]
4433 fn rollback_failed_of_an_undo_names_the_host_copy() {
4434 let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
4435 run_id: "u-9".into(),
4436 env: "dev".into(),
4437 run_dir: Some(".ign/deploy/gw/u-9".into()),
4438 journal: "ign-deploy/gw/projects/CapApp/pre-image".into(),
4439 cause: "rollback_run: undo of run r-8 from the host copy; left: x".into(),
4440 scope: RollbackScope::Projects,
4441 }));
4442 let shown = err.to_string();
4443 assert!(!shown.contains("automatic rollback"), "{shown}");
4444 assert!(shown.contains("the undo (--rollback-run) u-9"), "{shown}");
4445 assert!(
4446 shown.contains(
4447 "the pre-images the gateway host keeps in ign-deploy/gw/projects/CapApp/pre-image"
4448 ),
4449 "{shown}"
4450 );
4451 let hint = err.hint().expect("hint");
4452 assert!(hint.contains("the gateway host keeps"), "{hint}");
4453 assert!(!hint.contains("in the run directory"), "{hint}");
4454 }
4455
4456 #[test]
4457 fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
4458 let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
4459 run_id: "r-7".into(),
4460 env: "prod".into(),
4461 run_dir: Some(".ign/deploy/r-7".into()),
4462 journal: "ign-deploy/gw/m/journal.json".into(),
4463 cause: "write_failed".into(),
4464 scope: RollbackScope::Files,
4465 }));
4466 let shown = err.to_string();
4467 for needle in [
4468 "r-7",
4469 ".ign/deploy/r-7",
4470 "ign-deploy/gw/m/journal.json",
4471 ".gwbk",
4472 "snapshot",
4473 "by hand",
4474 "ign deploy rollback --run r-7 --env prod",
4477 "ign-deploy/gw/m/snapshots/r-7/",
4478 ] {
4479 assert!(shown.contains(needle), "{needle} missing from: {shown}");
4480 }
4481 assert!(
4482 shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
4483 "the retry precedes the hand deletion: {shown}"
4484 );
4485 assert!(
4486 !shown.contains("run-directory snapshot"),
4487 "the run directory has no snapshot to offer: {shown}"
4488 );
4489 let hint = err.hint().expect("hint");
4490 assert!(
4491 hint.contains("retry") && !hint.contains("run-directory snapshot"),
4492 "{hint}"
4493 );
4494 let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
4495 run_id: "r-7".into(),
4496 env: "prod".into(),
4497 run_dir: None,
4498 journal: "j".into(),
4499 cause: "c".into(),
4500 scope: RollbackScope::Files,
4501 }));
4502 assert!(!none.to_string().contains("None"), "{none}");
4503 }
4504
4505 #[test]
4506 fn preflight_failed_lists_every_failure_in_order() {
4507 let err = CoreError::DeployPreflightFailed {
4508 failures: vec![
4509 PreflightFailure {
4510 check: "mode_master".into(),
4511 detail: "first".into(),
4512 },
4513 PreflightFailure {
4514 check: "gwbk".into(),
4515 detail: "second".into(),
4516 },
4517 ]
4518 .into(),
4519 };
4520 let shown = err.to_string();
4521 let first = shown.find("mode_master: first").expect("first");
4522 let second = shown.find("gwbk: second").expect("second");
4523 assert!(first < second, "{shown}");
4524 assert_eq!(shown.lines().count(), 3, "{shown}");
4525 }
4526
4527 #[test]
4528 fn apply_refusal_lists_cap_at_ten() {
4529 let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
4530 let shown = CoreError::DestructiveChangeRefused {
4531 gate: "destructive".into(),
4532 keys: keys.clone(),
4533 count: 13,
4534 }
4535 .to_string();
4536 assert!(shown.contains("13 entries"), "{shown}");
4537 assert!(shown.contains("and 3 more"), "{shown}");
4538 assert!(shown.contains("--allow-destructive"), "{shown}");
4539 let drift = CoreError::DestructiveChangeRefused {
4540 gate: "drift".into(),
4541 keys: keys[..1].to_vec(),
4542 count: 1,
4543 }
4544 .to_string();
4545 assert!(drift.contains("--allow-drift"), "{drift}");
4546 assert!(drift.contains("1 entry"), "{drift}");
4547 let downgrade = CoreError::DestructiveChangeRefused {
4548 gate: "downgrade".into(),
4549 keys: vec!["CapApp".into()],
4550 count: 1,
4551 };
4552 let shown = downgrade.to_string();
4553 assert!(shown.contains("downgrade"), "{shown}");
4554 assert!(shown.contains("CapApp"), "{shown}");
4555 assert!(
4556 shown.contains("deploy projects needs --allow-downgrade"),
4557 "{shown}"
4558 );
4559 assert!(!shown.contains("apply needs"), "{shown}");
4560 let first = CoreError::DestructiveChangeRefused {
4561 gate: "first_deploy".into(),
4562 keys: vec!["CapApp".into()],
4563 count: 1,
4564 };
4565 let shown = first.to_string();
4566 assert!(shown.contains("first_deploy"), "{shown}");
4567 assert!(
4568 shown.contains("deploy projects needs --allow-first-change <reason>"),
4569 "{shown}"
4570 );
4571 let hints: Vec<String> = [&downgrade, &first]
4573 .iter()
4574 .map(|e| e.hint().expect("hint"))
4575 .collect();
4576 assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
4577 assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
4578 for hint in &hints {
4579 assert!(!hint.contains("ign "), "{hint}");
4580 assert!(!hint.contains("--allow-destructive"), "{hint}");
4581 }
4582 let removal = CoreError::DestructiveChangeRefused {
4585 gate: "tag_removal".into(),
4586 keys: vec!["[PlantA]Line1/Old".into()],
4587 count: 1,
4588 };
4589 let shown = removal.to_string();
4590 assert!(
4591 shown.contains("tag_removal") && shown.contains("[PlantA]Line1/Old"),
4592 "{shown}"
4593 );
4594 assert!(
4595 shown.contains("deploy projects needs --allow-destructive"),
4596 "{shown}"
4597 );
4598 assert!(!shown.contains("no tag delete primitive"), "{shown}");
4599 assert_eq!(removal.code(), "destructive_change_refused");
4600 assert_eq!(removal.exit_code(), 2);
4601 let hint = removal.hint().expect("hint");
4602 for needle in [
4603 "--check",
4604 "--allow-destructive",
4605 "tag-delete route",
4606 "--with-tag-delete",
4607 "nothing was written",
4608 ] {
4609 assert!(hint.contains(needle), "{needle}: {hint}");
4610 }
4611 assert!(!hint.contains("ign "), "{hint}");
4612 {
4614 let (gate, key) = ("provider_emptied", "tag provider PlantA");
4615 let err = CoreError::DestructiveChangeRefused {
4616 gate: gate.into(),
4617 keys: vec![key.into()],
4618 count: 1,
4619 };
4620 let shown = err.to_string();
4621 assert!(shown.contains(gate) && shown.contains(key), "{shown}");
4622 assert!(shown.contains("no flag permits"), "{shown}");
4623 assert!(!shown.contains("needs --"), "{shown}");
4624 assert_eq!(err.code(), "destructive_change_refused");
4625 assert_eq!(err.exit_code(), 2);
4626 let hint = err.hint().expect("hint");
4627 assert!(!hint.contains("ign "), "{hint}");
4628 assert!(!hint.contains("--allow-"), "{hint}");
4629 assert!(hint.contains("nothing was written"), "{hint}");
4630 }
4631
4632 let wipe = CoreError::DestructiveChangeRefused {
4634 gate: "provider_wipe".into(),
4635 keys: vec!["tag provider PlantA".into()],
4636 count: 1,
4637 };
4638 let shown = wipe.to_string();
4639 assert!(shown.contains("provider_wipe"), "{shown}");
4640 assert!(shown.contains("tag provider PlantA"), "{shown}");
4641 assert!(
4643 shown.contains("apply needs --allow-provider-wipe <provider> and --allow-destructive"),
4644 "{shown}"
4645 );
4646 assert_eq!(wipe.code(), "destructive_change_refused");
4647 assert_eq!(wipe.exit_code(), 2);
4648 let hint = wipe.hint().expect("hint");
4649 assert!(hint.contains("tag_providers"), "{hint}");
4650 assert!(hint.contains("same commit"), "{hint}");
4651 assert!(hint.contains("--allow-provider-wipe"), "{hint}");
4652 assert!(!hint.contains("ign "), "{hint}");
4653
4654 let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
4655 let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
4656 assert!(blocked.contains("and 15 more"), "{blocked}");
4657 assert!(!blocked.contains("a/10"), "{blocked}");
4658 let live = CoreError::DeployLiveChanged {
4659 run_id: "r".into(),
4660 paths: many[..10].to_vec(),
4661 count: 12,
4662 }
4663 .to_string();
4664 assert!(live.contains("and 2 more"), "{live}");
4665 }
4666}