pub mod converge;
pub mod fingerprint;
pub mod gates;
pub mod hold;
pub mod host;
pub mod journal;
pub mod preflight;
pub mod record;
pub mod rollback;
pub mod run_store;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::{Value, json};
use crate::actions::backup::backup_download;
use crate::actions::deploy::{
Declared, DeployPlanResult, PlanOptions, VerifyContext, VerifyOptions, deploy_plan,
deploy_verify_judged, gather_read_back,
};
use crate::actions::workspace::rfc3339_now_utc;
use crate::client::backup::BackupType;
use crate::client::deploy_api::scan_and_wait;
use crate::client::scan::{LockVerdict, ScanKind, ScanOutcome};
use crate::client::{DeployApi, GatewayApi};
use crate::deploy::layout::RelPath;
use crate::deploy::ledger::{Ledger, LedgerRoot, journal_location, ledger_location};
use crate::deploy::pair::{BackupNode, BackupPolicy, BackupReport, effective_files, wall_clock_ms};
use crate::deploy::plan::PlanStatus;
use crate::deploy::target::NodeTarget;
use crate::deploy::transport::batch::BUDGET_MAX_S;
use crate::deploy::transport::{Scope, TreeReader};
use crate::deploy::tree::sha256_hex;
use crate::deploy::verify::{Expected, Finding, ReadBackBaseline, Written, compare_for};
use crate::error::{CoreError, PreflightFailure, RollbackFailure};
use crate::poll::PollConfig;
use self::fingerprint::{LEDGER_KEY, fingerprint};
use self::gates::GateFlags;
use self::hold::{Clock, HeldLock, HoldModel, LOCK_MARGIN_S};
use self::host::{ApplyHost, BatchSpec, DeleteOp, WriteOp};
use self::journal::{Journal, JournalPhase, journal_on_box};
use self::preflight::{DesignersPolicy, PreflightInput, run_preflights};
use self::record::{RecordState, RunRecord, TouchedEntry};
use self::run_store::{EventLog, RunDir, RunId};
pub use self::rollback::{
InterruptedRun, RollbackChecked, RollbackContext, RollbackKind, RollbackOptions,
RollbackOutcome, check_rollback, restore_set, run_rollback,
};
const LOCKED_SAMPLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub const ROLLED_BACK_SLUG: &str = "deploy_rolled_back";
pub const BACKUP_UNSYNCED_SLUG: &str = "deploy_backup_unsynced";
const GWBK_CHECK: &str = "gwbk";
const RESTART_UNAVAILABLE: &str = "restart_pending_unavailable";
const EVENTS_UNWRITABLE: &str = "events_log_unwritable";
const READ_BACK_BASELINE_FILE: &str = "read_back_baseline.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeKind {
Applied,
Noop,
RolledBack,
AppliedBackupUnsynced,
}
impl OutcomeKind {
pub fn as_str(self) -> &'static str {
match self {
OutcomeKind::Applied => "applied",
OutcomeKind::Noop => "noop",
OutcomeKind::RolledBack => "rolled_back",
OutcomeKind::AppliedBackupUnsynced => "applied_backup_unsynced",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RollbackCause {
LockExpired,
WriteFailed,
ScanIncomplete,
VerifyFailed,
}
impl RollbackCause {
pub fn as_str(self) -> &'static str {
match self {
RollbackCause::LockExpired => "lock_expired",
RollbackCause::WriteFailed => "write_failed",
RollbackCause::ScanIncomplete => "scan_incomplete",
RollbackCause::VerifyFailed => "verify_failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteSet {
pub writes: Vec<WriteOp>,
pub deletes: Vec<DeleteOp>,
}
pub fn write_set(plan: &DeployPlanResult, declared: &Declared) -> Result<WriteSet, CoreError> {
let defect = |why: String| CoreError::Internal(format!("write set: {why}"));
let mut writes = Vec::new();
let mut live: BTreeMap<&str, (bool, Option<&str>)> = BTreeMap::new();
for entry in &plan.entries {
let in_mode = entry.live_in.iter().any(|c| c == "mode");
for file in &entry.files {
live.insert(file.path.as_str(), (in_mode, file.live_sha256.as_deref()));
}
if !matches!(entry.status, PlanStatus::Added | PlanStatus::Changed) {
continue;
}
for file in &entry.files {
let Some(desired) = &file.desired_sha256 else {
continue;
};
let path = RelPath::new(&file.path)
.map_err(|why| defect(format!("the plan names an invalid path ({why})")))?;
let bytes =
declared.outcome.tree.files.get(&path).ok_or_else(|| {
defect(format!("{path} is in the plan but not in the render"))
})?;
if sha256_hex(bytes) != *desired {
return Err(defect(format!(
"{path} was rendered differently from the plan"
)));
}
writes.push(WriteOp {
path,
sha256: desired.clone(),
pre: if in_mode {
file.live_sha256.clone()
} else {
None
},
});
}
}
let mut deletes = Vec::new();
for path in &plan.delete_set.files {
let Some((true, Some(pre))) = live.get(path.as_str()) else {
return Err(defect(format!(
"{path} is in the delete set without a pre-image in the plan"
)));
};
deletes.push(DeleteOp {
path: path.clone(),
pre: (*pre).to_string(),
});
}
writes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
deletes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
Ok(WriteSet { writes, deletes })
}
fn new_ledger(
plan: &DeployPlanResult,
old: Option<&Ledger>,
set: &WriteSet,
run_id: &RunId,
) -> Ledger {
let mut files: BTreeSet<String> = old
.map(|ledger| ledger.files.iter().cloned().collect())
.unwrap_or_default();
for op in &set.deletes {
files.remove(op.path.as_str());
}
for op in &set.writes {
files.insert(op.path.as_str().to_string());
}
Ledger {
schema_version: 1,
gateway: plan.gateway.clone(),
mode: plan.mode.clone(),
render_sha256: plan.render_sha256.clone(),
run_id: run_id.as_str().to_string(),
files: files.into_iter().collect(),
}
}
fn commit_budget_s(hold: &HoldModel) -> u32 {
let seconds = hold.hold_s.saturating_sub(LOCK_MARGIN_S as u64);
u32::try_from(seconds.clamp(1, u64::from(BUDGET_MAX_S))).unwrap_or(BUDGET_MAX_S)
}
fn batch_spec(
plan: &DeployPlanResult,
run_id: &RunId,
set: &WriteSet,
ledger_new: Vec<u8>,
ledger_pre: Option<String>,
commit_budget_s: u32,
) -> BatchSpec {
let mut touched: Vec<TouchedEntry> = set
.writes
.iter()
.map(|w| TouchedEntry {
path: w.path.as_str().to_string(),
pre_sha256: w.pre.clone(),
post_sha256: Some(w.sha256.clone()),
})
.chain(set.deletes.iter().map(|d| TouchedEntry {
path: d.path.as_str().to_string(),
pre_sha256: Some(d.pre.clone()),
post_sha256: None,
}))
.collect();
touched.push(TouchedEntry {
path: LEDGER_KEY.to_string(),
pre_sha256: ledger_pre.clone(),
post_sha256: Some(sha256_hex(&ledger_new)),
});
touched.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
let pre_fingerprint = fingerprint(
&touched
.iter()
.map(|e| (e.path.clone(), e.pre_sha256.clone()))
.collect(),
);
let run = run_id.as_str().to_string();
BatchSpec {
run_id: run_id.clone(),
gateway: plan.gateway.clone(),
mode: plan.mode.clone(),
writes: set.writes.clone(),
deletes: set.deletes.clone(),
ledger_new: Some(ledger_new),
ledger_pre,
commit_budget_s: Some(commit_budget_s),
journal: Journal {
schema_version: 1,
run_id: run.clone(),
gateway: plan.gateway.clone(),
mode: plan.mode.clone(),
phase: JournalPhase::Snapshotting,
pre_fingerprint: pre_fingerprint.clone(),
touched: touched.iter().map(|e| e.path.clone()).collect(),
},
record: RunRecord {
schema_version: 1,
run_id: run,
gateway: plan.gateway.clone(),
mode: plan.mode.clone(),
render_sha256: plan.render_sha256.clone(),
plan_sha256: plan.plan_sha256.clone(),
state: RecordState::Applying,
rolled_back_by: None,
touched,
pre_fingerprint,
post_fingerprint: None,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplyEvent {
RunStarted {
run_id: String,
writes: usize,
deletes: usize,
},
Staged {
files: usize,
},
LockAcquired {
hold_s: u64,
},
Committed {
moved: usize,
deleted: usize,
},
LockVerdict {
held: bool,
},
ScanDone,
VerifyDone {
ok: bool,
},
Finalized,
CommitInterrupted {
code: String,
},
VerifyChecks {
checks: Vec<(String, String, Vec<Finding>)>,
},
RestoreStarted {
cause: Option<RollbackCause>,
},
LiveChangesDiscarded {
count: usize,
paths: Vec<String>,
},
Restored,
Cleared,
InterruptedRun {
phase: &'static str,
action: &'static str,
hashes: BTreeMap<String, Option<String>>,
},
PreflightChecks {
checks: Vec<(String, String)>,
},
DesignersPoll {
open: usize,
},
DesignersProceed {
sessions: Vec<preflight::DesignerSeen>,
},
GwbkDownloaded {
bytes: u64,
},
GwbkSkipped,
BackupSampled {
last_sync: Option<i64>,
},
BackupWaitStarted {
budget_s: u64,
},
BackupPoll {
peer_connected: bool,
sync_pending: bool,
advanced: bool,
},
BackupResync {
ok: bool,
code: Option<String>,
},
BackupVerdict {
converged: bool,
reason: Option<&'static str>,
},
Failed {
code: String,
},
}
impl ApplyEvent {
pub fn name(&self) -> &'static str {
match self {
ApplyEvent::RunStarted { .. } => "run_started",
ApplyEvent::Staged { .. } => "staged",
ApplyEvent::LockAcquired { .. } => "lock_acquired",
ApplyEvent::Committed { .. } => "committed",
ApplyEvent::LockVerdict { .. } => "lock_verdict",
ApplyEvent::ScanDone => "scan_done",
ApplyEvent::VerifyDone { .. } => "verify_done",
ApplyEvent::VerifyChecks { .. } => "verify_checks",
ApplyEvent::Finalized => "finalized",
ApplyEvent::CommitInterrupted { .. } => "commit_interrupted",
ApplyEvent::RestoreStarted { .. } => "restore_started",
ApplyEvent::Restored => "restored",
ApplyEvent::Cleared => "cleared",
ApplyEvent::InterruptedRun { .. } => "interrupted_run",
ApplyEvent::LiveChangesDiscarded { .. } => "live_changes_discarded",
ApplyEvent::PreflightChecks { .. } => "preflight_checks",
ApplyEvent::DesignersPoll { .. } => "designers_poll",
ApplyEvent::DesignersProceed { .. } => "designers_proceed",
ApplyEvent::GwbkDownloaded { .. } => "gwbk_downloaded",
ApplyEvent::GwbkSkipped => "gwbk_skipped",
ApplyEvent::BackupSampled { .. } => "backup_sampled",
ApplyEvent::BackupWaitStarted { .. } => "backup_wait_started",
ApplyEvent::BackupPoll { .. } => "backup_poll",
ApplyEvent::BackupResync { .. } => "backup_resync",
ApplyEvent::BackupVerdict { .. } => "backup_verdict",
ApplyEvent::Failed { .. } => "failed",
}
}
pub fn fields(&self) -> Value {
match self {
ApplyEvent::RunStarted {
run_id,
writes,
deletes,
} => json!({"run_id": run_id, "writes": writes, "deletes": deletes}),
ApplyEvent::Staged { files } => json!({"files": files}),
ApplyEvent::LockAcquired { hold_s } => json!({"hold_s": hold_s}),
ApplyEvent::Committed { moved, deleted } => {
json!({"moved": moved, "deleted": deleted})
}
ApplyEvent::LockVerdict { held } => json!({"held": held}),
ApplyEvent::VerifyDone { ok } => json!({"ok": ok}),
ApplyEvent::VerifyChecks { checks } => json!({
"checks": checks
.iter()
.map(|(id, status, findings)| {
json!({"id": id, "status": status, "findings": findings})
})
.collect::<Vec<_>>(),
}),
ApplyEvent::PreflightChecks { checks } => json!({
"checks": checks
.iter()
.map(|(id, status)| json!({"id": id, "status": status}))
.collect::<Vec<_>>(),
}),
ApplyEvent::DesignersPoll { open } => json!({"open": open}),
ApplyEvent::DesignersProceed { sessions } => json!({
"sessions": sessions
.iter()
.map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
.collect::<Vec<_>>(),
}),
ApplyEvent::RestoreStarted { cause } => {
json!({"cause": cause.map(RollbackCause::as_str)})
}
ApplyEvent::LiveChangesDiscarded { count, paths } => {
json!({"count": count, "paths": paths})
}
ApplyEvent::InterruptedRun {
phase,
action,
hashes,
} => json!({
"phase": phase,
"action": action,
"touched": hashes.len(),
"hashes": hashes,
}),
ApplyEvent::GwbkDownloaded { bytes } => json!({"bytes": bytes}),
ApplyEvent::BackupSampled { last_sync } => json!({"last_sync": last_sync}),
ApplyEvent::BackupWaitStarted { budget_s } => json!({"budget_s": budget_s}),
ApplyEvent::BackupPoll {
peer_connected,
sync_pending,
advanced,
} => json!({
"peer_connected": peer_connected,
"sync_pending": sync_pending,
"advanced": advanced,
}),
ApplyEvent::BackupResync { ok, code } => json!({"ok": ok, "code": code}),
ApplyEvent::BackupVerdict { converged, reason } => {
json!({"converged": converged, "reason": reason})
}
ApplyEvent::CommitInterrupted { code } | ApplyEvent::Failed { code } => {
json!({"code": code})
}
ApplyEvent::ScanDone
| ApplyEvent::Finalized
| ApplyEvent::Restored
| ApplyEvent::Cleared
| ApplyEvent::GwbkSkipped => Value::Null,
}
}
}
pub trait ApplyObserver: Send + Sync {
fn event(&self, e: &ApplyEvent);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoObserver;
impl ApplyObserver for NoObserver {
fn event(&self, _e: &ApplyEvent) {}
}
pub struct ApplyContext<'a> {
pub declared: &'a Declared,
pub repo: &'a std::path::Path,
pub reader: &'a dyn TreeReader,
pub host: &'a dyn ApplyHost,
pub deploy: &'a dyn DeployApi,
pub gateway: &'a dyn GatewayApi,
pub backup: Option<&'a BackupNode<'a>>,
pub clock: &'a dyn Clock,
pub observer: &'a dyn ApplyObserver,
}
impl<'a> ApplyContext<'a> {
fn restore_context(&'a self, opts: &'a ApplyOptions) -> RestoreContext<'a> {
RestoreContext {
env: &self.declared.env,
master: &self.declared.target.nodes.master,
host: self.host,
deploy: self.deploy,
clock: self.clock,
hold: &opts.hold,
poll: &opts.poll,
}
}
}
#[derive(Debug, Clone)]
pub struct ApplyOptions {
pub base: Option<PathBuf>,
pub gates: GateFlags,
pub project: String,
pub hold: HoldModel,
pub poll: PollConfig,
pub verify: VerifyOptions,
pub designers: DesignersPolicy,
pub skip_gwbk: bool,
pub backup: BackupPolicy,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ApplyOutcome {
#[serde(rename = "outcome")]
pub kind: OutcomeKind,
pub run_id: Option<String>,
pub run_dir: Option<String>,
pub cause: Option<RollbackCause>,
pub render_sha256: String,
pub plan_sha256: String,
pub written: Vec<String>,
pub deleted: Vec<String>,
pub post_fingerprint: Option<String>,
pub warnings: Vec<String>,
pub gwbk: Option<String>,
pub skip_gwbk: bool,
pub restart_pending: Vec<String>,
pub restart_pending_new: Vec<String>,
pub snapshot_sha256: BTreeMap<String, String>,
pub backup: Option<BackupReport>,
}
pub async fn run_apply(
ctx: &ApplyContext<'_>,
plan: &DeployPlanResult,
opts: &ApplyOptions,
) -> Result<ApplyOutcome, CoreError> {
if let Some(refusal) = gates::unreadable_refusal(plan) {
return Err(refusal);
}
if !plan.summary.has_changes {
return Ok(ApplyOutcome {
kind: OutcomeKind::Noop,
run_id: None,
run_dir: None,
cause: None,
render_sha256: plan.render_sha256.clone(),
plan_sha256: plan.plan_sha256.clone(),
written: Vec::new(),
deleted: Vec::new(),
post_fingerprint: None,
warnings: Vec::new(),
gwbk: None,
skip_gwbk: false,
restart_pending: Vec::new(),
restart_pending_new: Vec::new(),
snapshot_sha256: BTreeMap::new(),
backup: None,
});
}
let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &plan.gateway)?;
let mut steps = Steps::new(None, ctx.observer);
let result = drive(ctx, plan, opts, &run_id, &run_dir, &mut steps).await;
if let Err(e) = &result {
let _ = steps.emit(ApplyEvent::Failed {
code: e.code().to_string(),
});
}
let doc = match &result {
Ok(outcome) => {
let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
doc["ok"] = Value::Bool(!matches!(
outcome.kind,
OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
));
match outcome.kind {
OutcomeKind::RolledBack => {
doc["slug"] = Value::String(ROLLED_BACK_SLUG.to_string());
}
OutcomeKind::AppliedBackupUnsynced => {
doc["slug"] = Value::String(BACKUP_UNSYNCED_SLUG.to_string());
}
OutcomeKind::Applied | OutcomeKind::Noop => {}
}
doc
}
Err(e) => json!({
"ok": false,
"run_id": run_id.as_str(),
"error": {"code": e.code(), "message": e.to_string()},
}),
};
let written = run_dir.write_json("result.json", &doc);
match result {
Ok(mut outcome) => {
if written.is_err() {
outcome.warnings.push("result_json_unwritable".to_string());
}
Ok(outcome)
}
Err(e) => Err(e),
}
}
pub struct Steps<'a> {
log: Option<EventLog>,
observer: &'a dyn ApplyObserver,
}
impl<'a> Steps<'a> {
pub fn new(log: Option<EventLog>, observer: &'a dyn ApplyObserver) -> Self {
Steps { log, observer }
}
pub fn emit(&mut self, event: ApplyEvent) -> Result<(), CoreError> {
self.observer.event(&event);
match &mut self.log {
Some(log) => log.append(event.name(), event.fields()),
None => Ok(()),
}
}
}
fn with_env(err: CoreError, env: &str) -> CoreError {
match err {
CoreError::DeployJournalPresent {
run_id, location, ..
} => CoreError::DeployJournalPresent {
run_id,
env: env.to_string(),
location,
},
other => other,
}
}
enum BoxState {
Mine(Journal),
Other,
Absent,
Unknown,
}
async fn box_state(ctx: &ApplyContext<'_>, plan: &DeployPlanResult, run_id: &RunId) -> BoxState {
let master = &ctx.declared.target.nodes.master;
match journal_on_box(ctx.reader, master, &plan.gateway, &plan.mode).await {
Ok(Some(journal)) if journal.run_id == run_id.as_str() => BoxState::Mine(journal),
Ok(Some(_)) => BoxState::Other,
Ok(None) => BoxState::Absent,
Err(_) => BoxState::Unknown,
}
}
fn interrupted(
ctx: &ApplyContext<'_>,
plan: &DeployPlanResult,
steps: &mut Steps<'_>,
journal: &Journal,
err: &CoreError,
) -> CoreError {
let _ = steps.emit(ApplyEvent::CommitInterrupted {
code: err.code().to_string(),
});
journal::journal_present(
journal,
&ctx.declared.env,
&ctx.declared.target.nodes.master,
&plan.gateway,
&plan.mode,
)
}
async fn commit_failed(
ctx: &ApplyContext<'_>,
run_id: &RunId,
plan: &DeployPlanResult,
steps: &mut Steps<'_>,
err: CoreError,
) -> CoreError {
if matches!(err, CoreError::DeployJournalPresent { .. }) {
return err;
}
match box_state(ctx, plan, run_id).await {
BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
BoxState::Unknown => {
let _ = steps.emit(ApplyEvent::CommitInterrupted {
code: format!("{}; journal state unknown", err.code()),
});
recovery_pointer(ctx, plan, run_id, err)
}
BoxState::Other | BoxState::Absent => err,
}
}
async fn unfinished_after_green(
ctx: &ApplyContext<'_>,
run_id: &RunId,
plan: &DeployPlanResult,
steps: &mut Steps<'_>,
err: CoreError,
) -> CoreError {
match box_state(ctx, plan, run_id).await {
BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
_ => recovery_pointer(ctx, plan, run_id, err),
}
}
fn recovery_pointer(
ctx: &ApplyContext<'_>,
plan: &DeployPlanResult,
run_id: &RunId,
err: CoreError,
) -> CoreError {
let env = &ctx.declared.env;
match err {
CoreError::TransportFailed {
operation,
node,
detail,
} => CoreError::TransportFailed {
operation,
node,
detail: format!(
"{detail}; this run's journal may be on the host: recover it with: \
ign deploy rollback --run {run} --env {env}",
run = run_id.as_str()
),
},
_ => CoreError::DeployJournalPresent {
run_id: run_id.as_str().to_string(),
env: env.clone(),
location: journal_location(
LedgerRoot::for_node(&ctx.declared.target.nodes.master),
&plan.gateway,
&plan.mode,
),
},
}
}
async fn drive(
ctx: &ApplyContext<'_>,
plan: &DeployPlanResult,
opts: &ApplyOptions,
run_id: &RunId,
run_dir: &RunDir,
steps: &mut Steps<'_>,
) -> Result<ApplyOutcome, CoreError> {
let declared = ctx.declared;
let env = declared.env.as_str();
let (gateway, mode) = (plan.gateway.as_str(), plan.mode.as_str());
let started_at = rfc3339_now_utc();
steps.log = Some(EventLog::open(run_dir)?);
let set = write_set(plan, declared)?;
steps.emit(ApplyEvent::RunStarted {
run_id: run_id.as_str().to_string(),
writes: set.writes.len(),
deletes: set.deletes.len(),
})?;
let touched_paths: Vec<String> = set
.writes
.iter()
.map(|w| w.path.as_str().to_string())
.chain(set.deletes.iter().map(|d| d.path.as_str().to_string()))
.collect();
let deleted_paths: Vec<String> = set
.deletes
.iter()
.map(|d| d.path.as_str().to_string())
.collect();
let preflights = run_preflights(
&PreflightInput {
target: &declared.target,
declared: Some(declared),
master: ctx.deploy,
backup: ctx.backup,
gateway: ctx.gateway,
reader: ctx.reader,
host: ctx.host,
touched_files: set.writes.len() + set.deletes.len(),
touched_paths: &touched_paths,
deleted_paths: &deleted_paths,
hold: &opts.hold,
designers: &opts.designers,
clock: ctx.clock,
},
steps,
)
.await;
let seen = preflights.as_ref().ok();
run_dir.write_json(
"run.json",
&json!({
"schema_version": 1,
"run_id": run_id.as_str(),
"env": env,
"gateway": gateway,
"mode": mode,
"node": plan.node,
"transport": plan.transport,
"started_at": started_at,
"run_dir_git": run_dir.git_status().as_record(),
"render_sha256": plan.render_sha256,
"plan_sha256": plan.plan_sha256,
"hold_s": opts.hold.hold_s,
"allow_destructive": opts.gates.allow_destructive,
"allow_drift": opts.gates.allow_drift,
"skip_gwbk": opts.skip_gwbk,
"designers": opts.designers.mode.as_str(),
"designers_timeout_s": opts.designers.timeout.as_secs(),
"designers_seen": seen.map(|r| r.designers_seen
.iter()
.map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
.collect::<Vec<_>>()),
"preflights": seen.map(|r| r.findings
.iter()
.map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
.collect::<Vec<_>>()),
}),
)?;
run_dir.write_json("plan.json", plan)?;
let preflights = preflights?;
if !preflights.passed() {
steps.emit(ApplyEvent::PreflightChecks {
checks: preflights
.findings
.iter()
.map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
.collect(),
})?;
if !preflights.backup_unreadable.is_empty() {
return Err(CoreError::DeployUnreadable {
node: "backup".to_string(),
keys: preflights.backup_unreadable,
});
}
return Err(CoreError::DeployPreflightFailed {
failures: preflights.failures().into(),
});
}
let mut backup_before = match ctx.backup {
Some(backup) => {
let sampled = backup.status().await?.last_sync_epoch_ms();
steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
sampled
}
None => None,
};
let mut backup_log_since_ms = wall_clock_ms();
let gwbk = take_gwbk(ctx, opts, run_id, run_dir, steps).await?;
let restart_before = ctx.deploy.restart_pending().await.ok();
let old_bytes = ctx.reader.read_ledger(gateway, mode).await?;
let old_ledger = match &old_bytes {
Some(bytes) => Some(Ledger::parse(
&ledger_location(
LedgerRoot::for_node(&declared.target.nodes.master),
gateway,
mode,
),
bytes,
gateway,
mode,
)?),
None => None,
};
let ledger = new_ledger(plan, old_ledger.as_ref(), &set, run_id);
let ledger_bytes = ledger.to_bytes();
Ledger::parse(
&ledger_location(
LedgerRoot::for_node(&declared.target.nodes.master),
gateway,
mode,
),
&ledger_bytes,
gateway,
mode,
)?;
let spec = batch_spec(
plan,
run_id,
&set,
ledger_bytes,
old_bytes.as_deref().map(sha256_hex),
commit_budget_s(&opts.hold),
);
if let Some(problem) = spec.problem() {
return Err(CoreError::Internal(format!(
"the batch the engine built is invalid: {problem}"
)));
}
let (baseline, backup_baseline) = read_back_baselines(ctx, plan, run_dir).await?;
let scope = RunScope {
plan,
run_id,
run_dir,
gwbk: gwbk.as_deref(),
restart_before: restart_before.as_deref(),
record: &spec.record,
};
let prepared = ctx
.host
.prepare(&spec, &declared.outcome.tree)
.await
.map_err(|e| with_env(e, env))?;
steps.emit(ApplyEvent::Staged {
files: set.writes.len(),
})?;
let lock =
match HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await {
Ok(lock) => lock,
Err(refused) => {
let _ = ctx.host.discard_interrupted(run_id).await;
return Err(refused);
}
};
steps.emit(ApplyEvent::LockAcquired {
hold_s: opts.hold.hold_s,
})?;
if let Some(backup) = ctx.backup {
let since_ms = wall_clock_ms();
if let Ok(Ok(status)) = tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
let sampled = status.last_sync_epoch_ms();
steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
backup_before = sampled;
backup_log_since_ms = since_ms;
}
}
let report = match ctx.host.commit(&prepared).await {
Ok(report) => report,
Err(e) => return Err(commit_failed(ctx, run_id, plan, steps, with_env(e, env)).await),
};
if let Err(e) = steps.emit(ApplyEvent::Committed {
moved: report.moved.len(),
deleted: report.deleted.len(),
}) {
return Err(commit_failed(ctx, run_id, plan, steps, e).await);
}
if report.stale {
return Err(plan_stale(ctx, plan, opts, run_id).await);
}
if report.failure.is_some() {
let cause = if report.deadline {
RollbackCause::LockExpired
} else {
RollbackCause::WriteFailed
};
return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, Vec::new()).await;
}
let mut warnings = match confirm(ctx, opts, &lock, &baseline, steps).await {
Ok(Confirmed::Green { warnings }) => warnings,
Ok(Confirmed::Abort { cause, warnings }) => {
return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, warnings).await;
}
Err(Unconfirmed::Restore { cause, step, error }) => {
let note = format!("post_commit_error ({step}: {})", error.code());
return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, vec![note]).await;
}
Err(Unconfirmed::Green(error)) => {
return Err(unfinished_after_green(ctx, run_id, plan, steps, error).await);
}
};
let post = match ctx.host.fingerprint(&spec.touched_paths()).await {
Ok(post) => post,
Err(e) => return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await),
};
let post_fingerprint = fingerprint(&post);
let mut record = spec.record.clone();
record.state = RecordState::Applied;
record.post_fingerprint = Some(post_fingerprint.clone());
if let Err(e) = ctx.host.finalize(&record).await {
if !matches!(box_state(ctx, plan, run_id).await, BoxState::Absent) {
return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await);
}
warnings.push(format!("finalize_reported_error ({})", e.code()));
}
if steps.emit(ApplyEvent::Finalized).is_err() {
warnings.push(EVENTS_UNWRITABLE.to_string());
}
let (restart_pending, restart_pending_new) =
restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
let mut snapshot_sha256 = BTreeMap::new();
match ctx.host.fetch_snapshot(run_id, run_dir.path()).await {
Ok(hashes) => {
warnings.extend(snapshot_warnings(&spec.record, &hashes));
snapshot_sha256 = hashes;
}
Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
}
let backup_report = match ctx.backup {
Some(backup) => Some(
converge::converge_backup(
ctx,
backup,
opts,
&set,
converge::Baseline {
last_sync: backup_before,
log_since_ms: backup_log_since_ms,
read_back: backup_baseline,
},
steps,
&mut warnings,
)
.await,
),
None => None,
};
let kind = match &backup_report {
Some(report) if !report.converged => OutcomeKind::AppliedBackupUnsynced,
_ => OutcomeKind::Applied,
};
Ok(ApplyOutcome {
kind,
run_id: Some(run_id.as_str().to_string()),
run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
cause: None,
render_sha256: plan.render_sha256.clone(),
plan_sha256: plan.plan_sha256.clone(),
written: set
.writes
.iter()
.map(|w| w.path.as_str().to_string())
.collect(),
deleted: set
.deletes
.iter()
.map(|d| d.path.as_str().to_string())
.collect(),
post_fingerprint: Some(post_fingerprint),
warnings,
gwbk,
skip_gwbk: opts.skip_gwbk,
restart_pending,
restart_pending_new,
snapshot_sha256,
backup: backup_report,
})
}
enum Confirmed {
Green { warnings: Vec<String> },
Abort {
cause: RollbackCause,
warnings: Vec<String>,
},
}
enum Unconfirmed {
Restore {
cause: RollbackCause,
step: &'static str,
error: CoreError,
},
Green(CoreError),
}
async fn read_back_baselines(
ctx: &ApplyContext<'_>,
plan: &DeployPlanResult,
run_dir: &RunDir,
) -> Result<(ReadBackBaseline, Option<ReadBackBaseline>), CoreError> {
let declared = ctx.declared;
let expected =
Expected::from_render(&declared.target.mode, &declared.outcome, &declared.manifest);
let status: BTreeMap<&str, PlanStatus> = plan
.entries
.iter()
.map(|entry| (entry.key.as_str(), entry.status))
.collect();
let changed: Vec<_> = expected
.resources
.iter()
.filter(|res| status.get(res.key.as_str()) == Some(&PlanStatus::Changed))
.collect();
let live = if changed.is_empty() {
BTreeMap::new()
} else {
let types: BTreeSet<(String, String)> = changed
.iter()
.map(|res| (res.module.clone(), res.ty.clone()))
.collect();
effective_files(
ctx.reader
.read(&Scope {
mode: plan.mode.clone(),
types: types.into_iter().collect(),
tag_providers: Vec::new(),
})
.await?,
)
};
let written: BTreeMap<String, Written> = expected
.resources
.iter()
.filter_map(|res| match status.get(res.key.as_str()) {
Some(PlanStatus::Added) => Some((res.key.clone(), Written::Added)),
Some(PlanStatus::Changed) => {
let file = |name: &str| {
RelPath::new(&format!("{}/{name}", res.key))
.ok()
.and_then(|path| live.get(&path))
.map(|blob| blob.bytes.as_slice())
};
let compare = compare_for(&res.module, &res.ty, file).ok();
Some((res.key.clone(), Written::Changed(compare)))
}
_ => None,
})
.collect();
let before = gather_read_back(ctx.deploy, &expected).await?;
let master = ReadBackBaseline::build("master", &expected, &before, &written);
let backup = match ctx.backup {
Some(backup) => {
let before = backup.read_back(&expected).await?;
Some(ReadBackBaseline::build(
"backup", &expected, &before, &written,
))
}
None => None,
};
let nodes: Vec<_> = std::iter::once(master.record())
.chain(backup.as_ref().map(ReadBackBaseline::record))
.collect();
run_dir.write_json(
READ_BACK_BASELINE_FILE,
&json!({"schema_version": 1, "nodes": nodes}),
)?;
Ok((master, backup))
}
async fn confirm(
ctx: &ApplyContext<'_>,
opts: &ApplyOptions,
lock: &HeldLock,
baseline: &ReadBackBaseline,
steps: &mut Steps<'_>,
) -> Result<Confirmed, Unconfirmed> {
fn undone(cause: RollbackCause, step: &'static str) -> impl FnOnce(CoreError) -> Unconfirmed {
move |error| Unconfirmed::Restore { cause, step, error }
}
let abort = |cause, warnings| Ok(Confirmed::Abort { cause, warnings });
let verdict = lock
.check(ctx.deploy, LOCK_MARGIN_S, ctx.clock)
.await
.map_err(undone(RollbackCause::LockExpired, "lock_check"))?;
let held = matches!(verdict, LockVerdict::Held { .. });
steps
.emit(ApplyEvent::LockVerdict { held })
.map_err(undone(RollbackCause::LockExpired, "events"))?;
if !held {
return abort(RollbackCause::LockExpired, Vec::new());
}
match scan_and_wait(ctx.deploy, ScanKind::Config, opts.poll.clone())
.await
.map_err(undone(RollbackCause::ScanIncomplete, "scan"))?
{
ScanOutcome::Complete(_) => steps
.emit(ApplyEvent::ScanDone)
.map_err(undone(RollbackCause::ScanIncomplete, "events"))?,
ScanOutcome::NotComplete { .. } => {
return abort(RollbackCause::ScanIncomplete, Vec::new());
}
}
let verify = deploy_verify_judged(
ctx.declared,
&opts.project,
ctx.deploy,
ctx.gateway,
ctx.reader,
&opts.verify,
VerifyContext::PostApply(baseline),
)
.await
.map_err(undone(RollbackCause::VerifyFailed, "verify"))?;
if !verify.ok {
steps
.emit(ApplyEvent::VerifyDone { ok: false })
.map_err(undone(RollbackCause::VerifyFailed, "events"))?;
steps
.emit(ApplyEvent::VerifyChecks {
checks: verify
.checks
.iter()
.map(|c| {
(
c.id.word().to_string(),
c.status.word().to_string(),
c.findings.clone(),
)
})
.collect(),
})
.map_err(undone(RollbackCause::VerifyFailed, "events"))?;
return abort(RollbackCause::VerifyFailed, verify.warnings);
}
steps
.emit(ApplyEvent::VerifyDone { ok: true })
.map_err(Unconfirmed::Green)?;
Ok(Confirmed::Green {
warnings: verify.warnings,
})
}
async fn restart_report(
deploy: &dyn DeployApi,
before: Option<&[String]>,
warnings: &mut Vec<String>,
) -> (Vec<String>, Vec<String>) {
let after = deploy.restart_pending().await.ok();
if before.is_none() || after.is_none() {
warnings.push(RESTART_UNAVAILABLE.to_string());
}
let pending = after.unwrap_or_default();
let known = before.unwrap_or_default();
let added = pending
.iter()
.filter(|entry| !known.contains(entry))
.cloned()
.collect();
(pending, added)
}
fn snapshot_warnings(record: &RunRecord, copied: &BTreeMap<String, String>) -> Vec<String> {
record
.pre_map()
.into_iter()
.filter_map(|(path, pre)| {
let pre = pre?;
let key = if path == LEDGER_KEY {
"ledger.json".to_string()
} else {
format!("files/{path}")
};
(copied.get(&key) != Some(&pre)).then(|| format!("snapshot_copy_differs ({path})"))
})
.collect()
}
async fn take_gwbk(
ctx: &ApplyContext<'_>,
opts: &ApplyOptions,
run_id: &RunId,
run_dir: &RunDir,
steps: &mut Steps<'_>,
) -> Result<Option<String>, CoreError> {
if opts.skip_gwbk {
steps.emit(ApplyEvent::GwbkSkipped)?;
return Ok(None);
}
let refuse = |detail: String| CoreError::DeployPreflightFailed {
failures: vec![PreflightFailure {
check: GWBK_CHECK.to_string(),
detail,
}]
.into(),
};
let name = format!("{}.gwbk", run_id.as_str());
drop(run_dir.create_private_file(&name)?);
let path = run_dir.path().join(&name);
let verdict = match backup_download(ctx.gateway, Some(&path), "", BackupType::All).await {
Ok(_) => validate_gwbk(&path),
Err(e) => Err(format!("the .gwbk was not downloaded ({})", e.code())),
};
match verdict {
Ok(bytes) => {
steps.emit(ApplyEvent::GwbkDownloaded { bytes })?;
Ok(Some(name))
}
Err(detail) => {
let _ = std::fs::remove_file(&path);
Err(refuse(detail))
}
}
}
fn validate_gwbk(path: &Path) -> Result<u64, String> {
let bytes = std::fs::metadata(path)
.map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?
.len();
if bytes == 0 {
return Err("the downloaded .gwbk is empty".to_string());
}
let file = std::fs::File::open(path)
.map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?;
let archive = zip::ZipArchive::new(file).map_err(|_| {
"the downloaded .gwbk is not a readable ZIP (no central directory), so the download is incomplete"
.to_string()
})?;
if archive.is_empty() {
return Err("the downloaded .gwbk is a ZIP with no entries".to_string());
}
Ok(bytes)
}
struct RunScope<'a> {
plan: &'a DeployPlanResult,
run_id: &'a RunId,
run_dir: &'a RunDir,
gwbk: Option<&'a str>,
restart_before: Option<&'a [String]>,
record: &'a RunRecord,
}
async fn roll_back(
ctx: &ApplyContext<'_>,
opts: &ApplyOptions,
scope: &RunScope<'_>,
held: Option<&HeldLock>,
cause: RollbackCause,
steps: &mut Steps<'_>,
mut warnings: Vec<String>,
) -> Result<ApplyOutcome, CoreError> {
let restored = restore_snapshot(
&ctx.restore_context(opts),
&RestoreInput {
run_id: scope.run_id,
run_dir: scope.run_dir,
record: scope.record,
held,
cause: Some(cause),
},
steps,
)
.await?;
warnings.extend(restored.warnings);
let (restart_pending, restart_pending_new) =
restart_report(ctx.deploy, scope.restart_before, &mut warnings).await;
Ok(ApplyOutcome {
kind: OutcomeKind::RolledBack,
run_id: Some(scope.run_id.as_str().to_string()),
run_dir: Some(scope.run_dir.path().to_string_lossy().into_owned()),
cause: Some(cause),
render_sha256: scope.plan.render_sha256.clone(),
plan_sha256: scope.plan.plan_sha256.clone(),
written: Vec::new(),
deleted: Vec::new(),
post_fingerprint: Some(scope.record.pre_fingerprint.clone()),
warnings,
gwbk: scope.gwbk.map(str::to_string),
skip_gwbk: opts.skip_gwbk,
restart_pending,
restart_pending_new,
snapshot_sha256: restored.snapshot_sha256,
backup: None,
})
}
pub struct RestoreInput<'a> {
pub run_id: &'a RunId,
pub run_dir: &'a RunDir,
pub record: &'a RunRecord,
pub held: Option<&'a HeldLock>,
pub cause: Option<RollbackCause>,
}
pub struct RestoreContext<'a> {
pub env: &'a str,
pub master: &'a NodeTarget,
pub host: &'a dyn ApplyHost,
pub deploy: &'a dyn DeployApi,
pub clock: &'a dyn Clock,
pub hold: &'a HoldModel,
pub poll: &'a PollConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoreReport {
pub restored: usize,
pub removed: usize,
pub fresh_lock: bool,
pub warnings: Vec<String>,
pub snapshot_sha256: BTreeMap<String, String>,
}
pub async fn restore_snapshot(
ctx: &RestoreContext<'_>,
input: &RestoreInput<'_>,
steps: &mut Steps<'_>,
) -> Result<RestoreReport, CoreError> {
let record = input.record;
match restore_steps(ctx, input, steps).await {
Ok(report) => Ok(report),
Err(cause) => Err(CoreError::RollbackFailed(Box::new(RollbackFailure {
run_id: record.run_id.clone(),
env: ctx.env.to_string(),
run_dir: Some(input.run_dir.path().to_string_lossy().into_owned()),
journal: journal_location(
LedgerRoot::for_node(ctx.master),
&record.gateway,
&record.mode,
),
cause,
}))),
}
}
async fn restore_steps(
ctx: &RestoreContext<'_>,
input: &RestoreInput<'_>,
steps: &mut Steps<'_>,
) -> Result<RestoreReport, String> {
let gw = |step: &'static str| move |e: CoreError| format!("{step}: {}", e.code());
let host = |step: &'static str| move |e: CoreError| format!("{step}: {}", with_env(e, ctx.env));
let event = |e: CoreError| format!("events: {}", e.code());
let record = input.record;
let deploy = ctx.deploy;
steps
.emit(ApplyEvent::RestoreStarted { cause: input.cause })
.map_err(event)?;
let need_s = ctx.hold.estimate_commit_s(record.touched.len()).ceil() as i64 + LOCK_MARGIN_S;
let reuse = match input.held {
Some(lock) => match lock
.check_gateway(deploy, need_s)
.await
.map_err(gw("checking the scan-lock"))?
{
LockVerdict::Held { .. } => true,
LockVerdict::Expiring { .. } => {
lock.wait_for_lapse(deploy, ctx.poll.interval)
.await
.map_err(gw("waiting for the scan-lock to lapse"))?;
false
}
LockVerdict::NotHeld => false,
},
None => false,
};
if !reuse {
HeldLock::acquire(deploy, ScanKind::Config, ctx.hold.hold_s, ctx.clock)
.await
.map_err(gw("taking the scan-lock"))?;
steps
.emit(ApplyEvent::LockAcquired {
hold_s: ctx.hold.hold_s,
})
.map_err(event)?;
}
let report = ctx.host.restore(record).await.map_err(host("restore"))?;
if let Some(failure) = &report.failure {
return Err(format!("restore: {failure}"));
}
if report.stale {
return Err("restore: the host reported a stale pre-image".to_string());
}
match scan_and_wait(deploy, ScanKind::Config, ctx.poll.clone())
.await
.map_err(gw("the scan after the restore"))?
{
ScanOutcome::Complete(_) => steps.emit(ApplyEvent::ScanDone).map_err(event)?,
ScanOutcome::NotComplete { .. } => {
return Err("the scan after the restore did not complete".to_string());
}
}
let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
let live = ctx
.host
.fingerprint(&touched)
.await
.map_err(host("reading the restored files"))?;
let pre = record.pre_map();
let differing: Vec<String> = pre
.iter()
.filter(|(path, hash)| live.get(*path) != Some(*hash))
.map(|(path, _)| path.clone())
.collect();
if !differing.is_empty() {
return Err(format!(
"{} restored path(s) differ from the snapshot: {}",
differing.len(),
listed(&differing)
));
}
let mode = deploy
.deployment_mode()
.await
.map_err(gw("reading the deployment mode"))?;
if mode.as_deref() != Some(record.mode.as_str()) {
return Err("the gateway's deployment mode is not the target's".to_string());
}
let mut rolled_back = record.clone();
rolled_back.state = RecordState::RolledBack;
rolled_back.rolled_back_by = Some(input.run_id.as_str().to_string());
rolled_back.post_fingerprint = Some(record.pre_fingerprint.clone());
ctx.host
.finalize(&rolled_back)
.await
.map_err(host("finalize"))?;
steps.emit(ApplyEvent::Restored).map_err(event)?;
let mut warnings = Vec::new();
let mut snapshot_sha256 = BTreeMap::new();
match RunId::parse(&record.run_id) {
Ok(restored_run) => {
match ctx
.host
.fetch_snapshot(&restored_run, input.run_dir.path())
.await
{
Ok(hashes) => {
warnings.extend(snapshot_warnings(record, &hashes));
snapshot_sha256 = hashes;
}
Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
}
}
Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
}
Ok(RestoreReport {
restored: report.moved.len(),
removed: report.deleted.len(),
fresh_lock: !reuse,
warnings,
snapshot_sha256,
})
}
fn listed(paths: &[String]) -> String {
let shown = paths.len().min(crate::error::APPLY_LIST_SHOWN);
let mut out = paths[..shown].join(", ");
if paths.len() > shown {
out.push_str(&format!(" and {} more", paths.len() - shown));
}
out
}
async fn plan_stale(
ctx: &ApplyContext<'_>,
plan: &DeployPlanResult,
opts: &ApplyOptions,
run_id: &RunId,
) -> CoreError {
let _ = ctx.host.discard_interrupted(run_id).await;
let _ = ctx.deploy.scan_request(ScanKind::Config).await;
let actual = match deploy_plan(
ctx.declared,
ctx.reader,
&PlanOptions {
base: opts.base.clone(),
diff: false,
},
)
.await
{
Ok(now) => now.plan_sha256,
Err(_) => "unavailable".to_string(),
};
CoreError::PlanStale {
expected: plan.plan_sha256.clone(),
actual,
detail: "a live file changed between the plan and the lock; nothing was written"
.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(touched: &[(&str, Option<&str>)]) -> RunRecord {
let touched: Vec<TouchedEntry> = touched
.iter()
.map(|(path, pre)| TouchedEntry {
path: (*path).to_string(),
pre_sha256: pre.map(str::to_string),
post_sha256: Some("f".repeat(64)),
})
.collect();
RunRecord {
schema_version: 1,
run_id: "20261016T000000Z-aaaaaa".to_string(),
gateway: "gw".to_string(),
mode: "dev".to_string(),
render_sha256: "a".repeat(64),
plan_sha256: "b".repeat(64),
state: RecordState::Applying,
rolled_back_by: None,
touched,
pre_fingerprint: "c".repeat(64),
post_fingerprint: None,
}
}
#[test]
fn the_commit_budget_is_the_hold_less_its_margin_and_never_zero() {
let hold = |hold_s| HoldModel {
hold_s,
..HoldModel::MEASURED
};
assert_eq!(commit_budget_s(&hold(176)), 174);
assert_eq!(commit_budget_s(&HoldModel::MEASURED), 174);
assert_eq!(commit_budget_s(&hold(2)), 1);
assert_eq!(commit_budget_s(&hold(0)), 1);
assert_eq!(commit_budget_s(&hold(1_000_000)), BUDGET_MAX_S);
}
#[test]
fn snapshot_warnings_name_a_differing_or_missing_copy_and_skip_created_paths() {
let (h1, h2) = ("1".repeat(64), "2".repeat(64));
let rec = record(&[
("a/config.json", Some(&h1)),
("b/config.json", Some(&h1)),
("c/config.json", None),
(LEDGER_KEY, Some(&h2)),
]);
let mut copied = BTreeMap::new();
copied.insert("files/a/config.json".to_string(), h1.clone());
copied.insert("files/b/config.json".to_string(), h2.clone());
copied.insert("ledger.json".to_string(), h2);
assert_eq!(
snapshot_warnings(&rec, &copied),
["snapshot_copy_differs (b/config.json)"]
);
copied.remove("files/a/config.json");
assert_eq!(
snapshot_warnings(&rec, &copied),
[
"snapshot_copy_differs (a/config.json)",
"snapshot_copy_differs (b/config.json)"
]
);
}
}