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 observed(&self, name: &str) -> impl Future<Output = Result<Option<Observed>>> + Send;
|
||||||
fn healthy(&self, name: &str) -> impl Future<Output = Result<bool>> + 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 {
|
fn simple(value: &str) -> bool {
|
||||||
!value.is_empty()
|
!value.is_empty()
|
||||||
&& value.len() <= 128
|
&& value.len() <= 128
|
||||||
@@ -151,6 +259,122 @@ pub(crate) fn pin_body(body: &str, name: &str, image: &str) -> Result<String> {
|
|||||||
);
|
);
|
||||||
Ok(output)
|
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> {
|
fn root(guard: &Guard) -> Result<PathBuf> {
|
||||||
let path = guard.directory().join("supervised");
|
let path = guard.directory().join("supervised");
|
||||||
match std::fs::DirBuilder::new().mode(0o700).create(&path) {
|
match std::fs::DirBuilder::new().mode(0o700).create(&path) {
|
||||||
@@ -672,6 +896,42 @@ mod tests {
|
|||||||
Ok(true)
|
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]
|
#[tokio::test]
|
||||||
async fn snapshot_failure_never_stops_or_recreates_original_runtime() {
|
async fn snapshot_failure_never_stops_or_recreates_original_runtime() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user