//! validated, atomic generation activation (packet §16). use std::path::Path; use std::sync::Arc; use anyhow::Context as _; use crate::config::ValidatedConfig; use crate::manifest::GenerationManifest; fn rollback_active_conf(previous: Option<&[u8]>, active_conf: &Path) { if let Some(bytes) = previous { let tmp = active_conf.with_extension(format!("rollback.tmp.{}", std::process::id())); if std::fs::write(&tmp, bytes).is_ok() { let _ = std::fs::rename(&tmp, active_conf); } } else { let _ = std::fs::remove_file(active_conf); } } #[derive(Debug, Clone)] pub struct ActivationResult { pub generation: String, pub changed_repos: Vec, } pub async fn activate(config: Arc, generation: &str) -> anyhow::Result { let _output_lock = crate::output::lock(&config.output_root)?; anyhow::ensure!( generation.len() == 64 && generation.bytes().all(|b| b.is_ascii_hexdigit()), "generation must be a 64-character hexadecimal id" ); let generation_dir = config.output_root.join("generations").join(generation); anyhow::ensure!( generation_dir.exists(), "generation directory does not exist: {}", generation_dir.display() ); let manifest = GenerationManifest::load(&generation_dir) .with_context(|| format!("load manifest from {}", generation_dir.display()))?; anyhow::ensure!( manifest.generation_id == generation, "manifest generation_id ({}) does not match requested generation ({generation})", manifest.generation_id ); let computed_id = manifest.compute_id(); anyhow::ensure!( computed_id == manifest.generation_id, "manifest generation_id ({}) does not match computed semantic hash ({computed_id})", manifest.generation_id ); // Verify referenced CAS objects exist (routes only; git_export_hash is // an input fingerprint, its bytes live in the generation www/_git tree) let cas = crate::cas::Cas::open(config.output_root.join("cas")); for response in manifest.routes.values() { let digest = crate::cas::Digest::from_hex(&response.body_hash) .with_context(|| format!("invalid CAS hex hash: {}", response.body_hash))?; anyhow::ensure!( cas.has(&digest), "missing referenced CAS object: {}", response.body_hash ); } for (index, _) in manifest .repos .iter() .enumerate() .filter(|(_, repo)| repo.git_export_hash.is_some()) { let export = generation_dir .join("www/_git") .join(format!("r_{index:03}")); anyhow::ensure!( export.exists(), "missing git export for r_{index:03} in generation" ); } let responses_dir = generation_dir.join("www/_responses"); for (route_key, response) in &manifest.routes { let hex = &response.body_hash; let sharded = (hex.len() >= 4).then(|| responses_dir.join(&hex[..2]).join(&hex[2..])); let flat = responses_dir.join(hex); let exists = sharded.as_ref().is_some_and(|p| p.exists()) || flat.exists(); anyhow::ensure!( exists, "missing www response file for route '{route_key}' (body hash: {hex})" ); } let nginx_conf = generation_dir.join("nginx/knot-static.conf"); anyhow::ensure!( nginx_conf.is_file(), "generation is missing its nginx configuration; regenerate it" ); let active_conf = config.output_root.join("active.conf"); let previous_active = active_conf .exists() .then(|| std::fs::read(&active_conf).context("read existing active.conf")) .transpose()?; let previous_manifest = std::fs::read_link(config.output_root.join("current")) .ok() .and_then(|link| { let id = link.file_name()?.to_str()?; GenerationManifest::load(&config.output_root.join("generations").join(id)).ok() }); let nginx_conf_canon = std::fs::canonicalize(&nginx_conf).unwrap_or(nginx_conf); let pid_path = config.output_root.join("runtime/nginx.pid"); let new_conf_content = format!( "pid {};\ninclude {};\n", pid_path.display(), nginx_conf_canon.display() ); let tmp_active = config .output_root .join(format!("active.conf.tmp.{}", std::process::id())); std::fs::write(&tmp_active, &new_conf_content) .with_context(|| format!("write tmp active.conf: {}", tmp_active.display()))?; std::fs::rename(&tmp_active, &active_conf) .with_context(|| format!("rename to active.conf: {}", active_conf.display()))?; let test_res = std::process::Command::new(&config.activate.nginx_binary) .arg("-t") .arg("-c") .arg(&active_conf) .output(); match test_res { Ok(output) if !output.status.success() => { rollback_active_conf(previous_active.as_deref(), &active_conf); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); anyhow::bail!( "nginx configuration test failed (-t):\nstderr: {}\nstdout: {}", stderr.trim(), stdout.trim() ); } Err(err) => { rollback_active_conf(previous_active.as_deref(), &active_conf); anyhow::bail!( "failed to execute nginx binary '{}': {err}", config.activate.nginx_binary ); } _ => {} } if let Some(reload_cmd) = &config.activate.reload_command { let reload_res = std::process::Command::new("sh") .arg("-c") .arg(reload_cmd) .output(); match reload_res { Ok(output) if !output.status.success() => { rollback_active_conf(previous_active.as_deref(), &active_conf); let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); anyhow::bail!( "nginx reload command failed:\nstderr: {}\nstdout: {}", stderr.trim(), stdout.trim() ); } Err(err) => { rollback_active_conf(previous_active.as_deref(), &active_conf); anyhow::bail!("failed to execute reload command '{reload_cmd}': {err}"); } _ => {} } } let current_link = config.output_root.join("current"); let tmp_link = config .output_root .join(format!("current.tmp.{}", std::process::id())); let _ = std::fs::remove_file(&tmp_link); let rel_target = Path::new("generations").join(generation); #[cfg(unix)] { std::os::unix::fs::symlink(&rel_target, &tmp_link).with_context(|| { format!("symlink {} -> {}", tmp_link.display(), rel_target.display()) })?; std::fs::rename(&tmp_link, ¤t_link) .with_context(|| format!("rename symlink to {}", current_link.display()))?; } // fingerprint changed → repos whose records and mirrors need updating let previous_fingerprints: std::collections::HashMap<&str, &str> = previous_manifest .as_ref() .map(|m| { m.repos .iter() .map(|repo| (repo.repo_did.as_str(), repo.fingerprint.as_str())) .collect() }) .unwrap_or_default(); let changed_repos: Vec = manifest .repos .iter() .filter(|repo| { previous_fingerprints.get(repo.repo_did.as_str()) != Some(&repo.fingerprint.as_str()) }) .map(|repo| repo.repo_did.clone()) .collect(); // GC is mark-and-sweep across retained manifests; run offline via `knot-static gc`, not on every activation. eprintln!( "activated generation {} (repos: {}, routes: {})", generation, manifest.repos.len(), manifest.routes.len() ); Ok(ActivationResult { generation: generation.to_string(), changed_repos, }) } /// post-activation network tasks: mirror resync pings + PDS record publishing. /// these run concurrently and independently of the generation switch. pub async fn post_activate(config: Arc, changed_repos: &[String]) { if changed_repos.is_empty() { return; } let repo_dids: Vec = changed_repos .iter() .filter_map(|r| knot_types::RepoDid::new(r.clone()).ok()) .collect(); let mirror_fut = async { let triggers = crate::mirror::triggers_from_config(&config); crate::mirror::trigger_all(&triggers, &repo_dids).await; }; let publish_fut = async { if !config.publish.is_empty() { crate::pds::publish_all(config.clone(), changed_repos).await; } }; tokio::join!(mirror_fut, publish_fut); } pub async fn run(config: Arc, generation: &str) -> anyhow::Result<()> { let result = activate(config.clone(), generation).await?; post_activate(config, &result.changed_repos).await; Ok(()) }