use std::collections::{BTreeMap, BTreeSet};
use super::fingerprint::LEDGER_KEY;
use super::hold::Clock;
use super::record::RunRecord;
use super::{ApplyContext, ApplyEvent, ApplyOptions, EVENTS_UNWRITABLE, Steps, WriteSet};
use crate::client::DeployApi;
use crate::deploy::layout::{RelPath, TAG_TYPES, split_key};
use crate::deploy::pair::{
BackupCheck, BackupCheckId, BackupNode, BackupPolicy, BackupReport, BackupSignals,
PairWaitStep, ResyncRecord, TickOutcome, UnreadableUnits, compare_files, effective_files,
exempt_unreadable, files_check, files_skipped_check, mode_backup_check,
read_back_backup_check_with, report, unreachable_check, unreadable_units, unreadable_warnings,
wait_step,
};
use crate::deploy::transport::{FileBlob, Scope, TreeReader};
use crate::deploy::verify::{CheckStatus, Expected, ReadBackBaseline, ReadBackJudge};
#[derive(Debug, Clone)]
pub(crate) struct Baseline {
pub(crate) last_sync: Option<i64>,
pub(crate) log_since_ms: i64,
pub(crate) read_back: Option<ReadBackBaseline>,
}
struct Content {
checks: Vec<BackupCheck>,
files_checked: usize,
}
enum ReadBack {
Judge(Option<ReadBackBaseline>),
Skipped,
}
struct Phase<'a, 'c, 's> {
deploy: &'a dyn DeployApi,
reader: &'a dyn TreeReader,
clock: &'a dyn Clock,
backup: &'a BackupNode<'c>,
steps: &'a mut Steps<'s>,
warnings: &'a mut Vec<String>,
scope: Scope,
present: BTreeSet<RelPath>,
deleted: Vec<RelPath>,
master_files: Option<BTreeMap<RelPath, FileBlob>>,
master_unreadable: UnreadableUnits,
expected: Expected,
read_back: ReadBack,
policy: BackupPolicy,
before: Option<i64>,
log_since_ms: i64,
resync: Option<ResyncRecord>,
}
pub(crate) async fn converge_backup(
ctx: &ApplyContext<'_>,
backup: &BackupNode<'_>,
opts: &ApplyOptions,
set: &WriteSet,
baseline: Baseline,
steps: &mut Steps<'_>,
warnings: &mut Vec<String>,
) -> BackupReport {
let declared = ctx.declared;
let Baseline {
last_sync: before,
log_since_ms,
read_back: read_back_baseline,
} = baseline;
let phase = Phase {
deploy: ctx.deploy,
reader: ctx.reader,
clock: ctx.clock,
backup,
steps,
warnings,
scope: Scope::from_manifest(
&declared.manifest,
&declared.target.mode,
&declared.excluded_tag_providers(),
),
present: declared.outcome.tree.files.keys().cloned().collect(),
deleted: set
.deletes
.iter()
.filter_map(|op| RelPath::new(split_key(op.path.as_str()).1).ok())
.collect(),
master_files: None,
master_unreadable: UnreadableUnits::new(),
expected: Expected::from_render(
&declared.target.mode,
&declared.outcome,
&declared.manifest,
),
read_back: ReadBack::Judge(read_back_baseline),
policy: opts.backup,
before,
log_since_ms,
resync: None,
};
phase.run().await
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct RestoreBaseline {
pub(crate) last_sync: Option<i64>,
pub(crate) log_since_ms: i64,
}
pub(crate) struct RestoreSurfaces<'a, 'c> {
pub(crate) deploy: &'a dyn DeployApi,
pub(crate) reader: &'a dyn TreeReader,
pub(crate) clock: &'a dyn Clock,
pub(crate) backup: &'a BackupNode<'c>,
}
pub(crate) async fn converge_restore(
surfaces: RestoreSurfaces<'_, '_>,
record: &RunRecord,
mode: &str,
policy: BackupPolicy,
baseline: RestoreBaseline,
steps: &mut Steps<'_>,
warnings: &mut Vec<String>,
) -> BackupReport {
let mut present = BTreeSet::new();
let mut deleted = Vec::new();
let mut types = BTreeSet::new();
let mut providers = BTreeSet::new();
for entry in &record.touched {
if entry.path == LEDGER_KEY {
continue;
}
let Ok(path) = RelPath::new(split_key(&entry.path).1) else {
continue;
};
let mut parts = path.as_str().split('/');
if let (Some(module), Some(ty)) = (parts.next(), parts.next()) {
if module == "ignition" && TAG_TYPES.contains(&ty) {
if let Some(provider) = parts.next() {
providers.insert(provider.to_string());
}
} else {
types.insert((module.to_string(), ty.to_string()));
}
}
if entry.pre_sha256.is_some() {
present.insert(path);
} else {
deleted.push(path);
}
}
let phase = Phase {
deploy: surfaces.deploy,
reader: surfaces.reader,
clock: surfaces.clock,
backup: surfaces.backup,
steps,
warnings,
scope: Scope {
mode: mode.to_string(),
types: types.into_iter().collect(),
tag_providers: providers.into_iter().collect(),
},
present,
deleted,
master_files: None,
master_unreadable: UnreadableUnits::new(),
expected: Expected {
mode: mode.to_string(),
resources: Vec::new(),
not_checked: Vec::new(),
declared_ids: Vec::new(),
tag_units: Vec::new(),
probes: Vec::new(),
},
read_back: ReadBack::Skipped,
policy,
before: baseline.last_sync,
log_since_ms: baseline.log_since_ms,
resync: None,
};
phase.run().await
}
fn read_back_skipped_check() -> BackupCheck {
BackupCheck {
id: BackupCheckId::ReadBack,
status: CheckStatus::Warn,
detail: "a restore puts back the run's snapshot, not a render, so the backup's REST \
read-back was not compared"
.to_string(),
findings: Vec::new(),
}
}
impl Phase<'_, '_, '_> {
async fn run(mut self) -> BackupReport {
let phase = &mut self;
let before = phase.before;
let budget_s = phase.policy.budget.as_secs();
let started = phase.clock.now();
phase.emit(ApplyEvent::BackupWaitStarted { budget_s });
let (mut last, mut step) = phase.wait_budget().await;
if step != PairWaitStep::Converged {
phase.send_resync_once().await;
if step == PairWaitStep::TimedOut {
(last, step) = phase.wait_budget().await;
}
if step != PairWaitStep::Converged {
last = phase.tick(true).await;
}
}
let waited = phase.clock.now().saturating_duration_since(started);
let resync = phase.resync.take();
let report = report(&last, before, budget_s, waited.as_secs(), resync);
phase.emit(ApplyEvent::BackupVerdict {
converged: report.converged,
reason: report.reason.map(|reason| reason.as_str()),
});
report
}
fn emit(&mut self, event: ApplyEvent) {
if self.steps.emit(event).is_err()
&& !self
.warnings
.iter()
.any(|warning| warning == EVENTS_UNWRITABLE)
{
self.warnings.push(EVENTS_UNWRITABLE.to_string());
}
}
async fn send_resync_once(&mut self) {
if self.resync.is_some() {
return;
}
let record = match self.deploy.redundancy_resync().await {
Ok(()) => ResyncRecord {
ok: true,
code: None,
},
Err(error) => ResyncRecord {
ok: false,
code: Some(error.code().to_string()),
},
};
self.resync = Some(record.clone());
self.emit(ApplyEvent::BackupResync {
ok: record.ok,
code: record.code,
});
}
async fn wait_budget(&mut self) -> (TickOutcome, PairWaitStep) {
let started = self.clock.now();
loop {
let tick = self.tick(false).await;
let elapsed = self.clock.now().saturating_duration_since(started);
match wait_step(&tick, elapsed, &self.policy) {
PairWaitStep::Sleep(pause) => tokio::time::sleep(pause).await,
step => return (tick, step),
}
}
}
async fn tick(&mut self, force: bool) -> TickOutcome {
let signals = match self.backup.status().await {
Ok(status) => Ok(BackupSignals::from_status(self.before, &status)),
Err(error) => Err(error.code().to_string()),
};
let poll = match &signals {
Ok(s) => ApplyEvent::BackupPoll {
peer_connected: s.peer_connected,
sync_pending: s.sync_pending,
advanced: s.advanced(),
},
Err(_) => ApplyEvent::BackupPoll {
peer_connected: false,
sync_pending: false,
advanced: false,
},
};
self.emit(poll);
let exhausted = match &signals {
Ok(_) => self
.backup
.sync_exhausted(self.log_since_ms)
.await
.unwrap_or(false),
Err(_) => false,
};
let content = match &signals {
Ok(s) if force || (s.linked() && s.advanced()) => Some(self.content().await),
_ => None,
};
TickOutcome {
signals,
exhausted,
files_checked: content.as_ref().map_or(0, |c| c.files_checked),
content: content.map(|c| c.checks),
stable_ticks: 0,
}
}
async fn content(&mut self) -> Content {
let mut checks = Vec::with_capacity(3);
checks.push(match self.backup.deployment_mode().await {
Ok(mode) => mode_backup_check(&self.expected, mode.as_deref()),
Err(error) => unreachable_check(BackupCheckId::Mode, error.code()),
});
checks.push(match &self.read_back {
ReadBack::Skipped => read_back_skipped_check(),
ReadBack::Judge(baseline) => match self.backup.read_back(&self.expected).await {
Ok(read_back) => {
let judge = match baseline {
Some(baseline) => ReadBackJudge::Baseline(baseline),
None => ReadBackJudge::Strict,
};
read_back_backup_check_with(&self.expected, &read_back, &judge, &mut Vec::new())
.0
}
Err(error) => unreachable_check(BackupCheckId::ReadBack, error.code()),
},
});
let (files, files_checked) = if self.backup.has_files() {
self.files().await
} else {
(files_skipped_check(), 0)
};
checks.push(files);
Content {
checks,
files_checked,
}
}
async fn files(&mut self) -> (BackupCheck, usize) {
let unreachable = |side: &str, code: &str| {
(
unreachable_check(BackupCheckId::Files, &format!("{side} {code}")),
0,
)
};
if self.master_files.is_none() {
match self.reader.read(&self.scope).await {
Ok(observed) => {
self.master_unreadable = unreadable_units(&observed);
self.master_files = Some(effective_files(observed));
}
Err(error) => return unreachable("master", error.code()),
}
}
let (backup_files, backup_unreadable) = match self.backup.read_files(&self.scope).await {
Some(Ok(observed)) => {
let unreadable = unreadable_units(&observed);
(effective_files(observed), unreadable)
}
Some(Err(error)) => return unreachable("backup", error.code()),
None => return (files_skipped_check(), 0),
};
let Some(master_files) = self.master_files.as_ref() else {
return (files_skipped_check(), 0);
};
let present = &self.present;
let absent: BTreeSet<RelPath> = self
.deleted
.iter()
.filter(|path| !master_files.contains_key(*path))
.cloned()
.collect();
let mut comparison = compare_files(master_files, &backup_files, present, &absent);
exempt_unreadable(
&mut comparison,
present,
&absent,
&[
("master", &self.master_unreadable),
("backup", &backup_unreadable),
],
);
for warning in unreadable_warnings(&comparison) {
if !self.warnings.contains(&warning) {
self.warnings.push(warning);
}
}
(files_check(&comparison), comparison.checked)
}
}