use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::compose::env_key_looks_secret;
use crate::error::{DeployError, Result};
use crate::failure::{classify_deploy_error, ClassifiedDeployOutcome, DeployFailureClass};
use crate::types::DeployPlan;
pub const DEPLOY_SECRET_PLACEHOLDER_PREFIX: &str = "xbp:deploy:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: String,
pub status: String,
pub path: String,
pub timestamp: DateTime<Utc>,
pub env: String,
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeployHistoryIndex {
pub entries: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployHistoryRecord {
pub id: String,
pub timestamp: DateTime<Utc>,
pub env: String,
pub target: String,
pub target_kind: String,
pub status: String,
pub services: Vec<String>,
pub git_sha: Option<String>,
pub project_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub xbp_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_env_redacted: Option<bool>,
pub digests: BTreeMap<String, String>,
pub kubernetes_context: Option<String>,
pub namespace: Option<String>,
pub summary: String,
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destination: Option<String>,
pub plan: DeployPlan,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeployHistorySecret {
pub name: String,
pub value: String,
pub hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SanitizedHistoryRecord {
pub record: DeployHistoryRecord,
pub secrets: Vec<DeployHistorySecret>,
}
pub struct DeployHistoryStore {
pub dir: PathBuf,
}
impl DeployHistoryStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn index_path(&self) -> PathBuf {
self.dir.join("index.json")
}
pub fn load_index(&self) -> Result<DeployHistoryIndex> {
let path = self.index_path();
if !path.exists() {
return Ok(DeployHistoryIndex::default());
}
let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
if raw.contains("<<<<<<<") || raw.contains(">>>>>>>") {
return self.rebuild_index_from_records();
}
match parse_index_raw(&raw) {
Ok(index) => Ok(index),
Err(_) => {
self.rebuild_index_from_records()
}
}
}
pub fn rebuild_index_from_records(&self) -> Result<DeployHistoryIndex> {
let mut entries = self.scan_record_entries()?;
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
entries.truncate(200);
let index = DeployHistoryIndex { entries };
self.write_index_atomic(&index)?;
Ok(index)
}
pub fn scan_record_entries(&self) -> Result<Vec<HistoryEntry>> {
if !self.dir.is_dir() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
let rd = std::fs::read_dir(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
for ent in rd {
let ent = ent.map_err(|e| DeployError::Io(e.to_string()))?;
let path = ent.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name == "index.json" || !name.ends_with(".json") {
continue;
}
if name.contains(".revitalized.") {
continue;
}
let raw = match std::fs::read_to_string(&path) {
Ok(r) => r,
Err(_) => continue,
};
let record: DeployHistoryRecord = match serde_json::from_str(&raw) {
Ok(r) => r,
Err(_) => continue,
};
entries.push(history_entry_from_record(&record, &name));
}
Ok(entries)
}
fn write_index_atomic(&self, index: &DeployHistoryIndex) -> Result<()> {
std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
let index_body =
serde_json::to_string_pretty(index).map_err(|e| DeployError::Io(e.to_string()))?;
let final_path = self.index_path();
let tmp_path = self.dir.join("index.json.tmp");
std::fs::write(&tmp_path, index_body).map_err(|e| DeployError::Io(e.to_string()))?;
if final_path.exists() {
let _ = std::fs::remove_file(&final_path);
}
std::fs::rename(&tmp_path, &final_path).map_err(|e| DeployError::Io(e.to_string()))?;
Ok(())
}
pub fn write_record(&self, record: &DeployHistoryRecord) -> Result<PathBuf> {
std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
let file_name = format!("{}.json", record.id);
let path = self.dir.join(&file_name);
let body =
serde_json::to_string_pretty(record).map_err(|e| DeployError::Io(e.to_string()))?;
let tmp = self.dir.join(format!("{}.json.tmp", record.id));
std::fs::write(&tmp, &body).map_err(|e| DeployError::Io(e.to_string()))?;
if path.exists() {
let _ = std::fs::remove_file(&path);
}
std::fs::rename(&tmp, &path).map_err(|e| DeployError::Io(e.to_string()))?;
let mut index = self.load_index().unwrap_or_default();
index.entries.retain(|e| e.id != record.id);
index
.entries
.insert(0, history_entry_from_record(record, &file_name));
index.entries.truncate(200);
self.write_index_atomic(&index)?;
Ok(path)
}
pub fn stats(&self, target: &str, env: &str, limit: usize) -> Result<DeployHistoryStats> {
let entries = self.list(target, env, limit.max(1))?;
let mut by_code: BTreeMap<String, usize> = BTreeMap::new();
let mut by_target: BTreeMap<String, usize> = BTreeMap::new();
let mut success = 0usize;
let mut failed = 0usize;
for entry in &entries {
*by_target.entry(entry.target.clone()).or_default() += 1;
if entry.status == "success" {
success += 1;
*by_code
.entry(DeployFailureClass::Success.as_str().into())
.or_default() += 1;
continue;
}
failed += 1;
let code = if let Some(c) = &entry.error_code {
c.clone()
} else if let Ok(Some(rec)) = self.load_record(entry) {
rec.error_code
.or_else(|| {
let c = classify_deploy_error(
rec.error.as_deref(),
&rec.summary,
false,
);
Some(c.error_code.as_str().into())
})
.unwrap_or_else(|| DeployFailureClass::Unknown.as_str().into())
} else {
DeployFailureClass::Unknown.as_str().into()
};
*by_code.entry(code).or_default() += 1;
}
Ok(DeployHistoryStats {
total: entries.len(),
success,
failed,
by_error_code: by_code,
by_target,
})
}
pub fn latest_status(&self, target: &str, env: &str) -> Result<Option<HistoryEntry>> {
let index = self.load_index()?;
Ok(index
.entries
.into_iter()
.find(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env)))
}
pub fn list(&self, target: &str, env: &str, limit: usize) -> Result<Vec<HistoryEntry>> {
let index = self.load_index()?;
Ok(index
.entries
.into_iter()
.filter(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env))
.take(limit)
.collect())
}
pub fn load_record(&self, entry: &HistoryEntry) -> Result<Option<DeployHistoryRecord>> {
let candidates = [
self.dir.join(&entry.path),
self.dir.join(format!("{}.json", entry.id)),
];
for path in candidates {
if !path.is_file() {
continue;
}
let raw =
std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
let record: DeployHistoryRecord =
serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
return Ok(Some(record));
}
Ok(None)
}
pub fn load_record_by_id(&self, id: &str) -> Result<Option<DeployHistoryRecord>> {
let id = id.trim().trim_end_matches(".json");
if id.is_empty() {
return Ok(None);
}
let path = self.dir.join(format!("{id}.json"));
if !path.is_file() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
let record: DeployHistoryRecord =
serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
Ok(Some(record))
}
pub fn record_path(&self, id: &str) -> PathBuf {
let id = id.trim().trim_end_matches(".json");
self.dir.join(format!("{id}.json"))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeployHistoryStats {
pub total: usize,
pub success: usize,
pub failed: usize,
pub by_error_code: BTreeMap<String, usize>,
pub by_target: BTreeMap<String, usize>,
}
fn history_entry_from_record(record: &DeployHistoryRecord, file_name: &str) -> HistoryEntry {
HistoryEntry {
id: record.id.clone(),
status: record.status.clone(),
path: file_name.to_string(),
timestamp: record.timestamp,
env: record.env.clone(),
target: record.target.clone(),
error_code: record.error_code.clone(),
phase: record.phase.clone(),
}
}
pub fn parse_index_raw(raw: &str) -> Result<DeployHistoryIndex> {
if raw.contains("<<<<<<<") || raw.contains(">>>>>>>") || raw.contains("=======") {
let cleaned: String = raw
.lines()
.filter(|line| {
let t = line.trim_start();
!(t.starts_with("<<<<<<<")
|| t.starts_with(">>>>>>>")
|| t.starts_with("======="))
})
.collect::<Vec<_>>()
.join("\n");
if let Ok(index) = serde_json::from_str::<DeployHistoryIndex>(&cleaned) {
return Ok(index);
}
return Err(DeployError::Io(
"deploy history index.json contains merge conflict markers".into(),
));
}
serde_json::from_str(raw).map_err(|e| DeployError::Io(e.to_string()))
}
pub fn make_record_id(env: &str, target: &str, ts: DateTime<Utc>) -> String {
let safe_target: String = target
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
format!("{}-{}-{}", ts.format("%Y%m%dT%H%M%SZ"), env, safe_target)
}
pub fn secret_value_hash(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
digest
.iter()
.flat_map(|b| [nibble(b >> 4), nibble(b & 0x0f)])
.take(12)
.collect()
}
fn nibble(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + (n - 10)) as char,
_ => '0',
}
}
pub fn deploy_secret_placeholder(deployment_id: &str, name: &str, hash: &str) -> String {
format!("{DEPLOY_SECRET_PLACEHOLDER_PREFIX}{deployment_id}:{name}:{hash}")
}
pub fn parse_deploy_secret_placeholder(value: &str) -> Option<(&str, &str, &str)> {
let rest = value.strip_prefix(DEPLOY_SECRET_PLACEHOLDER_PREFIX)?;
let (left, hash) = rest.rsplit_once(':')?;
let (deployment_id, name) = left.rsplit_once(':')?;
if deployment_id.is_empty() || name.is_empty() || hash.is_empty() {
return None;
}
Some((deployment_id, name, hash))
}
pub fn runtime_env_value_should_redact(key: &str, value: &str) -> bool {
if value.is_empty() {
return false;
}
if value.starts_with(DEPLOY_SECRET_PLACEHOLDER_PREFIX) {
return false; }
if env_key_looks_secret(key) {
return true;
}
let v = value.trim();
if v.contains("://") && v.contains('@') {
return true; }
if v.contains("://") && v.to_ascii_lowercase().contains("/webhooks/") {
return true;
}
if v.len() >= 24
&& v.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
{
return true;
}
false
}
pub fn redact_plan_runtime_env(
plan: &mut DeployPlan,
deployment_id: &str,
) -> Vec<DeployHistorySecret> {
let mut secrets: Vec<DeployHistorySecret> = Vec::new();
let mut seen: BTreeMap<String, String> = BTreeMap::new();
for svc in &mut plan.services {
let mut next_env = BTreeMap::new();
for (key, value) in std::mem::take(&mut svc.runtime_env) {
if runtime_env_value_should_redact(&key, &value) {
let hash = secret_value_hash(&value);
let placeholder = deploy_secret_placeholder(deployment_id, &key, &hash);
if let Some(prev_hash) = seen.get(&key) {
if prev_hash != &hash {
let scoped = format!("{}__{}", svc.name.replace('@', "_"), key);
let hash2 = secret_value_hash(&value);
let ph = deploy_secret_placeholder(deployment_id, &scoped, &hash2);
secrets.push(DeployHistorySecret {
name: scoped,
value: value.clone(),
hash: hash2,
service: Some(svc.name.clone()),
});
next_env.insert(key, ph);
continue;
}
} else {
seen.insert(key.clone(), hash.clone());
secrets.push(DeployHistorySecret {
name: key.clone(),
value: value.clone(),
hash: hash.clone(),
service: Some(svc.name.clone()),
});
}
next_env.insert(key, placeholder);
} else {
next_env.insert(key, value);
}
}
svc.runtime_env = next_env;
}
secrets
}
pub fn revitalize_record_runtime_env(
record: &mut DeployHistoryRecord,
secrets: &BTreeMap<String, String>,
) -> usize {
let mut filled = 0usize;
for svc in &mut record.plan.services {
for (_key, value) in svc.runtime_env.iter_mut() {
if let Some((dep_id, name, hash)) = parse_deploy_secret_placeholder(value) {
if dep_id != record.id {
continue;
}
if let Some(plain) = secrets.get(name) {
let actual = secret_value_hash(plain);
if actual == hash || hash.is_empty() {
*value = plain.clone();
filled += 1;
}
}
}
}
}
if filled > 0 {
record.runtime_env_redacted = Some(false);
}
filled
}
pub fn record_from_plan(
plan: &DeployPlan,
ok: bool,
summary: String,
error: Option<String>,
kube_context: Option<String>,
) -> SanitizedHistoryRecord {
record_from_plan_with_version(plan, ok, summary, error, kube_context, None)
}
pub fn record_from_plan_with_version(
plan: &DeployPlan,
ok: bool,
summary: String,
error: Option<String>,
kube_context: Option<String>,
xbp_version: Option<String>,
) -> SanitizedHistoryRecord {
let classified = classify_deploy_error(error.as_deref(), &summary, ok);
record_from_plan_with_classification(
plan,
ok,
summary,
error,
kube_context,
xbp_version,
classified,
)
}
pub fn record_from_plan_with_classification(
plan: &DeployPlan,
ok: bool,
summary: String,
error: Option<String>,
kube_context: Option<String>,
xbp_version: Option<String>,
classified: ClassifiedDeployOutcome,
) -> SanitizedHistoryRecord {
let ts = Utc::now();
let id = make_record_id(&plan.env, &plan.target.label(), ts);
let mut digests = BTreeMap::new();
for svc in &plan.services {
if let Some(d) = &svc.digest {
digests.insert(svc.name.clone(), d.clone());
}
}
let mut plan_for_disk = plan.clone();
let secrets = redact_plan_runtime_env(&mut plan_for_disk, &id);
let redacted = !secrets.is_empty();
let primary = plan.services.first();
let provider = primary.map(|s| s.provider.clone());
let destination = primary.and_then(|s| s.destination.clone());
let (error_code, phase, diagnostics) = if ok {
(
Some(DeployFailureClass::Success.as_str().to_string()),
None,
Vec::new(),
)
} else {
(
Some(classified.error_code.as_str().to_string()),
Some(classified.phase.as_str().to_string()),
classified.diagnostics,
)
};
let record = DeployHistoryRecord {
id,
timestamp: ts,
env: plan.env.clone(),
target: plan.target.label(),
target_kind: plan.target.kind_str().into(),
status: if ok {
"success".into()
} else {
"failed".into()
},
services: plan.order.clone(),
git_sha: plan.git_sha.clone(),
project_version: Some(plan.project_version.clone()),
xbp_version,
runtime_env_redacted: if redacted { Some(true) } else { None },
digests,
kubernetes_context: kube_context,
namespace: plan
.services
.first()
.and_then(|s| s.deploy.namespace.clone()),
summary,
error,
error_code,
phase,
diagnostics,
provider,
destination,
plan: plan_for_disk,
};
SanitizedHistoryRecord { record, secrets }
}
#[allow(dead_code)]
pub fn history_dir(root: &Path, rel: &Path) -> PathBuf {
root.join(rel)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
DeployTarget, K8sPlanView, OciPlan, ServiceDeployPlan, ServicePlan,
};
use std::collections::BTreeMap;
fn sample_plan_with_secret() -> DeployPlan {
let mut env = BTreeMap::new();
env.insert("PORT".into(), "5052".into());
env.insert("ATHENA_DEBUG_JWT_SECRET".into(), "super-secret-token-value".into());
env.insert(
"DATABASE_URL".into(),
"postgresql://user:pass@host:5432/db".into(),
);
DeployPlan {
target: DeployTarget::Service("athena".into()),
env: "production".into(),
project: "athena".into(),
project_version: "4.1.0".into(),
git_sha: Some("abc".into()),
services: vec![ServicePlan {
name: "athena".into(),
provider: "kubernetes".into(),
destination: Some("kubernetes".into()),
root_directory: None,
version: "4.1.0".into(),
image: None,
image_ref: None,
digest: None,
dockerfile: None,
build_context: None,
platforms: vec![],
worker_app: None,
rollout: None,
runtime_env: env,
container_port: Some(5052),
config_mounts: vec![],
expose: None,
deploy: ServiceDeployPlan {
namespace: Some("athena".into()),
workload: None,
service: None,
health: vec![],
manifest_paths: vec![],
crds_path: None,
install_path: None,
selector: None,
actions: vec![],
},
}],
order: vec!["athena@kubernetes".into()],
oci_plan: OciPlan::default(),
k8s_plan: K8sPlanView {
context: None,
default_namespace: Some("athena".into()),
services: vec![],
},
hash: "h".into(),
}
}
#[test]
fn redacts_discord_webhook_urls_even_without_secret_key_suffix() {
assert!(runtime_env_value_should_redact(
"DISCORD_WEBHOOK_URL",
"https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz",
));
assert!(runtime_env_value_should_redact(
"NOTIFY_HOOK",
"https://discordapp.com/api/webhooks/1/token-here",
));
assert!(!runtime_env_value_should_redact(
"DISCORD_WEBHOOK_URL",
"xbp:deploy:id:DISCORD_WEBHOOK_URL:abcdef012345",
));
}
#[test]
fn redacts_secrets_and_revitalizes() {
let plan = sample_plan_with_secret();
let sanitized = record_from_plan_with_version(
&plan,
true,
"ok".into(),
None,
None,
Some("10.51.1".into()),
);
assert_eq!(sanitized.record.xbp_version.as_deref(), Some("10.51.1"));
assert_eq!(sanitized.record.runtime_env_redacted, Some(true));
assert!(sanitized.secrets.len() >= 2);
let env = &sanitized.record.plan.services[0].runtime_env;
assert_eq!(env.get("PORT").map(String::as_str), Some("5052"));
let secret_ph = env.get("ATHENA_DEBUG_JWT_SECRET").unwrap();
assert!(secret_ph.starts_with("xbp:deploy:"));
assert!(parse_deploy_secret_placeholder(secret_ph).is_some());
let mut secrets_map = BTreeMap::new();
for s in &sanitized.secrets {
secrets_map.insert(s.name.clone(), s.value.clone());
}
let mut restored = sanitized.record.clone();
let n = revitalize_record_runtime_env(&mut restored, &secrets_map);
assert!(n >= 2);
assert_eq!(
restored.plan.services[0]
.runtime_env
.get("ATHENA_DEBUG_JWT_SECRET")
.map(String::as_str),
Some("super-secret-token-value")
);
}
#[test]
fn placeholder_roundtrip() {
let ph = deploy_secret_placeholder("20260722T052353Z-production-athena", "FOO", "abc123def456");
let (id, name, hash) = parse_deploy_secret_placeholder(&ph).unwrap();
assert_eq!(id, "20260722T052353Z-production-athena");
assert_eq!(name, "FOO");
assert_eq!(hash, "abc123def456");
}
#[test]
fn classifies_on_record_write_path() {
let plan = sample_plan_with_secret();
let sanitized = record_from_plan_with_version(
&plan,
false,
"failed".into(),
Some("OpenNext WSL deploy failed with status exit code: 1.".into()),
None,
Some("10.54.0".into()),
);
assert_eq!(
sanitized.record.error_code.as_deref(),
Some("opennext_wsl_failed")
);
assert_eq!(sanitized.record.phase.as_deref(), Some("opennext"));
assert_eq!(sanitized.record.provider.as_deref(), Some("kubernetes"));
assert!(!sanitized.record.diagnostics.is_empty());
}
#[test]
fn index_salvages_merge_conflict_markers() {
let dir = std::env::temp_dir().join(format!(
"xbp-deploy-history-test-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = DeployHistoryStore::new(&dir);
let plan = sample_plan_with_secret();
let sanitized = record_from_plan(&plan, true, "deploy succeeded".into(), None, None);
store.write_record(&sanitized.record).unwrap();
let corrupt = r#"{
"entries": [
<<<<<<< Updated upstream
=======
{
"id": "stale",
"status": "failed",
"path": "stale.json",
"timestamp": "2026-07-22T13:45:02.303897600Z",
"env": "production",
"target": "next-heroui-example"
},
>>>>>>> Stashed changes
{
"id": "keep-me",
"status": "success",
"path": "keep-me.json",
"timestamp": "2026-07-22T05:23:53.154691300Z",
"env": "production",
"target": "athena"
}
]
}
"#;
std::fs::write(dir.join("index.json"), corrupt).unwrap();
let index = store.load_index().unwrap();
assert!(
index.entries.iter().any(|e| e.id == sanitized.record.id),
"rebuilt index should include written record"
);
assert!(
!index.entries.iter().any(|e| e.id == "stale"),
"stale conflict-only entry without a file should not appear after rebuild"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_index_strips_conflict_markers_when_json_salvageable() {
let raw = r#"{
"entries": [
<<<<<<< Updated upstream
=======
{
"id": "a",
"status": "failed",
"path": "a.json",
"timestamp": "2026-07-22T13:45:02.303897600Z",
"env": "production",
"target": "x"
}
>>>>>>> Stashed changes
]
}
"#;
let index = parse_index_raw(raw).unwrap();
assert_eq!(index.entries.len(), 1);
assert_eq!(index.entries[0].id, "a");
}
}