1use crate::{
2 execution::{
3 BackupExecutionJournalOperation, BackupExecutionOperationState,
4 BackupExecutionResumeSummary,
5 },
6 journal::ArtifactState,
7 persistence::CommandLifetimeHandle,
8 plan::{BackupExecutionPreflightReceipts, BackupPlan},
9};
10use serde::Serialize;
11use std::{error::Error as StdError, fmt, path::Path, path::PathBuf};
12use thiserror::Error as ThisError;
13
14use crate::persistence::JournalLockError;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct BackupRunnerConfig {
22 pub out: PathBuf,
23 pub max_steps: Option<usize>,
24 pub updated_at: Option<String>,
25 pub tool_name: String,
26 pub tool_version: String,
27}
28
29pub trait BackupRunnerExecutor {
34 fn preflight_receipts(
36 &mut self,
37 plan: &BackupPlan,
38 preflight_id: &str,
39 validated_at: &str,
40 expires_at: &str,
41 ) -> Result<BackupExecutionPreflightReceipts, BackupRunnerCommandError>;
42
43 fn canister_status(
45 &mut self,
46 canister_id: &str,
47 ) -> Result<BackupRunnerCanisterStatus, BackupRunnerCommandError>;
48
49 fn snapshot_inventory(
51 &mut self,
52 canister_id: &str,
53 ) -> Result<Vec<BackupRunnerSnapshot>, BackupRunnerCommandError>;
54
55 fn stop_canister(
57 &mut self,
58 canister_id: &str,
59 command_lifetime: CommandLifetimeHandle,
60 ) -> Result<(), BackupRunnerCommandError>;
61
62 fn start_canister(
64 &mut self,
65 canister_id: &str,
66 command_lifetime: CommandLifetimeHandle,
67 ) -> Result<(), BackupRunnerCommandError>;
68
69 fn create_snapshot(
71 &mut self,
72 canister_id: &str,
73 command_lifetime: CommandLifetimeHandle,
74 ) -> Result<BackupRunnerSnapshot, BackupRunnerCommandError>;
75
76 fn download_snapshot(
78 &mut self,
79 canister_id: &str,
80 snapshot_id: &str,
81 artifact_path: &Path,
82 command_lifetime: CommandLifetimeHandle,
83 ) -> Result<(), BackupRunnerCommandError>;
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93pub enum BackupRunnerCanisterStatus {
94 Running,
95 Stopped,
96 Stopping,
97}
98
99impl BackupRunnerCanisterStatus {
100 #[must_use]
101 pub const fn label(self) -> &'static str {
102 match self {
103 Self::Running => "Running",
104 Self::Stopped => "Stopped",
105 Self::Stopping => "Stopping",
106 }
107 }
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct BackupRunnerSnapshot {
116 pub snapshot_id: String,
117 pub taken_at_timestamp: Option<u64>,
118 pub total_size_bytes: Option<u64>,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct BackupRunnerCommandError {
127 pub status: String,
128 pub message: String,
129}
130
131impl BackupRunnerCommandError {
132 #[must_use]
133 pub fn failed(status: impl Into<String>, message: impl Into<String>) -> Self {
134 Self {
135 status: status.into(),
136 message: message.into(),
137 }
138 }
139}
140
141impl fmt::Display for BackupRunnerCommandError {
142 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(formatter, "{}: {}", self.status, self.message)
144 }
145}
146
147impl StdError for BackupRunnerCommandError {}
148
149#[derive(Debug, ThisError)]
154pub enum BackupRunnerError {
155 #[error("backup execution journal is locked: {lock_path}")]
156 JournalLocked { lock_path: String },
157
158 #[error("backup execution journal lock path is unsafe: {lock_path} ({kind})")]
159 JournalLockUnsafeEntry { lock_path: String, kind: String },
160
161 #[error(
162 "backup operation {sequence} {operation_id} has an external command still running: {lock_path}"
163 )]
164 CommandInFlight {
165 sequence: usize,
166 operation_id: String,
167 lock_path: String,
168 },
169
170 #[error(
171 "backup operation {sequence} {operation_id} has a quiescent command with an unknown external outcome: {lock_path}"
172 )]
173 CommandOutcomeUnknown {
174 sequence: usize,
175 operation_id: String,
176 lock_path: String,
177 },
178
179 #[error(
180 "backup operation {sequence} {operation_id} command lock path is unsafe: {lock_path} ({kind})"
181 )]
182 CommandLockUnsafeEntry {
183 sequence: usize,
184 operation_id: String,
185 lock_path: String,
186 kind: String,
187 },
188
189 #[error(
190 "backup operation {sequence} {operation_id} observed unsettled canister status {status}"
191 )]
192 CanisterStatusUnsettled {
193 sequence: usize,
194 operation_id: String,
195 status: &'static str,
196 },
197
198 #[error("backup operation {sequence} canister status observation failed: {status}: {message}")]
199 CanisterStatusFailed {
200 sequence: usize,
201 status: String,
202 message: String,
203 },
204
205 #[error(
206 "backup operation {sequence} snapshot inventory observation failed: {status}: {message}"
207 )]
208 SnapshotInventoryFailed {
209 sequence: usize,
210 status: String,
211 message: String,
212 },
213
214 #[error(
215 "backup operation {sequence} {operation_id} snapshot inventory lost baseline identities: {snapshot_ids:?}"
216 )]
217 SnapshotInventoryLostBaseline {
218 sequence: usize,
219 operation_id: String,
220 snapshot_ids: Vec<String>,
221 },
222
223 #[error(
224 "backup operation {sequence} {operation_id} snapshot identity is ambiguous: {snapshot_ids:?}"
225 )]
226 SnapshotIdentityAmbiguous {
227 sequence: usize,
228 operation_id: String,
229 snapshot_ids: Vec<String>,
230 },
231
232 #[error(
233 "backup operation {sequence} {operation_id} snapshot inventory contains a blank identity"
234 )]
235 InvalidSnapshotIdentity {
236 sequence: usize,
237 operation_id: String,
238 },
239
240 #[error(
241 "backup operation {sequence} {operation_id} snapshot inventory contains duplicate identity {snapshot_id}"
242 )]
243 DuplicateSnapshotIdentity {
244 sequence: usize,
245 operation_id: String,
246 snapshot_id: String,
247 },
248
249 #[error("backup operation {sequence} {operation_id} is missing its command lifetime handle")]
250 MissingCommandLifetime {
251 sequence: usize,
252 operation_id: String,
253 },
254
255 #[error(
256 "download journal backup id does not match the backup plan: expected={expected}, actual={actual}"
257 )]
258 DownloadJournalBackupIdMismatch { expected: String, actual: String },
259
260 #[error(
261 "download journal topology receipt {field} does not match the backup plan: expected={expected}, actual={actual}"
262 )]
263 DownloadJournalTopologyMismatch {
264 field: &'static str,
265 expected: String,
266 actual: String,
267 },
268
269 #[error("backup operation {sequence} has no target canister")]
270 MissingOperationTarget { sequence: usize },
271
272 #[error("backup operation {sequence} has no snapshot id for target {target_canister_id}")]
273 MissingSnapshotId {
274 sequence: usize,
275 target_canister_id: String,
276 },
277
278 #[error(
279 "backup operation {sequence} has no artifact journal entry for target {target_canister_id}"
280 )]
281 MissingArtifactEntry {
282 sequence: usize,
283 target_canister_id: String,
284 },
285
286 #[error(
287 "backup operation {sequence} has multiple artifact snapshots for target {target_canister_id}: {snapshot_ids:?}"
288 )]
289 AmbiguousArtifactSnapshot {
290 sequence: usize,
291 target_canister_id: String,
292 snapshot_ids: Vec<String>,
293 },
294
295 #[error(
296 "backup operation {sequence} artifact snapshot for target {target_canister_id} does not match the execution receipt: expected={expected_snapshot_id}, actual={actual_snapshot_id}"
297 )]
298 ArtifactDownloadSnapshotMismatch {
299 sequence: usize,
300 target_canister_id: String,
301 expected_snapshot_id: String,
302 actual_snapshot_id: String,
303 },
304
305 #[error(
306 "backup operation {sequence} artifact path for target {target_canister_id} does not match the runner path: journal={journal_path}, expected={expected_path}"
307 )]
308 ArtifactPathMismatch {
309 sequence: usize,
310 target_canister_id: String,
311 journal_path: String,
312 expected_path: String,
313 },
314
315 #[error(
316 "backup operation {sequence} cannot reconcile pending download for target {target_canister_id} while artifact state is {state:?}"
317 )]
318 ArtifactDownloadStateConflict {
319 sequence: usize,
320 target_canister_id: String,
321 state: ArtifactState,
322 },
323
324 #[error(
325 "backup operation {sequence} cannot reconcile pending artifact verification for target {target_canister_id} while artifact state is {state:?}"
326 )]
327 ArtifactVerificationStateConflict {
328 sequence: usize,
329 target_canister_id: String,
330 state: ArtifactState,
331 },
332
333 #[error(
334 "backup manifest exists before finalization operation {sequence} reached a recoverable state: {state:?}"
335 )]
336 PrematureManifest {
337 sequence: usize,
338 state: BackupExecutionOperationState,
339 },
340
341 #[error(
342 "backup operation {sequence} artifact temp path for target {target_canister_id} does not match expected runner path: journal={journal_path}, expected={expected_path}"
343 )]
344 ArtifactTempPathMismatch {
345 sequence: usize,
346 target_canister_id: String,
347 journal_path: String,
348 expected_path: String,
349 },
350
351 #[error(
352 "backup operation {sequence} artifact temp path for target {target_canister_id} is missing: {path}"
353 )]
354 ArtifactTempPathMissing {
355 sequence: usize,
356 target_canister_id: String,
357 path: String,
358 },
359
360 #[error(
361 "backup operation {sequence} artifact temp path for target {target_canister_id} is unsafe: {path} ({kind})"
362 )]
363 ArtifactTempPathUnsafeEntry {
364 sequence: usize,
365 target_canister_id: String,
366 path: String,
367 kind: String,
368 },
369
370 #[error("backup operation {sequence} failed: {status}: {message}")]
371 CommandFailed {
372 sequence: usize,
373 status: String,
374 message: String,
375 },
376
377 #[error("backup failure containment failed after {primary}: {containment}")]
378 FailureContainmentFailed {
379 #[source]
380 primary: Box<Self>,
381 containment: Box<Self>,
382 },
383
384 #[error("backup preflight failed: {status}: {message}")]
385 PreflightFailed { status: String, message: String },
386
387 #[error("backup execution has no operation ready to run")]
388 NoReadyOperation,
389
390 #[error("backup execution is blocked: {reasons:?}")]
391 Blocked { reasons: Vec<String> },
392
393 #[error(transparent)]
394 Io(#[from] std::io::Error),
395
396 #[error(transparent)]
397 Json(#[from] serde_json::Error),
398
399 #[error(transparent)]
400 Persistence(#[from] crate::persistence::PersistenceError),
401
402 #[error(transparent)]
403 BackupPlan(#[from] crate::plan::BackupPlanError),
404
405 #[error(transparent)]
406 ExecutionJournal(#[from] crate::execution::BackupExecutionJournalError),
407
408 #[error(transparent)]
409 Journal(#[from] crate::journal::JournalValidationError),
410
411 #[error(transparent)]
412 Checksum(#[from] crate::artifacts::ArtifactChecksumError),
413
414 #[error(transparent)]
415 Manifest(#[from] crate::manifest::ManifestValidationError),
416}
417
418impl From<JournalLockError> for BackupRunnerError {
419 fn from(error: JournalLockError) -> Self {
420 match error {
421 JournalLockError::Locked { lock_path } => Self::JournalLocked { lock_path },
422 JournalLockError::UnsafeEntry { lock_path, kind } => {
423 Self::JournalLockUnsafeEntry { lock_path, kind }
424 }
425 JournalLockError::Io(error) => Self::Io(error),
426 }
427 }
428}
429
430#[derive(Clone, Debug, Serialize)]
435pub struct BackupRunResponse {
436 pub run_id: String,
437 pub plan_id: String,
438 pub backup_id: String,
439 pub complete: bool,
440 pub max_steps_reached: bool,
441 pub executed_operation_count: usize,
442 pub executed_operations: Vec<BackupRunExecutedOperation>,
443 pub execution: BackupExecutionResumeSummary,
444}
445
446#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
451pub struct BackupRunExecutedOperation {
452 pub sequence: usize,
453 pub operation_id: String,
454 pub kind: String,
455 pub target_canister_id: Option<String>,
456 pub outcome: String,
457}
458
459impl BackupRunExecutedOperation {
460 pub(super) fn completed(operation: &BackupExecutionJournalOperation) -> Self {
461 Self::from_operation(operation, "completed")
462 }
463
464 pub(super) fn failed(operation: &BackupExecutionJournalOperation) -> Self {
465 Self::from_operation(operation, "failed")
466 }
467
468 fn from_operation(operation: &BackupExecutionJournalOperation, outcome: &str) -> Self {
469 Self {
470 sequence: operation.sequence,
471 operation_id: operation.operation_id.clone(),
472 kind: format!("{:?}", operation.kind),
473 target_canister_id: operation.target_canister_id.clone(),
474 outcome: outcome.to_string(),
475 }
476 }
477}