1use crate::{output, version_text};
2use canic_backup::{
3 manifest::FleetBackupManifest,
4 persistence::{BackupLayout, PersistenceError},
5 restore::{
6 RestoreApplyCommandConfig, RestoreApplyCommandOutputPair, RestoreApplyCommandPreview,
7 RestoreApplyDryRun, RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
8 RestoreApplyJournalOperation, RestoreApplyJournalReport, RestoreApplyJournalStatus,
9 RestoreApplyOperationKind, RestoreApplyOperationKindCounts, RestoreApplyOperationReceipt,
10 RestoreApplyOperationState, RestoreApplyPendingSummary, RestoreApplyProgressSummary,
11 RestoreApplyReportOperation, RestoreApplyReportOutcome, RestoreApplyRunnerCommand,
12 RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner, RestoreStatus,
13 },
14};
15use clap::{Arg, ArgAction, Command as ClapCommand};
16use serde::Serialize;
17use std::{
18 ffi::OsString,
19 fs,
20 io::{self, Write},
21 path::{Path, PathBuf},
22 process::Command as ProcessCommand,
23};
24use thiserror::Error as ThisError;
25
26#[derive(Debug, ThisError)]
31pub enum RestoreCommandError {
32 #[error("{0}")]
33 Usage(&'static str),
34
35 #[error("missing required option {0}")]
36 MissingOption(&'static str),
37
38 #[error("use either --manifest or --backup-dir, not both")]
39 ConflictingManifestSources,
40
41 #[error("--require-verified requires --backup-dir")]
42 RequireVerifiedNeedsBackupDir,
43
44 #[error("restore apply currently requires --dry-run")]
45 ApplyRequiresDryRun,
46
47 #[error("restore run requires --dry-run, --execute, or --unclaim-pending")]
48 RestoreRunRequiresMode,
49
50 #[error("use only one restore run mode: --dry-run, --execute, or --unclaim-pending")]
51 RestoreRunConflictingModes,
52
53 #[error("restore run command failed for operation {sequence}: status={status}")]
54 RestoreRunCommandFailed { sequence: usize, status: String },
55
56 #[error("restore apply journal is locked: {lock_path}")]
57 RestoreApplyJournalLocked { lock_path: String },
58
59 #[error("restore run for backup {backup_id} used run_mode={actual}, expected {expected}")]
60 RestoreRunModeMismatch {
61 backup_id: String,
62 expected: String,
63 actual: String,
64 },
65
66 #[error(
67 "restore run for backup {backup_id} stopped for {actual}, expected stopped_reason={expected}"
68 )]
69 RestoreRunStoppedReasonMismatch {
70 backup_id: String,
71 expected: String,
72 actual: String,
73 },
74
75 #[error(
76 "restore run for backup {backup_id} reported next_action={actual}, expected {expected}"
77 )]
78 RestoreRunNextActionMismatch {
79 backup_id: String,
80 expected: String,
81 actual: String,
82 },
83
84 #[error("restore run for backup {backup_id} executed {actual} operations, expected {expected}")]
85 RestoreRunExecutedCountMismatch {
86 backup_id: String,
87 expected: usize,
88 actual: usize,
89 },
90
91 #[error("restore run for backup {backup_id} wrote {actual} receipts, expected {expected}")]
92 RestoreRunReceiptCountMismatch {
93 backup_id: String,
94 expected: usize,
95 actual: usize,
96 },
97
98 #[error(
99 "restore run for backup {backup_id} wrote {actual} {receipt_kind} receipts, expected {expected}"
100 )]
101 RestoreRunReceiptKindCountMismatch {
102 backup_id: String,
103 receipt_kind: &'static str,
104 expected: usize,
105 actual: usize,
106 },
107
108 #[error(
109 "restore run for backup {backup_id} wrote {actual_receipts} receipts with {mismatched_receipts} updated_at mismatches, expected {expected}"
110 )]
111 RestoreRunReceiptUpdatedAtMismatch {
112 backup_id: String,
113 expected: String,
114 actual_receipts: usize,
115 mismatched_receipts: usize,
116 },
117
118 #[error(
119 "restore run for backup {backup_id} reported requested_state_updated_at={actual:?}, expected {expected}"
120 )]
121 RestoreRunStateUpdatedAtMismatch {
122 backup_id: String,
123 expected: String,
124 actual: Option<String>,
125 },
126
127 #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
128 RestoreNotReady {
129 backup_id: String,
130 reasons: Vec<String>,
131 },
132
133 #[error("restore manifest {backup_id} is not design ready")]
134 DesignConformanceNotReady { backup_id: String },
135
136 #[error(
137 "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
138 )]
139 RestoreApplyPending {
140 backup_id: String,
141 pending_operations: usize,
142 next_transition_sequence: Option<usize>,
143 },
144
145 #[error(
146 "restore apply journal for backup {backup_id} has stale or untracked pending work before {cutoff_updated_at}: pending_sequence={pending_sequence:?}, pending_updated_at={pending_updated_at:?}"
147 )]
148 RestoreApplyPendingStale {
149 backup_id: String,
150 cutoff_updated_at: String,
151 pending_sequence: Option<usize>,
152 pending_updated_at: Option<String>,
153 },
154
155 #[error(
156 "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
157 )]
158 RestoreApplyIncomplete {
159 backup_id: String,
160 completed_operations: usize,
161 operation_count: usize,
162 },
163
164 #[error(
165 "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
166 )]
167 RestoreApplyFailed {
168 backup_id: String,
169 failed_operations: usize,
170 },
171
172 #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
173 RestoreApplyNotReady {
174 backup_id: String,
175 reasons: Vec<String>,
176 },
177
178 #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
179 RestoreApplyReportNeedsAttention {
180 backup_id: String,
181 outcome: canic_backup::restore::RestoreApplyReportOutcome,
182 },
183
184 #[error(
185 "restore apply progress for backup {backup_id} has unexpected {field}: expected={expected}, actual={actual}"
186 )]
187 RestoreApplyProgressMismatch {
188 backup_id: String,
189 field: &'static str,
190 expected: usize,
191 actual: usize,
192 },
193
194 #[error(
195 "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
196 )]
197 RestoreApplyCommandUnavailable {
198 backup_id: String,
199 operation_available: bool,
200 complete: bool,
201 blocked_reasons: Vec<String>,
202 },
203
204 #[error(
205 "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
206 )]
207 RestoreRunClaimSequenceMismatch {
208 expected: usize,
209 actual: Option<usize>,
210 },
211
212 #[error("unknown option {0}")]
213 UnknownOption(String),
214
215 #[error("option --sequence requires a non-negative integer value")]
216 InvalidSequence,
217
218 #[error("option {option} requires a positive integer value")]
219 InvalidPositiveInteger { option: &'static str },
220
221 #[error(transparent)]
222 Io(#[from] std::io::Error),
223
224 #[error(transparent)]
225 Json(#[from] serde_json::Error),
226
227 #[error(transparent)]
228 Persistence(#[from] PersistenceError),
229
230 #[error(transparent)]
231 RestorePlan(#[from] RestorePlanError),
232
233 #[error(transparent)]
234 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
235
236 #[error(transparent)]
237 RestoreApplyJournal(#[from] RestoreApplyJournalError),
238}
239
240#[derive(Clone, Debug, Eq, PartialEq)]
245pub struct RestorePlanOptions {
246 pub manifest: Option<PathBuf>,
247 pub backup_dir: Option<PathBuf>,
248 pub mapping: Option<PathBuf>,
249 pub out: Option<PathBuf>,
250 pub require_verified: bool,
251 pub require_design_v1: bool,
252 pub require_restore_ready: bool,
253}
254
255impl RestorePlanOptions {
256 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
258 where
259 I: IntoIterator<Item = OsString>,
260 {
261 let matches = restore_plan_command()
262 .try_get_matches_from(std::iter::once(OsString::from("restore-plan")).chain(args))
263 .map_err(|_| RestoreCommandError::Usage(usage()))?;
264
265 let manifest = path_option(&matches, "manifest");
266 let backup_dir = path_option(&matches, "backup-dir");
267 let require_verified = matches.get_flag("require-verified");
268
269 if manifest.is_some() && backup_dir.is_some() {
270 return Err(RestoreCommandError::ConflictingManifestSources);
271 }
272
273 if manifest.is_none() && backup_dir.is_none() {
274 return Err(RestoreCommandError::MissingOption(
275 "--manifest or --backup-dir",
276 ));
277 }
278
279 if require_verified && backup_dir.is_none() {
280 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
281 }
282
283 Ok(Self {
284 manifest,
285 backup_dir,
286 mapping: path_option(&matches, "mapping"),
287 out: path_option(&matches, "out"),
288 require_verified,
289 require_design_v1: matches.get_flag("require-design"),
290 require_restore_ready: matches.get_flag("require-restore-ready"),
291 })
292 }
293}
294
295fn restore_plan_command() -> ClapCommand {
297 ClapCommand::new("restore-plan")
298 .disable_help_flag(true)
299 .arg(value_arg("manifest").long("manifest"))
300 .arg(value_arg("backup-dir").long("backup-dir"))
301 .arg(value_arg("mapping").long("mapping"))
302 .arg(value_arg("out").long("out"))
303 .arg(flag_arg("require-verified").long("require-verified"))
304 .arg(
305 flag_arg("require-design")
306 .long("require-design")
307 .alias("require-design-v1"),
308 )
309 .arg(flag_arg("require-restore-ready").long("require-restore-ready"))
310}
311
312#[derive(Clone, Debug, Eq, PartialEq)]
317pub struct RestoreStatusOptions {
318 pub plan: PathBuf,
319 pub out: Option<PathBuf>,
320}
321
322impl RestoreStatusOptions {
323 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
325 where
326 I: IntoIterator<Item = OsString>,
327 {
328 let matches = restore_status_command()
329 .try_get_matches_from(std::iter::once(OsString::from("restore-status")).chain(args))
330 .map_err(|_| RestoreCommandError::Usage(usage()))?;
331
332 Ok(Self {
333 plan: path_option(&matches, "plan")
334 .ok_or(RestoreCommandError::MissingOption("--plan"))?,
335 out: path_option(&matches, "out"),
336 })
337 }
338}
339
340fn restore_status_command() -> ClapCommand {
342 ClapCommand::new("restore-status")
343 .disable_help_flag(true)
344 .arg(value_arg("plan").long("plan"))
345 .arg(value_arg("out").long("out"))
346}
347
348#[derive(Clone, Debug, Eq, PartialEq)]
353pub struct RestoreApplyOptions {
354 pub plan: PathBuf,
355 pub status: Option<PathBuf>,
356 pub backup_dir: Option<PathBuf>,
357 pub out: Option<PathBuf>,
358 pub journal_out: Option<PathBuf>,
359 pub dry_run: bool,
360}
361
362impl RestoreApplyOptions {
363 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
365 where
366 I: IntoIterator<Item = OsString>,
367 {
368 let matches = restore_apply_command()
369 .try_get_matches_from(std::iter::once(OsString::from("restore-apply")).chain(args))
370 .map_err(|_| RestoreCommandError::Usage(usage()))?;
371 let dry_run = matches.get_flag("dry-run");
372
373 if !dry_run {
374 return Err(RestoreCommandError::ApplyRequiresDryRun);
375 }
376
377 Ok(Self {
378 plan: path_option(&matches, "plan")
379 .ok_or(RestoreCommandError::MissingOption("--plan"))?,
380 status: path_option(&matches, "status"),
381 backup_dir: path_option(&matches, "backup-dir"),
382 out: path_option(&matches, "out"),
383 journal_out: path_option(&matches, "journal-out"),
384 dry_run,
385 })
386 }
387}
388
389fn restore_apply_command() -> ClapCommand {
391 ClapCommand::new("restore-apply")
392 .disable_help_flag(true)
393 .arg(value_arg("plan").long("plan"))
394 .arg(value_arg("status").long("status"))
395 .arg(value_arg("backup-dir").long("backup-dir"))
396 .arg(value_arg("out").long("out"))
397 .arg(value_arg("journal-out").long("journal-out"))
398 .arg(flag_arg("dry-run").long("dry-run"))
399}
400
401#[derive(Clone, Debug, Eq, PartialEq)]
406#[expect(
407 clippy::struct_excessive_bools,
408 reason = "CLI status options mirror independent fail-closed guard flags"
409)]
410pub struct RestoreApplyStatusOptions {
411 pub journal: PathBuf,
412 pub require_ready: bool,
413 pub require_no_pending: bool,
414 pub require_no_failed: bool,
415 pub require_complete: bool,
416 pub require_remaining_count: Option<usize>,
417 pub require_attention_count: Option<usize>,
418 pub require_completion_basis_points: Option<usize>,
419 pub require_no_pending_before: Option<String>,
420 pub out: Option<PathBuf>,
421}
422
423impl RestoreApplyStatusOptions {
424 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
426 where
427 I: IntoIterator<Item = OsString>,
428 {
429 let matches = restore_apply_status_command()
430 .try_get_matches_from(
431 std::iter::once(OsString::from("restore-apply-status")).chain(args),
432 )
433 .map_err(|_| RestoreCommandError::Usage(usage()))?;
434
435 Ok(Self {
436 journal: path_option(&matches, "journal")
437 .ok_or(RestoreCommandError::MissingOption("--journal"))?,
438 require_ready: matches.get_flag("require-ready"),
439 require_no_pending: matches.get_flag("require-no-pending"),
440 require_no_failed: matches.get_flag("require-no-failed"),
441 require_complete: matches.get_flag("require-complete"),
442 require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
443 require_attention_count: sequence_option(&matches, "require-attention-count")?,
444 require_completion_basis_points: sequence_option(
445 &matches,
446 "require-completion-basis-points",
447 )?,
448 require_no_pending_before: string_option(&matches, "require-no-pending-before"),
449 out: path_option(&matches, "out"),
450 })
451 }
452}
453
454fn restore_apply_status_command() -> ClapCommand {
456 ClapCommand::new("restore-apply-status")
457 .disable_help_flag(true)
458 .arg(value_arg("journal").long("journal"))
459 .arg(flag_arg("require-ready").long("require-ready"))
460 .arg(flag_arg("require-no-pending").long("require-no-pending"))
461 .arg(flag_arg("require-no-failed").long("require-no-failed"))
462 .arg(flag_arg("require-complete").long("require-complete"))
463 .arg(value_arg("require-remaining-count").long("require-remaining-count"))
464 .arg(value_arg("require-attention-count").long("require-attention-count"))
465 .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
466 .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
467 .arg(value_arg("out").long("out"))
468}
469
470#[derive(Clone, Debug, Eq, PartialEq)]
475pub struct RestoreApplyReportOptions {
476 pub journal: PathBuf,
477 pub require_no_attention: bool,
478 pub require_remaining_count: Option<usize>,
479 pub require_attention_count: Option<usize>,
480 pub require_completion_basis_points: Option<usize>,
481 pub require_no_pending_before: Option<String>,
482 pub out: Option<PathBuf>,
483}
484
485impl RestoreApplyReportOptions {
486 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
488 where
489 I: IntoIterator<Item = OsString>,
490 {
491 let matches = restore_apply_report_command()
492 .try_get_matches_from(
493 std::iter::once(OsString::from("restore-apply-report")).chain(args),
494 )
495 .map_err(|_| RestoreCommandError::Usage(usage()))?;
496
497 Ok(Self {
498 journal: path_option(&matches, "journal")
499 .ok_or(RestoreCommandError::MissingOption("--journal"))?,
500 require_no_attention: matches.get_flag("require-no-attention"),
501 require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
502 require_attention_count: sequence_option(&matches, "require-attention-count")?,
503 require_completion_basis_points: sequence_option(
504 &matches,
505 "require-completion-basis-points",
506 )?,
507 require_no_pending_before: string_option(&matches, "require-no-pending-before"),
508 out: path_option(&matches, "out"),
509 })
510 }
511}
512
513fn restore_apply_report_command() -> ClapCommand {
515 ClapCommand::new("restore-apply-report")
516 .disable_help_flag(true)
517 .arg(value_arg("journal").long("journal"))
518 .arg(flag_arg("require-no-attention").long("require-no-attention"))
519 .arg(value_arg("require-remaining-count").long("require-remaining-count"))
520 .arg(value_arg("require-attention-count").long("require-attention-count"))
521 .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
522 .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
523 .arg(value_arg("out").long("out"))
524}
525
526#[derive(Clone, Debug, Eq, PartialEq)]
531#[expect(
532 clippy::struct_excessive_bools,
533 reason = "CLI runner options mirror independent mode and fail-closed guard flags"
534)]
535pub struct RestoreRunOptions {
536 pub journal: PathBuf,
537 pub dfx: String,
538 pub network: Option<String>,
539 pub out: Option<PathBuf>,
540 pub dry_run: bool,
541 pub execute: bool,
542 pub unclaim_pending: bool,
543 pub max_steps: Option<usize>,
544 pub updated_at: Option<String>,
545 pub require_complete: bool,
546 pub require_no_attention: bool,
547 pub require_run_mode: Option<String>,
548 pub require_stopped_reason: Option<String>,
549 pub require_next_action: Option<String>,
550 pub require_executed_count: Option<usize>,
551 pub require_receipt_count: Option<usize>,
552 pub require_completed_receipt_count: Option<usize>,
553 pub require_failed_receipt_count: Option<usize>,
554 pub require_recovered_receipt_count: Option<usize>,
555 pub require_receipt_updated_at: Option<String>,
556 pub require_state_updated_at: Option<String>,
557 pub require_remaining_count: Option<usize>,
558 pub require_attention_count: Option<usize>,
559 pub require_completion_basis_points: Option<usize>,
560 pub require_no_pending_before: Option<String>,
561}
562
563impl RestoreRunOptions {
564 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
566 where
567 I: IntoIterator<Item = OsString>,
568 {
569 let matches = restore_run_command()
570 .try_get_matches_from(std::iter::once(OsString::from("restore-run")).chain(args))
571 .map_err(|_| RestoreCommandError::Usage(usage()))?;
572
573 let dry_run = matches.get_flag("dry-run");
574 let execute = matches.get_flag("execute");
575 let unclaim_pending = matches.get_flag("unclaim-pending");
576
577 validate_restore_run_mode_selection(dry_run, execute, unclaim_pending)?;
578
579 Ok(Self {
580 journal: path_option(&matches, "journal")
581 .ok_or(RestoreCommandError::MissingOption("--journal"))?,
582 dfx: string_option(&matches, "dfx").unwrap_or_else(|| "dfx".to_string()),
583 network: string_option(&matches, "network"),
584 out: path_option(&matches, "out"),
585 dry_run,
586 execute,
587 unclaim_pending,
588 max_steps: positive_integer_option(&matches, "max-steps", "--max-steps")?,
589 updated_at: string_option(&matches, "updated-at"),
590 require_complete: matches.get_flag("require-complete"),
591 require_no_attention: matches.get_flag("require-no-attention"),
592 require_run_mode: string_option(&matches, "require-run-mode"),
593 require_stopped_reason: string_option(&matches, "require-stopped-reason"),
594 require_next_action: string_option(&matches, "require-next-action"),
595 require_executed_count: sequence_option(&matches, "require-executed-count")?,
596 require_receipt_count: sequence_option(&matches, "require-receipt-count")?,
597 require_completed_receipt_count: sequence_option(
598 &matches,
599 "require-completed-receipt-count",
600 )?,
601 require_failed_receipt_count: sequence_option(
602 &matches,
603 "require-failed-receipt-count",
604 )?,
605 require_recovered_receipt_count: sequence_option(
606 &matches,
607 "require-recovered-receipt-count",
608 )?,
609 require_receipt_updated_at: string_option(&matches, "require-receipt-updated-at"),
610 require_state_updated_at: string_option(&matches, "require-state-updated-at"),
611 require_remaining_count: sequence_option(&matches, "require-remaining-count")?,
612 require_attention_count: sequence_option(&matches, "require-attention-count")?,
613 require_completion_basis_points: sequence_option(
614 &matches,
615 "require-completion-basis-points",
616 )?,
617 require_no_pending_before: string_option(&matches, "require-no-pending-before"),
618 })
619 }
620}
621
622fn restore_run_command() -> ClapCommand {
624 ClapCommand::new("restore-run")
625 .disable_help_flag(true)
626 .arg(value_arg("journal").long("journal"))
627 .arg(value_arg("dfx").long("dfx"))
628 .arg(value_arg("network").long("network"))
629 .arg(value_arg("out").long("out"))
630 .arg(flag_arg("dry-run").long("dry-run"))
631 .arg(flag_arg("execute").long("execute"))
632 .arg(flag_arg("unclaim-pending").long("unclaim-pending"))
633 .arg(value_arg("max-steps").long("max-steps"))
634 .arg(value_arg("updated-at").long("updated-at"))
635 .arg(flag_arg("require-complete").long("require-complete"))
636 .arg(flag_arg("require-no-attention").long("require-no-attention"))
637 .arg(value_arg("require-run-mode").long("require-run-mode"))
638 .arg(value_arg("require-stopped-reason").long("require-stopped-reason"))
639 .arg(value_arg("require-next-action").long("require-next-action"))
640 .arg(value_arg("require-executed-count").long("require-executed-count"))
641 .arg(value_arg("require-receipt-count").long("require-receipt-count"))
642 .arg(value_arg("require-completed-receipt-count").long("require-completed-receipt-count"))
643 .arg(value_arg("require-failed-receipt-count").long("require-failed-receipt-count"))
644 .arg(value_arg("require-recovered-receipt-count").long("require-recovered-receipt-count"))
645 .arg(value_arg("require-receipt-updated-at").long("require-receipt-updated-at"))
646 .arg(value_arg("require-state-updated-at").long("require-state-updated-at"))
647 .arg(value_arg("require-remaining-count").long("require-remaining-count"))
648 .arg(value_arg("require-attention-count").long("require-attention-count"))
649 .arg(value_arg("require-completion-basis-points").long("require-completion-basis-points"))
650 .arg(value_arg("require-no-pending-before").long("require-no-pending-before"))
651}
652
653fn value_arg(id: &'static str) -> Arg {
655 Arg::new(id).num_args(1)
656}
657
658fn flag_arg(id: &'static str) -> Arg {
660 Arg::new(id).action(ArgAction::SetTrue)
661}
662
663fn string_option(matches: &clap::ArgMatches, id: &str) -> Option<String> {
665 matches.get_one::<String>(id).cloned()
666}
667
668fn path_option(matches: &clap::ArgMatches, id: &str) -> Option<PathBuf> {
670 string_option(matches, id).map(PathBuf::from)
671}
672
673fn sequence_option(
675 matches: &clap::ArgMatches,
676 id: &str,
677) -> Result<Option<usize>, RestoreCommandError> {
678 string_option(matches, id).map(parse_sequence).transpose()
679}
680
681fn positive_integer_option(
683 matches: &clap::ArgMatches,
684 id: &str,
685 option: &'static str,
686) -> Result<Option<usize>, RestoreCommandError> {
687 string_option(matches, id)
688 .map(|value| parse_positive_integer(option, value))
689 .transpose()
690}
691
692fn validate_restore_run_mode_selection(
694 dry_run: bool,
695 execute: bool,
696 unclaim_pending: bool,
697) -> Result<(), RestoreCommandError> {
698 let mode_count = [dry_run, execute, unclaim_pending]
699 .into_iter()
700 .filter(|enabled| *enabled)
701 .count();
702 if mode_count > 1 {
703 return Err(RestoreCommandError::RestoreRunConflictingModes);
704 }
705
706 if mode_count == 0 {
707 return Err(RestoreCommandError::RestoreRunRequiresMode);
708 }
709
710 Ok(())
711}
712
713struct RestoreRunResult {
718 response: RestoreRunResponse,
719 error: Option<RestoreCommandError>,
720}
721
722impl RestoreRunResult {
723 const fn ok(response: RestoreRunResponse) -> Self {
725 Self {
726 response,
727 error: None,
728 }
729 }
730}
731
732const RESTORE_RUN_MODE_DRY_RUN: &str = "dry-run";
733const RESTORE_RUN_MODE_EXECUTE: &str = "execute";
734const RESTORE_RUN_MODE_UNCLAIM_PENDING: &str = "unclaim-pending";
735
736const RESTORE_RUN_STOPPED_BLOCKED: &str = "blocked";
737const RESTORE_RUN_STOPPED_COMMAND_FAILED: &str = "command-failed";
738const RESTORE_RUN_STOPPED_COMPLETE: &str = "complete";
739const RESTORE_RUN_STOPPED_MAX_STEPS: &str = "max-steps-reached";
740const RESTORE_RUN_STOPPED_PENDING: &str = "pending";
741const RESTORE_RUN_STOPPED_PREVIEW: &str = "preview";
742const RESTORE_RUN_STOPPED_READY: &str = "ready";
743const RESTORE_RUN_STOPPED_RECOVERED_PENDING: &str = "recovered-pending";
744
745const RESTORE_RUN_ACTION_DONE: &str = "done";
746const RESTORE_RUN_ACTION_FIX_BLOCKED: &str = "fix-blocked-journal";
747const RESTORE_RUN_ACTION_INSPECT_FAILED: &str = "inspect-failed-operation";
748const RESTORE_RUN_ACTION_RERUN: &str = "rerun";
749const RESTORE_RUN_ACTION_UNCLAIM_PENDING: &str = "unclaim-pending";
750
751const RESTORE_RUN_EXECUTED_COMPLETED: &str = "completed";
752const RESTORE_RUN_EXECUTED_FAILED: &str = "failed";
753const RESTORE_RUN_RECEIPT_COMPLETED: &str = "command-completed";
754const RESTORE_RUN_RECEIPT_FAILED: &str = "command-failed";
755const RESTORE_RUN_RECEIPT_RECOVERED_PENDING: &str = "pending-recovered";
756const RESTORE_RUN_RECEIPT_STATE_READY: &str = "ready";
757const RESTORE_RUN_COMMAND_EXIT_PREFIX: &str = "runner-command-exit";
758const RESTORE_RUN_RESPONSE_VERSION: u16 = 1;
759const RESTORE_RUN_OUTPUT_RECEIPT_LIMIT: usize = 64 * 1024;
760
761#[derive(Clone, Debug, Serialize)]
766#[expect(
767 clippy::struct_excessive_bools,
768 reason = "Runner response exposes stable JSON status flags for operators and CI"
769)]
770pub struct RestoreRunResponse {
771 run_version: u16,
772 backup_id: String,
773 run_mode: &'static str,
774 dry_run: bool,
775 execute: bool,
776 unclaim_pending: bool,
777 stopped_reason: &'static str,
778 next_action: &'static str,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 requested_state_updated_at: Option<String>,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 max_steps_reached: Option<bool>,
783 #[serde(default, skip_serializing_if = "Vec::is_empty")]
784 executed_operations: Vec<RestoreRunExecutedOperation>,
785 #[serde(default, skip_serializing_if = "Vec::is_empty")]
786 operation_receipts: Vec<RestoreRunOperationReceipt>,
787 #[serde(skip_serializing_if = "Option::is_none")]
788 operation_receipt_count: Option<usize>,
789 operation_receipt_summary: RestoreRunReceiptSummary,
790 #[serde(skip_serializing_if = "Option::is_none")]
791 executed_operation_count: Option<usize>,
792 #[serde(skip_serializing_if = "Option::is_none")]
793 recovered_operation: Option<RestoreApplyJournalOperation>,
794 batch_summary: RestoreRunBatchSummary,
795 ready: bool,
796 complete: bool,
797 attention_required: bool,
798 outcome: RestoreApplyReportOutcome,
799 operation_count: usize,
800 operation_counts: RestoreApplyOperationKindCounts,
801 operation_counts_supplied: bool,
802 progress: RestoreApplyProgressSummary,
803 pending_summary: RestoreApplyPendingSummary,
804 pending_operations: usize,
805 ready_operations: usize,
806 blocked_operations: usize,
807 completed_operations: usize,
808 failed_operations: usize,
809 blocked_reasons: Vec<String>,
810 next_transition: Option<RestoreApplyReportOperation>,
811 #[serde(skip_serializing_if = "Option::is_none")]
812 operation_available: Option<bool>,
813 #[serde(skip_serializing_if = "Option::is_none")]
814 command_available: Option<bool>,
815 #[serde(skip_serializing_if = "Option::is_none")]
816 command: Option<RestoreApplyRunnerCommand>,
817}
818
819impl RestoreRunResponse {
820 fn from_report(
822 backup_id: String,
823 report: RestoreApplyJournalReport,
824 mode: RestoreRunResponseMode,
825 ) -> Self {
826 Self {
827 run_version: RESTORE_RUN_RESPONSE_VERSION,
828 backup_id,
829 run_mode: mode.run_mode,
830 dry_run: mode.dry_run,
831 execute: mode.execute,
832 unclaim_pending: mode.unclaim_pending,
833 stopped_reason: mode.stopped_reason,
834 next_action: mode.next_action,
835 requested_state_updated_at: None,
836 max_steps_reached: None,
837 executed_operations: Vec::new(),
838 operation_receipts: Vec::new(),
839 operation_receipt_count: Some(0),
840 operation_receipt_summary: RestoreRunReceiptSummary::default(),
841 executed_operation_count: None,
842 recovered_operation: None,
843 batch_summary: RestoreRunBatchSummary::from_counts(
844 RestoreRunBatchStart::new(
845 None,
846 report.ready_operations,
847 report.progress.remaining_operations,
848 ),
849 0,
850 report.ready_operations,
851 report.progress.remaining_operations,
852 false,
853 report.complete,
854 ),
855 ready: report.ready,
856 complete: report.complete,
857 attention_required: report.attention_required,
858 outcome: report.outcome,
859 operation_count: report.operation_count,
860 operation_counts: report.operation_counts,
861 operation_counts_supplied: report.operation_counts_supplied,
862 progress: report.progress,
863 pending_summary: report.pending_summary,
864 pending_operations: report.pending_operations,
865 ready_operations: report.ready_operations,
866 blocked_operations: report.blocked_operations,
867 completed_operations: report.completed_operations,
868 failed_operations: report.failed_operations,
869 blocked_reasons: report.blocked_reasons,
870 next_transition: report.next_transition,
871 operation_available: None,
872 command_available: None,
873 command: None,
874 }
875 }
876
877 fn set_operation_receipts(&mut self, receipts: Vec<RestoreRunOperationReceipt>) {
879 self.operation_receipt_summary = RestoreRunReceiptSummary::from_receipts(&receipts);
880 self.operation_receipt_count = Some(receipts.len());
881 self.operation_receipts = receipts;
882 }
883
884 fn set_requested_state_updated_at(&mut self, updated_at: Option<&String>) {
886 self.requested_state_updated_at = updated_at.cloned();
887 }
888
889 const fn set_batch_summary(
891 &mut self,
892 batch_start: RestoreRunBatchStart,
893 executed_operations: usize,
894 stopped_by_max_steps: bool,
895 ) {
896 self.batch_summary = RestoreRunBatchSummary::from_counts(
897 batch_start,
898 executed_operations,
899 self.ready_operations,
900 self.progress.remaining_operations,
901 stopped_by_max_steps,
902 self.complete,
903 );
904 }
905}
906
907#[derive(Clone, Copy, Debug)]
912struct RestoreRunBatchStart {
913 requested_max_steps: Option<usize>,
914 initial_ready_operations: usize,
915 initial_remaining_operations: usize,
916}
917
918impl RestoreRunBatchStart {
919 const fn new(
921 requested_max_steps: Option<usize>,
922 initial_ready_operations: usize,
923 initial_remaining_operations: usize,
924 ) -> Self {
925 Self {
926 requested_max_steps,
927 initial_ready_operations,
928 initial_remaining_operations,
929 }
930 }
931}
932
933#[derive(Clone, Debug, Serialize)]
938struct RestoreRunBatchSummary {
939 requested_max_steps: Option<usize>,
940 initial_ready_operations: usize,
941 initial_remaining_operations: usize,
942 executed_operations: usize,
943 remaining_ready_operations: usize,
944 remaining_operations: usize,
945 ready_operations_delta: isize,
946 remaining_operations_delta: isize,
947 stopped_by_max_steps: bool,
948 complete: bool,
949}
950
951impl RestoreRunBatchSummary {
952 const fn from_counts(
954 batch_start: RestoreRunBatchStart,
955 executed_operations: usize,
956 remaining_ready_operations: usize,
957 remaining_operations: usize,
958 stopped_by_max_steps: bool,
959 complete: bool,
960 ) -> Self {
961 Self {
962 requested_max_steps: batch_start.requested_max_steps,
963 initial_ready_operations: batch_start.initial_ready_operations,
964 initial_remaining_operations: batch_start.initial_remaining_operations,
965 executed_operations,
966 remaining_ready_operations,
967 remaining_operations,
968 ready_operations_delta: remaining_ready_operations.cast_signed()
969 - batch_start.initial_ready_operations.cast_signed(),
970 remaining_operations_delta: remaining_operations.cast_signed()
971 - batch_start.initial_remaining_operations.cast_signed(),
972 stopped_by_max_steps,
973 complete,
974 }
975 }
976}
977
978#[derive(Clone, Debug, Default, Serialize)]
983struct RestoreRunReceiptSummary {
984 total_receipts: usize,
985 command_completed: usize,
986 command_failed: usize,
987 pending_recovered: usize,
988}
989
990impl RestoreRunReceiptSummary {
991 fn from_receipts(receipts: &[RestoreRunOperationReceipt]) -> Self {
993 let mut summary = Self {
994 total_receipts: receipts.len(),
995 ..Self::default()
996 };
997
998 for receipt in receipts {
999 match receipt.event {
1000 RESTORE_RUN_RECEIPT_COMPLETED => summary.command_completed += 1,
1001 RESTORE_RUN_RECEIPT_FAILED => summary.command_failed += 1,
1002 RESTORE_RUN_RECEIPT_RECOVERED_PENDING => summary.pending_recovered += 1,
1003 _ => {}
1004 }
1005 }
1006
1007 summary
1008 }
1009}
1010
1011#[derive(Clone, Debug, Serialize)]
1016struct RestoreRunOperationReceipt {
1017 event: &'static str,
1018 sequence: usize,
1019 operation: RestoreApplyOperationKind,
1020 target_canister: String,
1021 state: &'static str,
1022 #[serde(skip_serializing_if = "Option::is_none")]
1023 updated_at: Option<String>,
1024 #[serde(skip_serializing_if = "Option::is_none")]
1025 command: Option<RestoreApplyRunnerCommand>,
1026 #[serde(skip_serializing_if = "Option::is_none")]
1027 status: Option<String>,
1028}
1029
1030impl RestoreRunOperationReceipt {
1031 fn completed(
1033 operation: RestoreApplyJournalOperation,
1034 command: RestoreApplyRunnerCommand,
1035 status: String,
1036 updated_at: Option<String>,
1037 ) -> Self {
1038 Self::from_operation(
1039 RESTORE_RUN_RECEIPT_COMPLETED,
1040 operation,
1041 RESTORE_RUN_EXECUTED_COMPLETED,
1042 updated_at,
1043 Some(command),
1044 Some(status),
1045 )
1046 }
1047
1048 fn failed(
1050 operation: RestoreApplyJournalOperation,
1051 command: RestoreApplyRunnerCommand,
1052 status: String,
1053 updated_at: Option<String>,
1054 ) -> Self {
1055 Self::from_operation(
1056 RESTORE_RUN_RECEIPT_FAILED,
1057 operation,
1058 RESTORE_RUN_EXECUTED_FAILED,
1059 updated_at,
1060 Some(command),
1061 Some(status),
1062 )
1063 }
1064
1065 fn recovered_pending(
1067 operation: RestoreApplyJournalOperation,
1068 updated_at: Option<String>,
1069 ) -> Self {
1070 Self::from_operation(
1071 RESTORE_RUN_RECEIPT_RECOVERED_PENDING,
1072 operation,
1073 RESTORE_RUN_RECEIPT_STATE_READY,
1074 updated_at,
1075 None,
1076 None,
1077 )
1078 }
1079
1080 fn from_operation(
1082 event: &'static str,
1083 operation: RestoreApplyJournalOperation,
1084 state: &'static str,
1085 updated_at: Option<String>,
1086 command: Option<RestoreApplyRunnerCommand>,
1087 status: Option<String>,
1088 ) -> Self {
1089 Self {
1090 event,
1091 sequence: operation.sequence,
1092 operation: operation.operation,
1093 target_canister: operation.target_canister,
1094 state,
1095 updated_at,
1096 command,
1097 status,
1098 }
1099 }
1100}
1101
1102#[derive(Clone, Debug, Serialize)]
1107struct RestoreRunExecutedOperation {
1108 sequence: usize,
1109 operation: RestoreApplyOperationKind,
1110 target_canister: String,
1111 command: RestoreApplyRunnerCommand,
1112 status: String,
1113 state: &'static str,
1114}
1115
1116impl RestoreRunExecutedOperation {
1117 fn completed(
1119 operation: RestoreApplyJournalOperation,
1120 command: RestoreApplyRunnerCommand,
1121 status: String,
1122 ) -> Self {
1123 Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_COMPLETED)
1124 }
1125
1126 fn failed(
1128 operation: RestoreApplyJournalOperation,
1129 command: RestoreApplyRunnerCommand,
1130 status: String,
1131 ) -> Self {
1132 Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_FAILED)
1133 }
1134
1135 fn from_operation(
1137 operation: RestoreApplyJournalOperation,
1138 command: RestoreApplyRunnerCommand,
1139 status: String,
1140 state: &'static str,
1141 ) -> Self {
1142 Self {
1143 sequence: operation.sequence,
1144 operation: operation.operation,
1145 target_canister: operation.target_canister,
1146 command,
1147 status,
1148 state,
1149 }
1150 }
1151}
1152
1153struct RestoreRunResponseMode {
1158 run_mode: &'static str,
1159 dry_run: bool,
1160 execute: bool,
1161 unclaim_pending: bool,
1162 stopped_reason: &'static str,
1163 next_action: &'static str,
1164}
1165
1166impl RestoreRunResponseMode {
1167 const fn new(
1169 run_mode: &'static str,
1170 dry_run: bool,
1171 execute: bool,
1172 unclaim_pending: bool,
1173 stopped_reason: &'static str,
1174 next_action: &'static str,
1175 ) -> Self {
1176 Self {
1177 run_mode,
1178 dry_run,
1179 execute,
1180 unclaim_pending,
1181 stopped_reason,
1182 next_action,
1183 }
1184 }
1185
1186 const fn dry_run(stopped_reason: &'static str, next_action: &'static str) -> Self {
1188 Self::new(
1189 RESTORE_RUN_MODE_DRY_RUN,
1190 true,
1191 false,
1192 false,
1193 stopped_reason,
1194 next_action,
1195 )
1196 }
1197
1198 const fn execute(stopped_reason: &'static str, next_action: &'static str) -> Self {
1200 Self::new(
1201 RESTORE_RUN_MODE_EXECUTE,
1202 false,
1203 true,
1204 false,
1205 stopped_reason,
1206 next_action,
1207 )
1208 }
1209
1210 const fn unclaim_pending(next_action: &'static str) -> Self {
1212 Self::new(
1213 RESTORE_RUN_MODE_UNCLAIM_PENDING,
1214 false,
1215 false,
1216 true,
1217 RESTORE_RUN_STOPPED_RECOVERED_PENDING,
1218 next_action,
1219 )
1220 }
1221}
1222
1223pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
1225where
1226 I: IntoIterator<Item = OsString>,
1227{
1228 let mut args = args.into_iter();
1229 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
1230 return Err(RestoreCommandError::Usage(usage()));
1231 };
1232
1233 match command.as_str() {
1234 "plan" => {
1235 let options = RestorePlanOptions::parse(args)?;
1236 let plan = plan_restore(&options)?;
1237 write_plan(&options, &plan)?;
1238 enforce_restore_plan_requirements(&options, &plan)?;
1239 Ok(())
1240 }
1241 "status" => {
1242 let options = RestoreStatusOptions::parse(args)?;
1243 let status = restore_status(&options)?;
1244 write_status(&options, &status)?;
1245 Ok(())
1246 }
1247 "apply" => {
1248 let options = RestoreApplyOptions::parse(args)?;
1249 let dry_run = restore_apply_dry_run(&options)?;
1250 write_apply_dry_run(&options, &dry_run)?;
1251 write_apply_journal_if_requested(&options, &dry_run)?;
1252 Ok(())
1253 }
1254 "apply-status" => {
1255 let options = RestoreApplyStatusOptions::parse(args)?;
1256 let status = restore_apply_status(&options)?;
1257 write_apply_status(&options, &status)?;
1258 enforce_apply_status_requirements(&options, &status)?;
1259 Ok(())
1260 }
1261 "apply-report" => {
1262 let options = RestoreApplyReportOptions::parse(args)?;
1263 let report = restore_apply_report(&options)?;
1264 write_apply_report(&options, &report)?;
1265 enforce_apply_report_requirements(&options, &report)?;
1266 Ok(())
1267 }
1268 "run" => {
1269 let options = RestoreRunOptions::parse(args)?;
1270 let run = if options.execute {
1271 restore_run_execute_result(&options)?
1272 } else if options.unclaim_pending {
1273 RestoreRunResult::ok(restore_run_unclaim_pending(&options)?)
1274 } else {
1275 RestoreRunResult::ok(restore_run_dry_run(&options)?)
1276 };
1277 write_restore_run(&options, &run.response)?;
1278 if let Some(error) = run.error {
1279 return Err(error);
1280 }
1281 enforce_restore_run_requirements(&options, &run.response)?;
1282 Ok(())
1283 }
1284 "help" | "--help" | "-h" => {
1285 println!("{}", usage());
1286 Ok(())
1287 }
1288 "version" | "--version" | "-V" => {
1289 println!("{}", version_text());
1290 Ok(())
1291 }
1292 _ => Err(RestoreCommandError::UnknownOption(command)),
1293 }
1294}
1295
1296pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
1298 verify_backup_layout_if_required(options)?;
1299
1300 let manifest = read_manifest_source(options)?;
1301 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
1302
1303 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
1304}
1305
1306pub fn restore_status(
1308 options: &RestoreStatusOptions,
1309) -> Result<RestoreStatus, RestoreCommandError> {
1310 let plan = read_plan(&options.plan)?;
1311 Ok(RestoreStatus::from_plan(&plan))
1312}
1313
1314pub fn restore_apply_dry_run(
1316 options: &RestoreApplyOptions,
1317) -> Result<RestoreApplyDryRun, RestoreCommandError> {
1318 let plan = read_plan(&options.plan)?;
1319 let status = options.status.as_ref().map(read_status).transpose()?;
1320 if let Some(backup_dir) = &options.backup_dir {
1321 return RestoreApplyDryRun::try_from_plan_with_artifacts(
1322 &plan,
1323 status.as_ref(),
1324 backup_dir,
1325 )
1326 .map_err(RestoreCommandError::from);
1327 }
1328
1329 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
1330}
1331
1332pub fn restore_apply_status(
1334 options: &RestoreApplyStatusOptions,
1335) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
1336 let journal = read_apply_journal(&options.journal)?;
1337 Ok(journal.status())
1338}
1339
1340pub fn restore_apply_report(
1342 options: &RestoreApplyReportOptions,
1343) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
1344 let journal = read_apply_journal(&options.journal)?;
1345 Ok(journal.report())
1346}
1347
1348pub fn restore_run_dry_run(
1350 options: &RestoreRunOptions,
1351) -> Result<RestoreRunResponse, RestoreCommandError> {
1352 let journal = read_apply_journal(&options.journal)?;
1353 let report = journal.report();
1354 let initial_ready_operations = report.ready_operations;
1355 let initial_remaining_operations = report.progress.remaining_operations;
1356 let preview = journal.next_command_preview_with_config(&restore_run_command_config(options));
1357 let stopped_reason = restore_run_stopped_reason(&report, false, false);
1358 let next_action = restore_run_next_action(&report, false);
1359
1360 let mut response = RestoreRunResponse::from_report(
1361 journal.backup_id,
1362 report,
1363 RestoreRunResponseMode::dry_run(stopped_reason, next_action),
1364 );
1365 response.set_requested_state_updated_at(options.updated_at.as_ref());
1366 response.set_batch_summary(
1367 RestoreRunBatchStart::new(
1368 options.max_steps,
1369 initial_ready_operations,
1370 initial_remaining_operations,
1371 ),
1372 0,
1373 false,
1374 );
1375 response.operation_available = Some(preview.operation_available);
1376 response.command_available = Some(preview.command_available);
1377 response.command = preview.command;
1378 Ok(response)
1379}
1380
1381pub fn restore_run_unclaim_pending(
1383 options: &RestoreRunOptions,
1384) -> Result<RestoreRunResponse, RestoreCommandError> {
1385 let _lock = RestoreJournalLock::acquire(&options.journal)?;
1386 let mut journal = read_apply_journal(&options.journal)?;
1387 let initial_report = journal.report();
1388 let initial_ready_operations = initial_report.ready_operations;
1389 let initial_remaining_operations = initial_report.progress.remaining_operations;
1390 let recovered_operation = journal
1391 .next_transition_operation()
1392 .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
1393 .cloned()
1394 .ok_or(RestoreApplyJournalError::NoPendingOperation)?;
1395
1396 let recovered_updated_at = state_updated_at(options.updated_at.as_ref());
1397 journal.mark_next_operation_ready_at(Some(recovered_updated_at.clone()))?;
1398 write_apply_journal_file(&options.journal, &journal)?;
1399
1400 let report = journal.report();
1401 let next_action = restore_run_next_action(&report, true);
1402 let mut response = RestoreRunResponse::from_report(
1403 journal.backup_id,
1404 report,
1405 RestoreRunResponseMode::unclaim_pending(next_action),
1406 );
1407 response.set_requested_state_updated_at(options.updated_at.as_ref());
1408 response.set_batch_summary(
1409 RestoreRunBatchStart::new(
1410 options.max_steps,
1411 initial_ready_operations,
1412 initial_remaining_operations,
1413 ),
1414 0,
1415 false,
1416 );
1417 response.set_operation_receipts(vec![RestoreRunOperationReceipt::recovered_pending(
1418 recovered_operation.clone(),
1419 Some(recovered_updated_at),
1420 )]);
1421 response.recovered_operation = Some(recovered_operation);
1422 Ok(response)
1423}
1424
1425pub fn restore_run_execute(
1427 options: &RestoreRunOptions,
1428) -> Result<RestoreRunResponse, RestoreCommandError> {
1429 let run = restore_run_execute_result(options)?;
1430 if let Some(error) = run.error {
1431 return Err(error);
1432 }
1433
1434 Ok(run.response)
1435}
1436
1437#[expect(
1439 clippy::too_many_lines,
1440 reason = "runner execution keeps claim, command, receipt, and journal commit steps together"
1441)]
1442fn restore_run_execute_result(
1443 options: &RestoreRunOptions,
1444) -> Result<RestoreRunResult, RestoreCommandError> {
1445 let _lock = RestoreJournalLock::acquire(&options.journal)?;
1446 let mut journal = read_apply_journal(&options.journal)?;
1447 let initial_report = journal.report();
1448 let batch_start = RestoreRunBatchStart::new(
1449 options.max_steps,
1450 initial_report.ready_operations,
1451 initial_report.progress.remaining_operations,
1452 );
1453 let mut executed_operations = Vec::new();
1454 let mut operation_receipts = Vec::new();
1455 let config = restore_run_command_config(options);
1456
1457 loop {
1458 let report = journal.report();
1459 let max_steps_reached =
1460 restore_run_max_steps_reached(options, executed_operations.len(), &report);
1461 if report.complete || max_steps_reached {
1462 return Ok(RestoreRunResult::ok(restore_run_execute_summary(
1463 &journal,
1464 executed_operations,
1465 operation_receipts,
1466 max_steps_reached,
1467 options.updated_at.as_ref(),
1468 batch_start,
1469 )));
1470 }
1471
1472 enforce_restore_run_executable(&journal, &report)?;
1473 let preview = journal.next_command_preview_with_config(&config);
1474 enforce_restore_run_command_available(&preview)?;
1475
1476 let operation = preview
1477 .operation
1478 .clone()
1479 .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1480 let command = preview
1481 .command
1482 .clone()
1483 .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1484 let sequence = operation.sequence;
1485 let attempt = journal
1486 .operation_receipts
1487 .iter()
1488 .filter(|receipt| receipt.sequence == sequence)
1489 .count()
1490 + 1;
1491
1492 enforce_apply_claim_sequence(sequence, &journal)?;
1493 journal.mark_operation_pending_at(
1494 sequence,
1495 Some(state_updated_at(options.updated_at.as_ref())),
1496 )?;
1497 write_apply_journal_file(&options.journal, &journal)?;
1498
1499 let output = ProcessCommand::new(&command.program)
1500 .args(&command.args)
1501 .output()?;
1502 let status_label = exit_status_label(output.status);
1503 let output_pair = RestoreApplyCommandOutputPair::from_bytes(
1504 &output.stdout,
1505 &output.stderr,
1506 RESTORE_RUN_OUTPUT_RECEIPT_LIMIT,
1507 );
1508 if output.status.success() {
1509 let uploaded_snapshot_id =
1510 parse_uploaded_snapshot_id(&String::from_utf8_lossy(&output.stdout));
1511 let completed_updated_at = state_updated_at(options.updated_at.as_ref());
1512 journal.mark_operation_completed_at(sequence, Some(completed_updated_at.clone()))?;
1513 if operation.operation != RestoreApplyOperationKind::UploadSnapshot
1514 || uploaded_snapshot_id.is_some()
1515 {
1516 journal.record_operation_receipt(
1517 RestoreApplyOperationReceipt::command_completed(
1518 &operation,
1519 command.clone(),
1520 status_label.clone(),
1521 Some(completed_updated_at.clone()),
1522 output_pair.clone(),
1523 attempt,
1524 uploaded_snapshot_id,
1525 ),
1526 )?;
1527 }
1528 write_apply_journal_file(&options.journal, &journal)?;
1529 executed_operations.push(RestoreRunExecutedOperation::completed(
1530 operation.clone(),
1531 command.clone(),
1532 status_label.clone(),
1533 ));
1534 operation_receipts.push(RestoreRunOperationReceipt::completed(
1535 operation,
1536 command,
1537 status_label,
1538 Some(completed_updated_at),
1539 ));
1540 continue;
1541 }
1542
1543 let failed_updated_at = state_updated_at(options.updated_at.as_ref());
1544 let failure_reason = format!("{RESTORE_RUN_COMMAND_EXIT_PREFIX}-{status_label}");
1545 journal.mark_operation_failed_at(
1546 sequence,
1547 failure_reason.clone(),
1548 Some(failed_updated_at.clone()),
1549 )?;
1550 journal.record_operation_receipt(RestoreApplyOperationReceipt::command_failed(
1551 &operation,
1552 command.clone(),
1553 status_label.clone(),
1554 Some(failed_updated_at.clone()),
1555 output_pair,
1556 attempt,
1557 failure_reason,
1558 ))?;
1559 write_apply_journal_file(&options.journal, &journal)?;
1560 executed_operations.push(RestoreRunExecutedOperation::failed(
1561 operation.clone(),
1562 command.clone(),
1563 status_label.clone(),
1564 ));
1565 operation_receipts.push(RestoreRunOperationReceipt::failed(
1566 operation,
1567 command,
1568 status_label.clone(),
1569 Some(failed_updated_at),
1570 ));
1571 let response = restore_run_execute_summary(
1572 &journal,
1573 executed_operations,
1574 operation_receipts,
1575 false,
1576 options.updated_at.as_ref(),
1577 batch_start,
1578 );
1579 return Ok(RestoreRunResult {
1580 response,
1581 error: Some(RestoreCommandError::RestoreRunCommandFailed {
1582 sequence,
1583 status: status_label,
1584 }),
1585 });
1586 }
1587}
1588
1589fn restore_run_command_config(options: &RestoreRunOptions) -> RestoreApplyCommandConfig {
1591 restore_command_config(&options.dfx, options.network.as_deref())
1592}
1593
1594fn restore_command_config(program: &str, network: Option<&str>) -> RestoreApplyCommandConfig {
1596 RestoreApplyCommandConfig {
1597 program: program.to_string(),
1598 network: network.map(str::to_string),
1599 }
1600}
1601
1602fn restore_run_max_steps_reached(
1604 options: &RestoreRunOptions,
1605 executed_operation_count: usize,
1606 report: &RestoreApplyJournalReport,
1607) -> bool {
1608 options.max_steps == Some(executed_operation_count) && !report.complete
1609}
1610
1611fn restore_run_execute_summary(
1613 journal: &RestoreApplyJournal,
1614 executed_operations: Vec<RestoreRunExecutedOperation>,
1615 operation_receipts: Vec<RestoreRunOperationReceipt>,
1616 max_steps_reached: bool,
1617 requested_state_updated_at: Option<&String>,
1618 batch_start: RestoreRunBatchStart,
1619) -> RestoreRunResponse {
1620 let report = journal.report();
1621 let executed_operation_count = executed_operations.len();
1622 let stopped_reason = restore_run_stopped_reason(&report, max_steps_reached, true);
1623 let next_action = restore_run_next_action(&report, false);
1624
1625 let mut response = RestoreRunResponse::from_report(
1626 journal.backup_id.clone(),
1627 report,
1628 RestoreRunResponseMode::execute(stopped_reason, next_action),
1629 );
1630 response.set_requested_state_updated_at(requested_state_updated_at);
1631 response.set_batch_summary(batch_start, executed_operation_count, max_steps_reached);
1632 response.max_steps_reached = Some(max_steps_reached);
1633 response.executed_operation_count = Some(executed_operation_count);
1634 response.executed_operations = executed_operations;
1635 response.set_operation_receipts(operation_receipts);
1636 response
1637}
1638
1639const fn restore_run_stopped_reason(
1641 report: &RestoreApplyJournalReport,
1642 max_steps_reached: bool,
1643 executed: bool,
1644) -> &'static str {
1645 if report.complete {
1646 return RESTORE_RUN_STOPPED_COMPLETE;
1647 }
1648 if report.failed_operations > 0 {
1649 return RESTORE_RUN_STOPPED_COMMAND_FAILED;
1650 }
1651 if report.pending_operations > 0 {
1652 return RESTORE_RUN_STOPPED_PENDING;
1653 }
1654 if !report.ready || report.blocked_operations > 0 {
1655 return RESTORE_RUN_STOPPED_BLOCKED;
1656 }
1657 if max_steps_reached {
1658 return RESTORE_RUN_STOPPED_MAX_STEPS;
1659 }
1660 if executed {
1661 return RESTORE_RUN_STOPPED_READY;
1662 }
1663 RESTORE_RUN_STOPPED_PREVIEW
1664}
1665
1666const fn restore_run_next_action(
1668 report: &RestoreApplyJournalReport,
1669 recovered_pending: bool,
1670) -> &'static str {
1671 if report.complete {
1672 return RESTORE_RUN_ACTION_DONE;
1673 }
1674 if report.failed_operations > 0 {
1675 return RESTORE_RUN_ACTION_INSPECT_FAILED;
1676 }
1677 if report.pending_operations > 0 {
1678 return RESTORE_RUN_ACTION_UNCLAIM_PENDING;
1679 }
1680 if !report.ready || report.blocked_operations > 0 {
1681 return RESTORE_RUN_ACTION_FIX_BLOCKED;
1682 }
1683 if recovered_pending {
1684 return RESTORE_RUN_ACTION_RERUN;
1685 }
1686 RESTORE_RUN_ACTION_RERUN
1687}
1688
1689fn enforce_restore_run_executable(
1691 journal: &RestoreApplyJournal,
1692 report: &RestoreApplyJournalReport,
1693) -> Result<(), RestoreCommandError> {
1694 if report.pending_operations > 0 {
1695 return Err(RestoreCommandError::RestoreApplyPending {
1696 backup_id: report.backup_id.clone(),
1697 pending_operations: report.pending_operations,
1698 next_transition_sequence: report
1699 .next_transition
1700 .as_ref()
1701 .map(|operation| operation.sequence),
1702 });
1703 }
1704
1705 if report.failed_operations > 0 {
1706 return Err(RestoreCommandError::RestoreApplyFailed {
1707 backup_id: report.backup_id.clone(),
1708 failed_operations: report.failed_operations,
1709 });
1710 }
1711
1712 if report.ready {
1713 return Ok(());
1714 }
1715
1716 Err(RestoreCommandError::RestoreApplyNotReady {
1717 backup_id: journal.backup_id.clone(),
1718 reasons: report.blocked_reasons.clone(),
1719 })
1720}
1721
1722fn enforce_restore_run_command_available(
1724 preview: &RestoreApplyCommandPreview,
1725) -> Result<(), RestoreCommandError> {
1726 if preview.command_available {
1727 return Ok(());
1728 }
1729
1730 Err(restore_command_unavailable_error(preview))
1731}
1732
1733fn restore_command_unavailable_error(preview: &RestoreApplyCommandPreview) -> RestoreCommandError {
1735 RestoreCommandError::RestoreApplyCommandUnavailable {
1736 backup_id: preview.backup_id.clone(),
1737 operation_available: preview.operation_available,
1738 complete: preview.complete,
1739 blocked_reasons: preview.blocked_reasons.clone(),
1740 }
1741}
1742
1743fn exit_status_label(status: std::process::ExitStatus) -> String {
1745 status
1746 .code()
1747 .map_or_else(|| "signal".to_string(), |code| code.to_string())
1748}
1749
1750fn parse_uploaded_snapshot_id(output: &str) -> Option<String> {
1752 output
1753 .lines()
1754 .filter_map(|line| line.split_once(':').map(|(_, value)| value.trim()))
1755 .find(|value| !value.is_empty())
1756 .map(str::to_string)
1757}
1758
1759fn enforce_restore_run_requirements(
1761 options: &RestoreRunOptions,
1762 run: &RestoreRunResponse,
1763) -> Result<(), RestoreCommandError> {
1764 if options.require_complete && !run.complete {
1765 return Err(RestoreCommandError::RestoreApplyIncomplete {
1766 backup_id: run.backup_id.clone(),
1767 completed_operations: run.completed_operations,
1768 operation_count: run.operation_count,
1769 });
1770 }
1771
1772 if options.require_no_attention && run.attention_required {
1773 return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1774 backup_id: run.backup_id.clone(),
1775 outcome: run.outcome.clone(),
1776 });
1777 }
1778
1779 if let Some(expected) = &options.require_run_mode
1780 && run.run_mode != expected
1781 {
1782 return Err(RestoreCommandError::RestoreRunModeMismatch {
1783 backup_id: run.backup_id.clone(),
1784 expected: expected.clone(),
1785 actual: run.run_mode.to_string(),
1786 });
1787 }
1788
1789 if let Some(expected) = &options.require_stopped_reason
1790 && run.stopped_reason != expected
1791 {
1792 return Err(RestoreCommandError::RestoreRunStoppedReasonMismatch {
1793 backup_id: run.backup_id.clone(),
1794 expected: expected.clone(),
1795 actual: run.stopped_reason.to_string(),
1796 });
1797 }
1798
1799 if let Some(expected) = &options.require_next_action
1800 && run.next_action != expected
1801 {
1802 return Err(RestoreCommandError::RestoreRunNextActionMismatch {
1803 backup_id: run.backup_id.clone(),
1804 expected: expected.clone(),
1805 actual: run.next_action.to_string(),
1806 });
1807 }
1808
1809 if let Some(expected) = options.require_executed_count {
1810 let actual = run.executed_operation_count.unwrap_or(0);
1811 if actual != expected {
1812 return Err(RestoreCommandError::RestoreRunExecutedCountMismatch {
1813 backup_id: run.backup_id.clone(),
1814 expected,
1815 actual,
1816 });
1817 }
1818 }
1819
1820 enforce_restore_run_receipt_requirements(options, run)?;
1821
1822 enforce_progress_requirements(
1823 &run.backup_id,
1824 &run.progress,
1825 options.require_remaining_count,
1826 options.require_attention_count,
1827 options.require_completion_basis_points,
1828 )?;
1829 enforce_pending_before_requirement(
1830 &run.backup_id,
1831 &run.pending_summary,
1832 options.require_no_pending_before.as_deref(),
1833 )?;
1834
1835 Ok(())
1836}
1837
1838fn enforce_restore_run_receipt_requirements(
1840 options: &RestoreRunOptions,
1841 run: &RestoreRunResponse,
1842) -> Result<(), RestoreCommandError> {
1843 if let Some(expected) = options.require_receipt_count {
1844 let actual = run.operation_receipt_count.unwrap_or(0);
1845 if actual != expected {
1846 return Err(RestoreCommandError::RestoreRunReceiptCountMismatch {
1847 backup_id: run.backup_id.clone(),
1848 expected,
1849 actual,
1850 });
1851 }
1852 }
1853
1854 enforce_restore_run_receipt_kind_requirement(
1855 &run.backup_id,
1856 RESTORE_RUN_RECEIPT_COMPLETED,
1857 options.require_completed_receipt_count,
1858 run.operation_receipt_summary.command_completed,
1859 )?;
1860 enforce_restore_run_receipt_kind_requirement(
1861 &run.backup_id,
1862 RESTORE_RUN_RECEIPT_FAILED,
1863 options.require_failed_receipt_count,
1864 run.operation_receipt_summary.command_failed,
1865 )?;
1866 enforce_restore_run_receipt_kind_requirement(
1867 &run.backup_id,
1868 RESTORE_RUN_RECEIPT_RECOVERED_PENDING,
1869 options.require_recovered_receipt_count,
1870 run.operation_receipt_summary.pending_recovered,
1871 )?;
1872 enforce_restore_run_receipt_updated_at_requirement(
1873 &run.backup_id,
1874 &run.operation_receipts,
1875 options.require_receipt_updated_at.as_deref(),
1876 )?;
1877 enforce_restore_run_state_updated_at_requirement(
1878 &run.backup_id,
1879 run.requested_state_updated_at.as_deref(),
1880 options.require_state_updated_at.as_deref(),
1881 )?;
1882
1883 Ok(())
1884}
1885
1886fn enforce_restore_run_state_updated_at_requirement(
1888 backup_id: &str,
1889 actual: Option<&str>,
1890 expected: Option<&str>,
1891) -> Result<(), RestoreCommandError> {
1892 if let Some(expected) = expected
1893 && actual != Some(expected)
1894 {
1895 return Err(RestoreCommandError::RestoreRunStateUpdatedAtMismatch {
1896 backup_id: backup_id.to_string(),
1897 expected: expected.to_string(),
1898 actual: actual.map(str::to_string),
1899 });
1900 }
1901
1902 Ok(())
1903}
1904
1905fn enforce_restore_run_receipt_updated_at_requirement(
1907 backup_id: &str,
1908 receipts: &[RestoreRunOperationReceipt],
1909 expected: Option<&str>,
1910) -> Result<(), RestoreCommandError> {
1911 let Some(expected) = expected else {
1912 return Ok(());
1913 };
1914
1915 let actual_receipts = receipts.len();
1916 let mismatched_receipts = receipts
1917 .iter()
1918 .filter(|receipt| receipt.updated_at.as_deref() != Some(expected))
1919 .count();
1920 if actual_receipts == 0 || mismatched_receipts > 0 {
1921 return Err(RestoreCommandError::RestoreRunReceiptUpdatedAtMismatch {
1922 backup_id: backup_id.to_string(),
1923 expected: expected.to_string(),
1924 actual_receipts,
1925 mismatched_receipts,
1926 });
1927 }
1928
1929 Ok(())
1930}
1931
1932fn enforce_restore_run_receipt_kind_requirement(
1934 backup_id: &str,
1935 receipt_kind: &'static str,
1936 expected: Option<usize>,
1937 actual: usize,
1938) -> Result<(), RestoreCommandError> {
1939 if let Some(expected) = expected
1940 && actual != expected
1941 {
1942 return Err(RestoreCommandError::RestoreRunReceiptKindCountMismatch {
1943 backup_id: backup_id.to_string(),
1944 receipt_kind,
1945 expected,
1946 actual,
1947 });
1948 }
1949
1950 Ok(())
1951}
1952
1953fn enforce_progress_requirements(
1955 backup_id: &str,
1956 progress: &RestoreApplyProgressSummary,
1957 require_remaining_count: Option<usize>,
1958 require_attention_count: Option<usize>,
1959 require_completion_basis_points: Option<usize>,
1960) -> Result<(), RestoreCommandError> {
1961 if let Some(expected) = require_remaining_count
1962 && progress.remaining_operations != expected
1963 {
1964 return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1965 backup_id: backup_id.to_string(),
1966 field: "remaining_operations",
1967 expected,
1968 actual: progress.remaining_operations,
1969 });
1970 }
1971
1972 if let Some(expected) = require_attention_count
1973 && progress.attention_operations != expected
1974 {
1975 return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1976 backup_id: backup_id.to_string(),
1977 field: "attention_operations",
1978 expected,
1979 actual: progress.attention_operations,
1980 });
1981 }
1982
1983 if let Some(expected) = require_completion_basis_points
1984 && progress.completion_basis_points != expected
1985 {
1986 return Err(RestoreCommandError::RestoreApplyProgressMismatch {
1987 backup_id: backup_id.to_string(),
1988 field: "completion_basis_points",
1989 expected,
1990 actual: progress.completion_basis_points,
1991 });
1992 }
1993
1994 Ok(())
1995}
1996
1997fn enforce_pending_before_requirement(
1999 backup_id: &str,
2000 pending: &RestoreApplyPendingSummary,
2001 require_no_pending_before: Option<&str>,
2002) -> Result<(), RestoreCommandError> {
2003 let Some(cutoff_updated_at) = require_no_pending_before else {
2004 return Ok(());
2005 };
2006
2007 if pending.pending_operations == 0 {
2008 return Ok(());
2009 }
2010
2011 if pending.pending_updated_at_known
2012 && pending
2013 .pending_updated_at
2014 .as_deref()
2015 .is_some_and(|updated_at| updated_at >= cutoff_updated_at)
2016 {
2017 return Ok(());
2018 }
2019
2020 Err(RestoreCommandError::RestoreApplyPendingStale {
2021 backup_id: backup_id.to_string(),
2022 cutoff_updated_at: cutoff_updated_at.to_string(),
2023 pending_sequence: pending.pending_sequence,
2024 pending_updated_at: pending.pending_updated_at.clone(),
2025 })
2026}
2027
2028fn enforce_apply_report_requirements(
2030 options: &RestoreApplyReportOptions,
2031 report: &RestoreApplyJournalReport,
2032) -> Result<(), RestoreCommandError> {
2033 if options.require_no_attention && report.attention_required {
2034 return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
2035 backup_id: report.backup_id.clone(),
2036 outcome: report.outcome.clone(),
2037 });
2038 }
2039
2040 enforce_progress_requirements(
2041 &report.backup_id,
2042 &report.progress,
2043 options.require_remaining_count,
2044 options.require_attention_count,
2045 options.require_completion_basis_points,
2046 )?;
2047 enforce_pending_before_requirement(
2048 &report.backup_id,
2049 &report.pending_summary,
2050 options.require_no_pending_before.as_deref(),
2051 )
2052}
2053
2054fn enforce_apply_status_requirements(
2056 options: &RestoreApplyStatusOptions,
2057 status: &RestoreApplyJournalStatus,
2058) -> Result<(), RestoreCommandError> {
2059 if options.require_ready && !status.ready {
2060 return Err(RestoreCommandError::RestoreApplyNotReady {
2061 backup_id: status.backup_id.clone(),
2062 reasons: status.blocked_reasons.clone(),
2063 });
2064 }
2065
2066 if options.require_no_pending && status.pending_operations > 0 {
2067 return Err(RestoreCommandError::RestoreApplyPending {
2068 backup_id: status.backup_id.clone(),
2069 pending_operations: status.pending_operations,
2070 next_transition_sequence: status.next_transition_sequence,
2071 });
2072 }
2073
2074 if options.require_no_failed && status.failed_operations > 0 {
2075 return Err(RestoreCommandError::RestoreApplyFailed {
2076 backup_id: status.backup_id.clone(),
2077 failed_operations: status.failed_operations,
2078 });
2079 }
2080
2081 if options.require_complete && !status.complete {
2082 return Err(RestoreCommandError::RestoreApplyIncomplete {
2083 backup_id: status.backup_id.clone(),
2084 completed_operations: status.completed_operations,
2085 operation_count: status.operation_count,
2086 });
2087 }
2088
2089 enforce_progress_requirements(
2090 &status.backup_id,
2091 &status.progress,
2092 options.require_remaining_count,
2093 options.require_attention_count,
2094 options.require_completion_basis_points,
2095 )?;
2096 enforce_pending_before_requirement(
2097 &status.backup_id,
2098 &status.pending_summary,
2099 options.require_no_pending_before.as_deref(),
2100 )?;
2101
2102 Ok(())
2103}
2104
2105fn enforce_apply_claim_sequence(
2107 expected: usize,
2108 journal: &RestoreApplyJournal,
2109) -> Result<(), RestoreCommandError> {
2110 let actual = journal
2111 .next_transition_operation()
2112 .map(|operation| operation.sequence);
2113
2114 if actual == Some(expected) {
2115 return Ok(());
2116 }
2117
2118 Err(RestoreCommandError::RestoreRunClaimSequenceMismatch { expected, actual })
2119}
2120
2121fn enforce_restore_plan_requirements(
2123 options: &RestorePlanOptions,
2124 plan: &RestorePlan,
2125) -> Result<(), RestoreCommandError> {
2126 if options.require_design_v1 {
2127 let manifest = read_manifest_source(options)?;
2128 if !manifest.design_conformance_report().design_v1_ready {
2129 return Err(RestoreCommandError::DesignConformanceNotReady {
2130 backup_id: plan.backup_id.clone(),
2131 });
2132 }
2133 }
2134
2135 if !options.require_restore_ready || plan.readiness_summary.ready {
2136 return Ok(());
2137 }
2138
2139 Err(RestoreCommandError::RestoreNotReady {
2140 backup_id: plan.backup_id.clone(),
2141 reasons: plan.readiness_summary.reasons.clone(),
2142 })
2143}
2144
2145fn verify_backup_layout_if_required(
2147 options: &RestorePlanOptions,
2148) -> Result<(), RestoreCommandError> {
2149 if !options.require_verified {
2150 return Ok(());
2151 }
2152
2153 let Some(dir) = &options.backup_dir else {
2154 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
2155 };
2156
2157 BackupLayout::new(dir.clone()).verify_integrity()?;
2158 Ok(())
2159}
2160
2161fn read_manifest_source(
2163 options: &RestorePlanOptions,
2164) -> Result<FleetBackupManifest, RestoreCommandError> {
2165 if let Some(path) = &options.manifest {
2166 return read_manifest(path);
2167 }
2168
2169 let Some(dir) = &options.backup_dir else {
2170 return Err(RestoreCommandError::MissingOption(
2171 "--manifest or --backup-dir",
2172 ));
2173 };
2174
2175 BackupLayout::new(dir.clone())
2176 .read_manifest()
2177 .map_err(RestoreCommandError::from)
2178}
2179
2180fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
2182 let data = fs::read_to_string(path)?;
2183 serde_json::from_str(&data).map_err(RestoreCommandError::from)
2184}
2185
2186fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
2188 let data = fs::read_to_string(path)?;
2189 serde_json::from_str(&data).map_err(RestoreCommandError::from)
2190}
2191
2192fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
2194 let data = fs::read_to_string(path)?;
2195 serde_json::from_str(&data).map_err(RestoreCommandError::from)
2196}
2197
2198fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
2200 let data = fs::read_to_string(path)?;
2201 serde_json::from_str(&data).map_err(RestoreCommandError::from)
2202}
2203
2204fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
2206 let data = fs::read_to_string(path)?;
2207 let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
2208 journal.validate()?;
2209 Ok(journal)
2210}
2211
2212fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
2214 value
2215 .parse::<usize>()
2216 .map_err(|_| RestoreCommandError::InvalidSequence)
2217}
2218
2219fn parse_positive_integer(
2221 option: &'static str,
2222 value: String,
2223) -> Result<usize, RestoreCommandError> {
2224 let parsed = parse_sequence(value)?;
2225 if parsed == 0 {
2226 return Err(RestoreCommandError::InvalidPositiveInteger { option });
2227 }
2228
2229 Ok(parsed)
2230}
2231
2232fn state_updated_at(updated_at: Option<&String>) -> String {
2234 updated_at.cloned().unwrap_or_else(timestamp_placeholder)
2235}
2236
2237fn timestamp_placeholder() -> String {
2239 "unknown".to_string()
2240}
2241
2242fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
2244 output::write_pretty_json(options.out.as_ref(), plan)
2245}
2246
2247fn write_status(
2249 options: &RestoreStatusOptions,
2250 status: &RestoreStatus,
2251) -> Result<(), RestoreCommandError> {
2252 output::write_pretty_json(options.out.as_ref(), status)
2253}
2254
2255fn write_apply_dry_run(
2257 options: &RestoreApplyOptions,
2258 dry_run: &RestoreApplyDryRun,
2259) -> Result<(), RestoreCommandError> {
2260 output::write_pretty_json(options.out.as_ref(), dry_run)
2261}
2262
2263fn write_apply_journal_if_requested(
2265 options: &RestoreApplyOptions,
2266 dry_run: &RestoreApplyDryRun,
2267) -> Result<(), RestoreCommandError> {
2268 let Some(path) = &options.journal_out else {
2269 return Ok(());
2270 };
2271
2272 let journal = RestoreApplyJournal::from_dry_run(dry_run);
2273 let data = serde_json::to_vec_pretty(&journal)?;
2274 fs::write(path, data)?;
2275 Ok(())
2276}
2277
2278fn write_apply_status(
2280 options: &RestoreApplyStatusOptions,
2281 status: &RestoreApplyJournalStatus,
2282) -> Result<(), RestoreCommandError> {
2283 output::write_pretty_json(options.out.as_ref(), status)
2284}
2285
2286fn write_apply_report(
2288 options: &RestoreApplyReportOptions,
2289 report: &RestoreApplyJournalReport,
2290) -> Result<(), RestoreCommandError> {
2291 output::write_pretty_json(options.out.as_ref(), report)
2292}
2293
2294fn write_restore_run(
2296 options: &RestoreRunOptions,
2297 run: &RestoreRunResponse,
2298) -> Result<(), RestoreCommandError> {
2299 output::write_pretty_json(options.out.as_ref(), run)
2300}
2301
2302fn write_apply_journal_file(
2304 path: &PathBuf,
2305 journal: &RestoreApplyJournal,
2306) -> Result<(), RestoreCommandError> {
2307 let data = serde_json::to_vec_pretty(journal)?;
2308 fs::write(path, data)?;
2309 Ok(())
2310}
2311
2312struct RestoreJournalLock {
2317 path: PathBuf,
2318}
2319
2320impl RestoreJournalLock {
2321 fn acquire(journal_path: &Path) -> Result<Self, RestoreCommandError> {
2323 let path = journal_lock_path(journal_path);
2324 match fs::OpenOptions::new()
2325 .write(true)
2326 .create_new(true)
2327 .open(&path)
2328 {
2329 Ok(mut file) => {
2330 writeln!(file, "pid={}", std::process::id())?;
2331 Ok(Self { path })
2332 }
2333 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
2334 Err(RestoreCommandError::RestoreApplyJournalLocked {
2335 lock_path: path.to_string_lossy().to_string(),
2336 })
2337 }
2338 Err(error) => Err(error.into()),
2339 }
2340 }
2341}
2342
2343impl Drop for RestoreJournalLock {
2344 fn drop(&mut self) {
2346 let _ = fs::remove_file(&self.path);
2347 }
2348}
2349
2350fn journal_lock_path(path: &Path) -> PathBuf {
2352 let mut lock_path = path.as_os_str().to_os_string();
2353 lock_path.push(".lock");
2354 PathBuf::from(lock_path)
2355}
2356
2357const fn usage() -> &'static str {
2359 "usage: canic restore <command> [<args>]\n\ncommands:\n plan Build a no-mutation restore plan.\n status Build initial restore status from a plan.\n apply Render restore operations and optionally write an apply journal.\n apply-status Summarize apply journal state for scripts.\n apply-report Write an operator-focused apply journal report.\n run Preview, execute, or recover the native restore runner."
2360}
2361
2362#[cfg(test)]
2363mod tests;