use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::runtime;
pub const MIGRATIONS_DIR_NAME: &str = "systemg-migrations";
pub const JOURNAL_FILE_NAME: &str = "journal.json";
pub const JOURNAL_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Planned,
Archived,
Staged,
DataPublished,
RegistryPublished,
Complete,
}
impl Phase {
pub fn is_complete(self) -> bool {
self == Self::Complete
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceRecord {
pub path: String,
pub len: u64,
pub sha256: String,
pub archive_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputRecord {
pub stage_path: String,
pub target_path: String,
pub sha256: String,
pub project_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuarantineRecord {
pub kind: String,
pub name: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationJournal {
pub schema: u32,
pub id: String,
pub phase: Phase,
pub state_dir: String,
pub log_dir: String,
pub path_to_id: BTreeMap<String, String>,
pub sources: BTreeMap<String, SourceRecord>,
pub outputs: BTreeMap<String, OutputRecord>,
pub quarantined: Vec<QuarantineRecord>,
}
impl MigrationJournal {
pub fn new(id: impl Into<String>, state_dir: &Path, log_dir: &Path) -> Self {
Self {
schema: JOURNAL_SCHEMA,
id: id.into(),
phase: Phase::Planned,
state_dir: state_dir.to_string_lossy().to_string(),
log_dir: log_dir.to_string_lossy().to_string(),
path_to_id: BTreeMap::new(),
sources: BTreeMap::new(),
outputs: BTreeMap::new(),
quarantined: Vec::new(),
}
}
pub fn advance(&mut self, phase: Phase, path: &Path) -> io::Result<()> {
self.phase = phase;
self.save_to(path)
}
pub fn load_from(path: &Path) -> io::Result<Option<Self>> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let journal: Self = serde_json::from_str(&raw)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
if journal.schema != JOURNAL_SCHEMA {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"migration journal schema v{} is not supported (expected v{JOURNAL_SCHEMA})",
journal.schema
),
));
}
Ok(Some(journal))
}
pub fn save_to(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
runtime::create_private_dir(parent)?;
}
let mut bytes = serde_json::to_vec_pretty(self)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
bytes.push(b'\n');
let temp = path.with_file_name(format!(
".{}.{}.tmp",
path.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| JOURNAL_FILE_NAME.to_string()),
std::process::id()
));
runtime::write_private_file(&temp, &bytes)?;
match std::fs::rename(&temp, path) {
Ok(()) => Ok(()),
Err(err) => {
let _ = std::fs::remove_file(&temp);
Err(err)
}
}
}
}
pub fn migrations_dir(state_dir: &Path) -> PathBuf {
state_dir
.parent()
.map(|parent| parent.join(MIGRATIONS_DIR_NAME))
.unwrap_or_else(|| PathBuf::from(MIGRATIONS_DIR_NAME))
}
pub fn journal_path(state_dir: &Path) -> PathBuf {
migrations_dir(state_dir).join(JOURNAL_FILE_NAME)
}
pub fn pending_journal(state_dir: &Path) -> io::Result<Option<MigrationJournal>> {
Ok(MigrationJournal::load_from(&journal_path(state_dir))?
.filter(|journal| !journal.phase.is_complete()))
}
pub fn file_digest(path: &Path) -> io::Result<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path)?;
let digest = Sha256::digest(&bytes);
Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phases_order_from_planned_to_complete() {
assert!(Phase::Planned < Phase::Archived);
assert!(Phase::Archived < Phase::Staged);
assert!(Phase::Staged < Phase::DataPublished);
assert!(Phase::DataPublished < Phase::RegistryPublished);
assert!(Phase::RegistryPublished < Phase::Complete);
assert!(Phase::Complete.is_complete());
assert!(!Phase::Staged.is_complete());
}
#[test]
fn a_journal_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(JOURNAL_FILE_NAME);
let mut journal =
MigrationJournal::new("run-1", Path::new("/state"), Path::new("/logs"));
journal
.path_to_id
.insert("/units/a.yaml".into(), "a-abcd".into());
journal.save_to(&path).unwrap();
let loaded = MigrationJournal::load_from(&path).unwrap().unwrap();
assert_eq!(loaded.id, "run-1");
assert_eq!(loaded.phase, Phase::Planned);
assert_eq!(loaded.path_to_id.get("/units/a.yaml").unwrap(), "a-abcd");
}
#[test]
fn an_absent_journal_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(
MigrationJournal::load_from(&dir.path().join("missing.json"))
.unwrap()
.is_none()
);
}
#[test]
fn a_malformed_journal_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(JOURNAL_FILE_NAME);
std::fs::write(&path, b"{ not json").unwrap();
assert!(MigrationJournal::load_from(&path).is_err());
}
#[test]
fn an_incomplete_journal_is_pending_and_a_complete_one_is_not() {
let dir = tempfile::tempdir().unwrap();
let state_dir = dir.path().join("state");
std::fs::create_dir_all(&state_dir).unwrap();
let path = journal_path(&state_dir);
let mut journal = MigrationJournal::new("run-1", &state_dir, Path::new("/logs"));
journal.advance(Phase::Staged, &path).unwrap();
assert!(pending_journal(&state_dir).unwrap().is_some());
journal.advance(Phase::Complete, &path).unwrap();
assert!(pending_journal(&state_dir).unwrap().is_none());
}
#[test]
fn the_migrations_dir_sits_outside_the_state_root() {
let migrations = migrations_dir(Path::new("/home/u/.local/share/systemg"));
assert!(!migrations.starts_with("/home/u/.local/share/systemg"));
assert!(migrations.ends_with(MIGRATIONS_DIR_NAME));
}
#[test]
fn digests_distinguish_contents() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::write(&a, b"alpha").unwrap();
std::fs::write(&b, b"beta").unwrap();
assert_ne!(file_digest(&a).unwrap(), file_digest(&b).unwrap());
assert_eq!(file_digest(&a).unwrap(), file_digest(&a).unwrap());
}
}