use std::collections::BTreeMap;
use std::path::Path;
use super::fingerprint::LEDGER_KEY;
use super::journal::{Journal, JournalPhase};
use super::record::{RecordState, RunRecord};
use super::run_store::RunId;
use crate::deploy::layout::RelPath;
use crate::deploy::ledger::LEDGER_MAX_BYTES;
use crate::deploy::transport::{mirror_mode_problem, mirror_path_problem};
use crate::deploy::tree::{RenderedTree, sha256_hex};
use crate::error::CoreError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WriteOp {
pub path: RelPath,
pub sha256: String,
pub pre: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteOp {
pub path: RelPath,
pub pre: String,
}
#[derive(Debug, Clone)]
pub struct BatchSpec {
pub run_id: RunId,
pub gateway: String,
pub mode: String,
pub writes: Vec<WriteOp>,
pub deletes: Vec<DeleteOp>,
pub ledger_new: Option<Vec<u8>>,
pub ledger_pre: Option<String>,
pub journal: Journal,
pub record: RunRecord,
pub commit_budget_s: Option<u32>,
}
impl BatchSpec {
pub fn touched_paths(&self) -> Vec<String> {
let mut paths: Vec<String> = self
.writes
.iter()
.map(|w| w.path.as_str().to_string())
.chain(self.deletes.iter().map(|d| d.path.as_str().to_string()))
.collect();
if self.ledger_new.is_some() {
paths.push(LEDGER_KEY.to_string());
}
paths.sort();
paths
}
pub fn problem(&self) -> Option<String> {
self.check().err()
}
fn check(&self) -> Result<(), String> {
if let Some(why) = crate::deploy::component_problem(&self.gateway) {
return Err(format!("the gateway {why}"));
}
if let Some(why) = mirror_mode_problem(&self.mode) {
return Err(why);
}
let run = self.run_id.as_str();
if self.journal.run_id != run || self.record.run_id != run {
return Err("the journal or record names a different run".to_string());
}
for (what, gateway, mode) in [
("journal", &self.journal.gateway, &self.journal.mode),
("record", &self.record.gateway, &self.record.mode),
] {
if *gateway != self.gateway || *mode != self.mode {
return Err(format!("the {what} names a different gateway or mode"));
}
}
if self.record.state != RecordState::Applying {
return Err("the record is not at state applying".to_string());
}
sorted_unique(self.writes.iter().map(|w| w.path.as_str()), "writes")?;
sorted_unique(self.deletes.iter().map(|d| d.path.as_str()), "deletes")?;
for op in &self.writes {
if let Some(why) = mirror_path_problem(&op.path) {
return Err(why);
}
if !is_hex64(&op.sha256) || op.pre.as_deref().is_some_and(|p| !is_hex64(p)) {
return Err(format!(
"a hash of {} is not 64 lowercase hex digits",
op.path
));
}
}
for op in &self.deletes {
if let Some(why) = mirror_path_problem(&op.path) {
return Err(why);
}
if !is_hex64(&op.pre) {
return Err(format!("the pre-image hash of {} is not hex", op.path));
}
if self.writes.iter().any(|w| w.path == op.path) {
return Err(format!("{} is both written and deleted", op.path));
}
}
match (&self.ledger_new, &self.ledger_pre) {
(None, Some(_)) => {
return Err("a prior ledger hash without a new ledger".to_string());
}
(Some(bytes), _) if bytes.is_empty() || bytes.len() > LEDGER_MAX_BYTES => {
return Err("the new ledger is empty or over the size cap".to_string());
}
_ => {}
}
if self.ledger_pre.as_deref().is_some_and(|p| !is_hex64(p)) {
return Err("the prior ledger hash is not hex".to_string());
}
let touched = self.touched_paths();
if self.journal.touched != touched {
return Err("the journal's touched set is not this batch's".to_string());
}
let record_paths: Vec<&str> = self
.record
.touched
.iter()
.map(|e| e.path.as_str())
.collect();
if record_paths != touched.iter().map(String::as_str).collect::<Vec<_>>() {
return Err("the record's touched set is not this batch's".to_string());
}
for entry in &self.record.touched {
let (pre, post): (Option<&str>, Option<String>) = if entry.path == LEDGER_KEY {
(
self.ledger_pre.as_deref(),
self.ledger_new.as_deref().map(sha256_hex),
)
} else if let Some(w) = self.writes.iter().find(|w| w.path.as_str() == entry.path) {
(w.pre.as_deref(), Some(w.sha256.clone()))
} else if let Some(d) = self.deletes.iter().find(|d| d.path.as_str() == entry.path) {
(Some(d.pre.as_str()), None)
} else {
return Err("the record names a path outside the batch".to_string());
};
if entry.pre_sha256.as_deref() != pre || entry.post_sha256 != post {
return Err(format!(
"the record's hashes for {} are not this batch's",
super::journal::shown(&entry.path)
));
}
}
for phase in PRERENDERED_PHASES {
let journal = self.journal_at(phase);
Journal::parse("journal", &journal.to_bytes(), &self.gateway, &self.mode)
.map_err(|e| e.to_string())?;
}
RunRecord::parse("record", &self.record.to_bytes(), &self.gateway, &self.mode)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn journal_at(&self, phase: JournalPhase) -> Journal {
Journal {
phase,
..self.journal.clone()
}
}
}
pub const PRERENDERED_PHASES: [JournalPhase; 4] = [
JournalPhase::Snapshotting,
JournalPhase::Ready,
JournalPhase::Writing,
JournalPhase::Written,
];
pub fn restoring_journal(record: &RunRecord) -> Journal {
Journal {
schema_version: record.schema_version,
run_id: record.run_id.clone(),
gateway: record.gateway.clone(),
mode: record.mode.clone(),
phase: JournalPhase::Restoring,
pre_fingerprint: record.pre_fingerprint.clone(),
touched: record.touched.iter().map(|e| e.path.clone()).collect(),
}
}
fn is_hex64(text: &str) -> bool {
text.len() == 64
&& text
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn sorted_unique<'a>(paths: impl Iterator<Item = &'a str>, what: &str) -> Result<(), String> {
let mut previous: Option<&str> = None;
for path in paths {
if previous.is_some_and(|p| p.as_bytes() >= path.as_bytes()) {
return Err(format!("{what} are not byte-sorted and unique"));
}
previous = Some(path);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prepared {
run_id: RunId,
staging: String,
work_sha256: String,
}
impl Prepared {
pub fn new(run_id: RunId, staging: String, work_sha256: String) -> Self {
Prepared {
run_id,
staging,
work_sha256,
}
}
pub fn run_id(&self) -> &RunId {
&self.run_id
}
pub fn staging(&self) -> &str {
&self.staging
}
pub fn work_sha256(&self) -> &str {
&self.work_sha256
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchReport {
pub phase: JournalPhase,
pub stale: bool,
pub moved: Vec<usize>,
pub deleted: Vec<usize>,
pub removed_dirs: Vec<String>,
pub failure: Option<String>,
pub deadline: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeReport {
pub writable: bool,
pub problems: Vec<String>,
}
#[async_trait::async_trait]
pub trait ApplyHost: Send + Sync {
async fn probe(&self) -> Result<ProbeReport, CoreError>;
async fn probe_paths(&self, touched: &[String]) -> Result<ProbeReport, CoreError> {
let _ = touched;
self.probe().await
}
async fn prepare(&self, spec: &BatchSpec, tree: &RenderedTree) -> Result<Prepared, CoreError>;
async fn commit(&self, prepared: &Prepared) -> Result<BatchReport, CoreError>;
async fn restore(&self, record: &RunRecord) -> Result<BatchReport, CoreError>;
async fn fingerprint(
&self,
paths: &[String],
) -> Result<BTreeMap<String, Option<String>>, CoreError>;
async fn finalize(&self, record: &RunRecord) -> Result<(), CoreError>;
async fn read_record(&self, run_id: &RunId) -> Result<Option<RunRecord>, CoreError>;
async fn fetch_snapshot(
&self,
run_id: &RunId,
dest: &Path,
) -> Result<BTreeMap<String, String>, CoreError>;
async fn discard_interrupted(&self, run_id: &RunId) -> Result<(), CoreError>;
}