Plan reviewed Quadlet migrations and preserve private writable-layer snapshots
This commit is contained in:
@@ -97,6 +97,114 @@ pub(crate) trait Supervisor: Sync {
|
||||
fn observed(&self, name: &str) -> impl Future<Output = Result<Option<Observed>>> + Send;
|
||||
fn healthy(&self, name: &str) -> impl Future<Output = Result<bool>> + Send;
|
||||
}
|
||||
/// Local recovery images are never pushed or exported. A matching tag may be
|
||||
/// reused after a lost commit response only when image labels bind the exact
|
||||
/// original container and transaction. Mounted data is deliberately excluded.
|
||||
pub(crate) async fn capture_local_recovery_image(
|
||||
original: &Unit,
|
||||
operation: &str,
|
||||
tag: &str,
|
||||
) -> Result<RecoveryImage> {
|
||||
use super::update_transaction::{Podman, Runtime};
|
||||
anyhow::ensure!(
|
||||
digest(&original.container_id)
|
||||
&& uuid::Uuid::parse_str(operation)?.to_string() == operation
|
||||
&& tag.starts_with(&format!("localhost/archy-update-recovery:{operation}-"))
|
||||
&& tag
|
||||
.rsplit('-')
|
||||
.next()
|
||||
.is_some_and(|v| v.parse::<usize>().is_ok()),
|
||||
"Invalid local recovery image ownership"
|
||||
);
|
||||
async fn command(args: &[&str]) -> Result<std::process::Output> {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(300),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(args)
|
||||
.kill_on_drop(true)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.context("Recovery image operation timed out; original runtime retained")?
|
||||
.context("Recovery image runtime unavailable")
|
||||
}
|
||||
async fn inspect(tag: &str, original: &Unit, operation: &str) -> Result<RecoveryImage> {
|
||||
let result = command(&["image", "inspect", tag]).await?;
|
||||
anyhow::ensure!(
|
||||
result.status.success(),
|
||||
"Local recovery image inspection failed"
|
||||
);
|
||||
let rows: Vec<serde_json::Value> = serde_json::from_slice(&result.stdout)?;
|
||||
anyhow::ensure!(rows.len() == 1, "Ambiguous recovery image");
|
||||
let row = &rows[0];
|
||||
let labels = row
|
||||
.pointer("/Config/Labels")
|
||||
.context("Recovery image has no ownership labels")?;
|
||||
anyhow::ensure!(
|
||||
labels
|
||||
.get("io.archipelago.recovery.operation")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some(operation)
|
||||
&& labels
|
||||
.get("io.archipelago.recovery.container")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some(original.container_id.as_str()),
|
||||
"Existing recovery image belongs to another operation"
|
||||
);
|
||||
let image = row
|
||||
.get("Id")
|
||||
.or_else(|| row.get("ID"))
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Recovery image identity missing")?
|
||||
.trim_start_matches("sha256:")
|
||||
.to_string();
|
||||
anyhow::ensure!(digest(&image), "Invalid recovery image digest");
|
||||
Ok(RecoveryImage {
|
||||
image,
|
||||
source_container_id: original.container_id.clone(),
|
||||
operation_id: operation.into(),
|
||||
})
|
||||
}
|
||||
let current = Podman
|
||||
.inspect(&original.name)
|
||||
.await?
|
||||
.context("Original container missing before snapshot")?;
|
||||
anyhow::ensure!(
|
||||
current.id == original.container_id
|
||||
&& current.image == original.image
|
||||
&& current.running == original.running
|
||||
&& current.config_sha256 == original.config_sha256,
|
||||
"Original runtime changed before writable-layer snapshot"
|
||||
);
|
||||
let exists = command(&["image", "exists", tag]).await?;
|
||||
match exists.status.code() {
|
||||
Some(0) => return inspect(tag, original, operation).await,
|
||||
Some(1) => {}
|
||||
_ => anyhow::bail!("Recovery image inventory unavailable"),
|
||||
}
|
||||
let operation_label = format!("LABEL io.archipelago.recovery.operation={operation}");
|
||||
let container_label = format!(
|
||||
"LABEL io.archipelago.recovery.container={}",
|
||||
original.container_id
|
||||
);
|
||||
let result = command(&[
|
||||
"commit",
|
||||
"--pause=true",
|
||||
"--include-volumes=false",
|
||||
"--change",
|
||||
&operation_label,
|
||||
"--change",
|
||||
&container_label,
|
||||
&original.container_id,
|
||||
tag,
|
||||
])
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
result.status.success(),
|
||||
"Writable-layer snapshot failed; original runtime retained"
|
||||
);
|
||||
inspect(tag, original, operation).await
|
||||
}
|
||||
fn simple(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
@@ -151,6 +259,122 @@ pub(crate) fn pin_body(body: &str, name: &str, image: &str) -> Result<String> {
|
||||
);
|
||||
Ok(output)
|
||||
}
|
||||
/// Three-way configuration migration: the previous renderer output must come
|
||||
/// from the installed immutable manifest, never today's mutable catalog. Keep
|
||||
/// unrelated operator overrides; a conflicting required change is a preflight
|
||||
/// error rather than an overwrite. Values are never included in errors.
|
||||
pub(crate) fn merge_reviewed_unit(original: &str, previous: &str, next: &str) -> Result<String> {
|
||||
type Key = (String, String);
|
||||
fn parse(body: &str) -> Result<(Vec<Key>, std::collections::BTreeMap<Key, Vec<String>>)> {
|
||||
anyhow::ensure!(body.len() <= 1024 * 1024, "Unit exceeds migration limit");
|
||||
let mut section = String::new();
|
||||
let mut sections = std::collections::HashSet::new();
|
||||
let mut order = Vec::new();
|
||||
let mut values = std::collections::BTreeMap::<Key, Vec<String>>::new();
|
||||
for raw in body.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
!line.ends_with('\\') && !line.starts_with(".include"),
|
||||
"Continued or included unit cannot be migrated automatically"
|
||||
);
|
||||
if line.starts_with('[') {
|
||||
anyhow::ensure!(
|
||||
line.ends_with(']') && sections.insert(line.to_string()),
|
||||
"Repeated or malformed unit section"
|
||||
);
|
||||
section = line.into();
|
||||
continue;
|
||||
}
|
||||
let (directive, value) = line.split_once('=').context("Malformed unit directive")?;
|
||||
anyhow::ensure!(
|
||||
!section.is_empty()
|
||||
&& !value.is_empty()
|
||||
&& directive.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
&& !matches!(directive, "EnvironmentFile" | "EnvFile"),
|
||||
"Unsupported unit reset or external configuration"
|
||||
);
|
||||
let key = if directive == "Environment" {
|
||||
let env = value.strip_prefix('"').unwrap_or(value);
|
||||
let (name, _) = env
|
||||
.split_once('=')
|
||||
.context("Unsupported environment directive")?;
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
&& (value.starts_with('"') && value.ends_with('"')
|
||||
|| !value.contains(char::is_whitespace)),
|
||||
"Ambiguous environment directive"
|
||||
);
|
||||
format!("Environment:{name}")
|
||||
} else {
|
||||
directive.into()
|
||||
};
|
||||
let key = (section.clone(), key);
|
||||
if !values.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
values.entry(key).or_default().push(raw.to_string());
|
||||
}
|
||||
Ok((order, values))
|
||||
}
|
||||
let (mut order, original) = parse(original)?;
|
||||
let (_, previous) = parse(previous)?;
|
||||
let (next_order, next) = parse(next)?;
|
||||
let mut merged = original.clone();
|
||||
let keys: std::collections::BTreeSet<_> = previous.keys().chain(next.keys()).cloned().collect();
|
||||
for key in keys {
|
||||
let old = previous.get(&key);
|
||||
let wanted = next.get(&key);
|
||||
if old == wanted {
|
||||
continue;
|
||||
}
|
||||
let actual = original.get(&key);
|
||||
anyhow::ensure!(
|
||||
actual == old || actual == wanted,
|
||||
"Required unit configuration conflicts with an operator override in {} {}",
|
||||
key.0,
|
||||
key.1
|
||||
);
|
||||
match wanted {
|
||||
Some(lines) => {
|
||||
merged.insert(key, lines.clone());
|
||||
}
|
||||
None => {
|
||||
merged.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in next_order {
|
||||
if !order.contains(&key) {
|
||||
order.push(key);
|
||||
}
|
||||
}
|
||||
// Group sections once; preserve directive and repeated-value order within
|
||||
// each section, including operator-only directives absent from both plans.
|
||||
let mut sections = Vec::<String>::new();
|
||||
for (section, _) in &order {
|
||||
if !sections.contains(section) {
|
||||
sections.push(section.clone());
|
||||
}
|
||||
}
|
||||
let mut output = String::new();
|
||||
for section in sections {
|
||||
output.push_str(§ion);
|
||||
output.push('\n');
|
||||
for key in order.iter().filter(|key| key.0 == section) {
|
||||
if let Some(lines) = merged.get(key) {
|
||||
for line in lines {
|
||||
output.push_str(line);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
fn root(guard: &Guard) -> Result<PathBuf> {
|
||||
let path = guard.directory().join("supervised");
|
||||
match std::fs::DirBuilder::new().mode(0o700).create(&path) {
|
||||
@@ -672,6 +896,42 @@ mod tests {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn reviewed_migration_keeps_unrelated_operator_values_and_applies_required_new_fields() {
|
||||
let old = "[Container]\nImage=old\nEnvironment=FEATURE=old\nEnvironment=PORT=1\n[Service]\nRestart=always\n";
|
||||
let actual = old
|
||||
.replace("PORT=1", "PORT=42")
|
||||
.replace("[Service]", "Environment=OPERATOR=mine\n[Service]");
|
||||
let next = old
|
||||
.replace("Image=old", "Image=new")
|
||||
.replace("FEATURE=old", "FEATURE=new")
|
||||
.replace("[Service]", "Volume=/identity:/run/identity:ro\n[Service]");
|
||||
let merged = merge_reviewed_unit(&actual, old, &next).unwrap();
|
||||
assert!(merged.contains("Image=new"));
|
||||
assert!(merged.contains("FEATURE=new"));
|
||||
assert!(merged.contains("PORT=42"));
|
||||
assert!(merged.contains("OPERATOR=mine"));
|
||||
assert!(merged.contains("Volume=/identity:/run/identity:ro"));
|
||||
assert_eq!(merge_reviewed_unit(&merged, old, &next).unwrap(), merged);
|
||||
}
|
||||
#[test]
|
||||
fn conflicting_required_environment_or_mount_migration_is_rejected_before_lifecycle() {
|
||||
let old = "[Container]\nEnvironment=FEATURE=old\nVolume=/old:/data\n";
|
||||
assert!(merge_reviewed_unit(
|
||||
&old.replace("FEATURE=old", "FEATURE=operator"),
|
||||
old,
|
||||
&old.replace("FEATURE=old", "FEATURE=new")
|
||||
)
|
||||
.is_err());
|
||||
assert!(merge_reviewed_unit(
|
||||
&old.replace("/old:/data", "/operator:/data"),
|
||||
old,
|
||||
&old.replace("/old:/data", "/required:/data")
|
||||
)
|
||||
.is_err());
|
||||
assert!(merge_reviewed_unit("[Container]\nEnvironment=\n", old, old).is_err());
|
||||
assert!(merge_reviewed_unit("[Container]\nEnvironment=A=1 B=2\n", old, old).is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn snapshot_failure_never_stops_or_recreates_original_runtime() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user