Skip to main content

xbp_deploy/
history.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::compose::env_key_looks_secret;
9use crate::error::{DeployError, Result};
10use crate::failure::{classify_deploy_error, ClassifiedDeployOutcome, DeployFailureClass};
11use crate::types::DeployPlan;
12
13/// Prefix for redacted runtime_env placeholders written to disk.
14/// Format: `xbp:deploy:{deployment_id}:{SECRET_NAME}:{hash12}`
15pub const DEPLOY_SECRET_PLACEHOLDER_PREFIX: &str = "xbp:deploy:";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HistoryEntry {
19    pub id: String,
20    pub status: String,
21    pub path: String,
22    pub timestamp: DateTime<Utc>,
23    pub env: String,
24    pub target: String,
25    /// Stable failure/success code when known (optional for older index rows).
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub error_code: Option<String>,
28    /// Deploy phase when failure was recorded.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub phase: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct DeployHistoryIndex {
35    pub entries: Vec<HistoryEntry>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DeployHistoryRecord {
40    pub id: String,
41    pub timestamp: DateTime<Utc>,
42    pub env: String,
43    pub target: String,
44    pub target_kind: String,
45    pub status: String,
46    pub services: Vec<String>,
47    pub git_sha: Option<String>,
48    pub project_version: Option<String>,
49    /// XBP CLI version that produced this record (semver string).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub xbp_version: Option<String>,
52    /// When true, `plan.services[].runtime_env` values that looked secret were
53    /// replaced with `xbp:deploy:{id}:{NAME}:{hash}` placeholders; plaintexts
54    /// live in xbp.app D1 (`cli_deploy_secret`) when the operator was logged in.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub runtime_env_redacted: Option<bool>,
57    pub digests: BTreeMap<String, String>,
58    pub kubernetes_context: Option<String>,
59    pub namespace: Option<String>,
60    pub summary: String,
61    pub error: Option<String>,
62    /// Stable machine-readable failure class (e.g. `opennext_wsl_failed`).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub error_code: Option<String>,
65    /// Deploy phase where the attempt stopped (`oci_build`, `cf_deploy`, …).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub phase: Option<String>,
68    /// Short operator-facing diagnostics (remediation + first error line).
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub diagnostics: Vec<String>,
71    /// Primary provider from the first plan step (`kubernetes`, `cloudflare-worker`, …).
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub provider: Option<String>,
74    /// Primary destination (`kubernetes`, `cloudflare`, …).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub destination: Option<String>,
77    pub plan: DeployPlan,
78}
79
80/// One secret extracted from deploy history before writing redacted JSON.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct DeployHistorySecret {
83    pub name: String,
84    pub value: String,
85    /// First 12 hex chars of sha256(value) — embedded in the on-disk placeholder.
86    pub hash: String,
87    /// Service plan step name that owned this env key (`athena` or `athena@kubernetes`).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub service: Option<String>,
90}
91
92/// Result of building a history record with secrets stripped for disk.
93#[derive(Debug, Clone)]
94pub struct SanitizedHistoryRecord {
95    pub record: DeployHistoryRecord,
96    pub secrets: Vec<DeployHistorySecret>,
97}
98
99pub struct DeployHistoryStore {
100    pub dir: PathBuf,
101}
102
103impl DeployHistoryStore {
104    pub fn new(dir: impl Into<PathBuf>) -> Self {
105        Self { dir: dir.into() }
106    }
107
108    pub fn index_path(&self) -> PathBuf {
109        self.dir.join("index.json")
110    }
111
112    pub fn load_index(&self) -> Result<DeployHistoryIndex> {
113        let path = self.index_path();
114        if !path.exists() {
115            return Ok(DeployHistoryIndex::default());
116        }
117        let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
118        // Merge-conflict markers are never trustworthy — rebuild from attempt files.
119        if raw.contains("<<<<<<<") || raw.contains(">>>>>>>") {
120            return self.rebuild_index_from_records();
121        }
122        match parse_index_raw(&raw) {
123            Ok(index) => Ok(index),
124            Err(_) => {
125                // Corrupt / partial index — salvage from attempt files.
126                self.rebuild_index_from_records()
127            }
128        }
129    }
130
131    /// Rebuild `index.json` solely from `*.json` attempt records on disk.
132    pub fn rebuild_index_from_records(&self) -> Result<DeployHistoryIndex> {
133        let mut entries = self.scan_record_entries()?;
134        // Newest first
135        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
136        entries.truncate(200);
137        let index = DeployHistoryIndex { entries };
138        self.write_index_atomic(&index)?;
139        Ok(index)
140    }
141
142    /// Scan deployment attempt files (excludes `index.json` and revitalized sidecars).
143    pub fn scan_record_entries(&self) -> Result<Vec<HistoryEntry>> {
144        if !self.dir.is_dir() {
145            return Ok(Vec::new());
146        }
147        let mut entries = Vec::new();
148        let rd = std::fs::read_dir(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
149        for ent in rd {
150            let ent = ent.map_err(|e| DeployError::Io(e.to_string()))?;
151            let path = ent.path();
152            let name = match path.file_name().and_then(|n| n.to_str()) {
153                Some(n) => n.to_string(),
154                None => continue,
155            };
156            if name == "index.json" || !name.ends_with(".json") {
157                continue;
158            }
159            if name.contains(".revitalized.") {
160                continue;
161            }
162            let raw = match std::fs::read_to_string(&path) {
163                Ok(r) => r,
164                Err(_) => continue,
165            };
166            let record: DeployHistoryRecord = match serde_json::from_str(&raw) {
167                Ok(r) => r,
168                Err(_) => continue,
169            };
170            entries.push(history_entry_from_record(&record, &name));
171        }
172        Ok(entries)
173    }
174
175    fn write_index_atomic(&self, index: &DeployHistoryIndex) -> Result<()> {
176        std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
177        let index_body =
178            serde_json::to_string_pretty(index).map_err(|e| DeployError::Io(e.to_string()))?;
179        let final_path = self.index_path();
180        let tmp_path = self.dir.join("index.json.tmp");
181        std::fs::write(&tmp_path, index_body).map_err(|e| DeployError::Io(e.to_string()))?;
182        // On Windows, rename over existing may fail — remove first.
183        if final_path.exists() {
184            let _ = std::fs::remove_file(&final_path);
185        }
186        std::fs::rename(&tmp_path, &final_path).map_err(|e| DeployError::Io(e.to_string()))?;
187        Ok(())
188    }
189
190    pub fn write_record(&self, record: &DeployHistoryRecord) -> Result<PathBuf> {
191        std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
192        let file_name = format!("{}.json", record.id);
193        let path = self.dir.join(&file_name);
194        let body =
195            serde_json::to_string_pretty(record).map_err(|e| DeployError::Io(e.to_string()))?;
196        // Atomic-ish: write temp then rename.
197        let tmp = self.dir.join(format!("{}.json.tmp", record.id));
198        std::fs::write(&tmp, &body).map_err(|e| DeployError::Io(e.to_string()))?;
199        if path.exists() {
200            let _ = std::fs::remove_file(&path);
201        }
202        std::fs::rename(&tmp, &path).map_err(|e| DeployError::Io(e.to_string()))?;
203
204        let mut index = self.load_index().unwrap_or_default();
205        index.entries.retain(|e| e.id != record.id);
206        index
207            .entries
208            .insert(0, history_entry_from_record(record, &file_name));
209        // Cap index size
210        index.entries.truncate(200);
211        self.write_index_atomic(&index)?;
212        Ok(path)
213    }
214
215    /// Aggregate counts by `error_code` (falls back to classifying free-text for older records).
216    pub fn stats(&self, target: &str, env: &str, limit: usize) -> Result<DeployHistoryStats> {
217        let entries = self.list(target, env, limit.max(1))?;
218        let mut by_code: BTreeMap<String, usize> = BTreeMap::new();
219        let mut by_target: BTreeMap<String, usize> = BTreeMap::new();
220        let mut success = 0usize;
221        let mut failed = 0usize;
222        for entry in &entries {
223            *by_target.entry(entry.target.clone()).or_default() += 1;
224            if entry.status == "success" {
225                success += 1;
226                *by_code
227                    .entry(DeployFailureClass::Success.as_str().into())
228                    .or_default() += 1;
229                continue;
230            }
231            failed += 1;
232            let code = if let Some(c) = &entry.error_code {
233                c.clone()
234            } else if let Ok(Some(rec)) = self.load_record(entry) {
235                rec.error_code
236                    .or_else(|| {
237                        let c = classify_deploy_error(
238                            rec.error.as_deref(),
239                            &rec.summary,
240                            false,
241                        );
242                        Some(c.error_code.as_str().into())
243                    })
244                    .unwrap_or_else(|| DeployFailureClass::Unknown.as_str().into())
245            } else {
246                DeployFailureClass::Unknown.as_str().into()
247            };
248            *by_code.entry(code).or_default() += 1;
249        }
250        Ok(DeployHistoryStats {
251            total: entries.len(),
252            success,
253            failed,
254            by_error_code: by_code,
255            by_target,
256        })
257    }
258
259    pub fn latest_status(&self, target: &str, env: &str) -> Result<Option<HistoryEntry>> {
260        let index = self.load_index()?;
261        Ok(index
262            .entries
263            .into_iter()
264            .find(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env)))
265    }
266
267    pub fn list(&self, target: &str, env: &str, limit: usize) -> Result<Vec<HistoryEntry>> {
268        let index = self.load_index()?;
269        Ok(index
270            .entries
271            .into_iter()
272            .filter(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env))
273            .take(limit)
274            .collect())
275    }
276
277    /// Load a full history JSON record by index entry (relative `path` or id).
278    pub fn load_record(&self, entry: &HistoryEntry) -> Result<Option<DeployHistoryRecord>> {
279        let candidates = [
280            self.dir.join(&entry.path),
281            self.dir.join(format!("{}.json", entry.id)),
282        ];
283        for path in candidates {
284            if !path.is_file() {
285                continue;
286            }
287            let raw =
288                std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
289            let record: DeployHistoryRecord =
290                serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
291            return Ok(Some(record));
292        }
293        Ok(None)
294    }
295
296    /// Load by deployment id (filename without `.json` or full id).
297    pub fn load_record_by_id(&self, id: &str) -> Result<Option<DeployHistoryRecord>> {
298        let id = id.trim().trim_end_matches(".json");
299        if id.is_empty() {
300            return Ok(None);
301        }
302        let path = self.dir.join(format!("{id}.json"));
303        if !path.is_file() {
304            return Ok(None);
305        }
306        let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
307        let record: DeployHistoryRecord =
308            serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
309        Ok(Some(record))
310    }
311
312    pub fn record_path(&self, id: &str) -> PathBuf {
313        let id = id.trim().trim_end_matches(".json");
314        self.dir.join(format!("{id}.json"))
315    }
316}
317
318#[derive(Debug, Clone, Default, Serialize, Deserialize)]
319pub struct DeployHistoryStats {
320    pub total: usize,
321    pub success: usize,
322    pub failed: usize,
323    pub by_error_code: BTreeMap<String, usize>,
324    pub by_target: BTreeMap<String, usize>,
325}
326
327fn history_entry_from_record(record: &DeployHistoryRecord, file_name: &str) -> HistoryEntry {
328    HistoryEntry {
329        id: record.id.clone(),
330        status: record.status.clone(),
331        path: file_name.to_string(),
332        timestamp: record.timestamp,
333        env: record.env.clone(),
334        target: record.target.clone(),
335        error_code: record.error_code.clone(),
336        phase: record.phase.clone(),
337    }
338}
339
340/// Parse index JSON; strip common merge-conflict noise when possible.
341pub fn parse_index_raw(raw: &str) -> Result<DeployHistoryIndex> {
342    if raw.contains("<<<<<<<") || raw.contains(">>>>>>>") || raw.contains("=======") {
343        // Try salvage: drop conflict marker lines and parse remaining JSON.
344        let cleaned: String = raw
345            .lines()
346            .filter(|line| {
347                let t = line.trim_start();
348                !(t.starts_with("<<<<<<<")
349                    || t.starts_with(">>>>>>>")
350                    || t.starts_with("======="))
351            })
352            .collect::<Vec<_>>()
353            .join("\n");
354        if let Ok(index) = serde_json::from_str::<DeployHistoryIndex>(&cleaned) {
355            return Ok(index);
356        }
357        return Err(DeployError::Io(
358            "deploy history index.json contains merge conflict markers".into(),
359        ));
360    }
361    serde_json::from_str(raw).map_err(|e| DeployError::Io(e.to_string()))
362}
363
364pub fn make_record_id(env: &str, target: &str, ts: DateTime<Utc>) -> String {
365    let safe_target: String = target
366        .chars()
367        .map(|c| {
368            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
369                c
370            } else {
371                '_'
372            }
373        })
374        .collect();
375    format!("{}-{}-{}", ts.format("%Y%m%dT%H%M%SZ"), env, safe_target)
376}
377
378/// sha256 hex truncated to 12 chars for short integrity stubs in placeholders.
379pub fn secret_value_hash(value: &str) -> String {
380    let digest = Sha256::digest(value.as_bytes());
381    digest
382        .iter()
383        .flat_map(|b| [nibble(b >> 4), nibble(b & 0x0f)])
384        .take(12)
385        .collect()
386}
387
388fn nibble(n: u8) -> char {
389    match n {
390        0..=9 => (b'0' + n) as char,
391        10..=15 => (b'a' + (n - 10)) as char,
392        _ => '0',
393    }
394}
395
396/// Build on-disk placeholder for a secret.
397/// Example: `xbp:deploy:20260722T052353Z-production-athena:ATHENA_DB_URL:a1b2c3d4e5f6`
398pub fn deploy_secret_placeholder(deployment_id: &str, name: &str, hash: &str) -> String {
399    format!("{DEPLOY_SECRET_PLACEHOLDER_PREFIX}{deployment_id}:{name}:{hash}")
400}
401
402/// Parse `xbp:deploy:{id}:{NAME}:{hash}` placeholders.
403pub fn parse_deploy_secret_placeholder(value: &str) -> Option<(&str, &str, &str)> {
404    let rest = value.strip_prefix(DEPLOY_SECRET_PLACEHOLDER_PREFIX)?;
405    // deployment_id may contain hyphens/underscores but not colons in practice;
406    // name is SCREAMING_SNAKE; hash is 12 hex. Split from the right.
407    let (left, hash) = rest.rsplit_once(':')?;
408    let (deployment_id, name) = left.rsplit_once(':')?;
409    if deployment_id.is_empty() || name.is_empty() || hash.is_empty() {
410        return None;
411    }
412    Some((deployment_id, name, hash))
413}
414
415/// Whether this env entry should leave the on-disk history file.
416pub fn runtime_env_value_should_redact(key: &str, value: &str) -> bool {
417    if value.is_empty() {
418        return false;
419    }
420    if value.starts_with(DEPLOY_SECRET_PLACEHOLDER_PREFIX) {
421        return false; // already redacted
422    }
423    if env_key_looks_secret(key) {
424        return true;
425    }
426    // Connection strings / tokens that slip past key heuristics.
427    let v = value.trim();
428    if v.contains("://") && v.contains('@') {
429        return true; // e.g. postgres://user:pass@host
430    }
431    // Discord (and similar) webhook endpoints: https://discord.com/api/webhooks/…
432    if v.contains("://") && v.to_ascii_lowercase().contains("/webhooks/") {
433        return true;
434    }
435    if v.len() >= 24
436        && v.chars()
437            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.'))
438    {
439        // long opaque tokens
440        return true;
441    }
442    false
443}
444
445/// Replace secret runtime_env values on a plan with placeholders; return extracted secrets.
446pub fn redact_plan_runtime_env(
447    plan: &mut DeployPlan,
448    deployment_id: &str,
449) -> Vec<DeployHistorySecret> {
450    let mut secrets: Vec<DeployHistorySecret> = Vec::new();
451    let mut seen: BTreeMap<String, String> = BTreeMap::new(); // name -> hash (dedupe)
452
453    for svc in &mut plan.services {
454        let mut next_env = BTreeMap::new();
455        for (key, value) in std::mem::take(&mut svc.runtime_env) {
456            if runtime_env_value_should_redact(&key, &value) {
457                let hash = secret_value_hash(&value);
458                let placeholder = deploy_secret_placeholder(deployment_id, &key, &hash);
459                if let Some(prev_hash) = seen.get(&key) {
460                    if prev_hash != &hash {
461                        // Same key, different values across services — namespace by service.
462                        let scoped = format!("{}__{}", svc.name.replace('@', "_"), key);
463                        let hash2 = secret_value_hash(&value);
464                        let ph = deploy_secret_placeholder(deployment_id, &scoped, &hash2);
465                        secrets.push(DeployHistorySecret {
466                            name: scoped,
467                            value: value.clone(),
468                            hash: hash2,
469                            service: Some(svc.name.clone()),
470                        });
471                        next_env.insert(key, ph);
472                        continue;
473                    }
474                } else {
475                    seen.insert(key.clone(), hash.clone());
476                    secrets.push(DeployHistorySecret {
477                        name: key.clone(),
478                        value: value.clone(),
479                        hash: hash.clone(),
480                        service: Some(svc.name.clone()),
481                    });
482                }
483                next_env.insert(key, placeholder);
484            } else {
485                next_env.insert(key, value);
486            }
487        }
488        svc.runtime_env = next_env;
489    }
490    secrets
491}
492
493/// Restore placeholders in a history record using a name→plaintext map from D1.
494/// Returns how many placeholders were filled.
495pub fn revitalize_record_runtime_env(
496    record: &mut DeployHistoryRecord,
497    secrets: &BTreeMap<String, String>,
498) -> usize {
499    let mut filled = 0usize;
500    for svc in &mut record.plan.services {
501        for (_key, value) in svc.runtime_env.iter_mut() {
502            if let Some((dep_id, name, hash)) = parse_deploy_secret_placeholder(value) {
503                if dep_id != record.id {
504                    continue;
505                }
506                if let Some(plain) = secrets.get(name) {
507                    let actual = secret_value_hash(plain);
508                    if actual == hash || hash.is_empty() {
509                        *value = plain.clone();
510                        filled += 1;
511                    }
512                }
513            }
514        }
515    }
516    if filled > 0 {
517        record.runtime_env_redacted = Some(false);
518    }
519    filled
520}
521
522pub fn record_from_plan(
523    plan: &DeployPlan,
524    ok: bool,
525    summary: String,
526    error: Option<String>,
527    kube_context: Option<String>,
528) -> SanitizedHistoryRecord {
529    record_from_plan_with_version(plan, ok, summary, error, kube_context, None)
530}
531
532pub fn record_from_plan_with_version(
533    plan: &DeployPlan,
534    ok: bool,
535    summary: String,
536    error: Option<String>,
537    kube_context: Option<String>,
538    xbp_version: Option<String>,
539) -> SanitizedHistoryRecord {
540    let classified = classify_deploy_error(error.as_deref(), &summary, ok);
541    record_from_plan_with_classification(
542        plan,
543        ok,
544        summary,
545        error,
546        kube_context,
547        xbp_version,
548        classified,
549    )
550}
551
552pub fn record_from_plan_with_classification(
553    plan: &DeployPlan,
554    ok: bool,
555    summary: String,
556    error: Option<String>,
557    kube_context: Option<String>,
558    xbp_version: Option<String>,
559    classified: ClassifiedDeployOutcome,
560) -> SanitizedHistoryRecord {
561    let ts = Utc::now();
562    let id = make_record_id(&plan.env, &plan.target.label(), ts);
563    let mut digests = BTreeMap::new();
564    for svc in &plan.services {
565        if let Some(d) = &svc.digest {
566            digests.insert(svc.name.clone(), d.clone());
567        }
568    }
569
570    let mut plan_for_disk = plan.clone();
571    let secrets = redact_plan_runtime_env(&mut plan_for_disk, &id);
572    let redacted = !secrets.is_empty();
573
574    let primary = plan.services.first();
575    let provider = primary.map(|s| s.provider.clone());
576    let destination = primary.and_then(|s| s.destination.clone());
577
578    let (error_code, phase, diagnostics) = if ok {
579        (
580            Some(DeployFailureClass::Success.as_str().to_string()),
581            None,
582            Vec::new(),
583        )
584    } else {
585        (
586            Some(classified.error_code.as_str().to_string()),
587            Some(classified.phase.as_str().to_string()),
588            classified.diagnostics,
589        )
590    };
591
592    let record = DeployHistoryRecord {
593        id,
594        timestamp: ts,
595        env: plan.env.clone(),
596        target: plan.target.label(),
597        target_kind: plan.target.kind_str().into(),
598        status: if ok {
599            "success".into()
600        } else {
601            "failed".into()
602        },
603        services: plan.order.clone(),
604        git_sha: plan.git_sha.clone(),
605        project_version: Some(plan.project_version.clone()),
606        xbp_version,
607        runtime_env_redacted: if redacted { Some(true) } else { None },
608        digests,
609        kubernetes_context: kube_context,
610        namespace: plan
611            .services
612            .first()
613            .and_then(|s| s.deploy.namespace.clone()),
614        summary,
615        error,
616        error_code,
617        phase,
618        diagnostics,
619        provider,
620        destination,
621        plan: plan_for_disk,
622    };
623
624    SanitizedHistoryRecord { record, secrets }
625}
626/// Path helper for tests.
627#[allow(dead_code)]
628pub fn history_dir(root: &Path, rel: &Path) -> PathBuf {
629    root.join(rel)
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::types::{
636        DeployTarget, K8sPlanView, OciPlan, ServiceDeployPlan, ServicePlan,
637    };
638    use std::collections::BTreeMap;
639
640    fn sample_plan_with_secret() -> DeployPlan {
641        let mut env = BTreeMap::new();
642        env.insert("PORT".into(), "5052".into());
643        env.insert("ATHENA_DEBUG_JWT_SECRET".into(), "super-secret-token-value".into());
644        env.insert(
645            "DATABASE_URL".into(),
646            "postgresql://user:pass@host:5432/db".into(),
647        );
648        DeployPlan {
649            target: DeployTarget::Service("athena".into()),
650            env: "production".into(),
651            project: "athena".into(),
652            project_version: "4.1.0".into(),
653            git_sha: Some("abc".into()),
654            services: vec![ServicePlan {
655                name: "athena".into(),
656                provider: "kubernetes".into(),
657                destination: Some("kubernetes".into()),
658                root_directory: None,
659                version: "4.1.0".into(),
660                image: None,
661                image_ref: None,
662                digest: None,
663                dockerfile: None,
664                build_context: None,
665                platforms: vec![],
666                worker_app: None,
667                rollout: None,
668                runtime_env: env,
669                container_port: Some(5052),
670                config_mounts: vec![],
671                expose: None,
672                deploy: ServiceDeployPlan {
673                    namespace: Some("athena".into()),
674                    workload: None,
675                    service: None,
676                    health: vec![],
677                    manifest_paths: vec![],
678                    crds_path: None,
679                    install_path: None,
680                    selector: None,
681                    actions: vec![],
682                },
683            }],
684            order: vec!["athena@kubernetes".into()],
685            oci_plan: OciPlan::default(),
686            k8s_plan: K8sPlanView {
687                context: None,
688                default_namespace: Some("athena".into()),
689                services: vec![],
690            },
691            hash: "h".into(),
692        }
693    }
694
695    #[test]
696    fn redacts_discord_webhook_urls_even_without_secret_key_suffix() {
697        // Live Discord webhook URLs must never land in on-disk history.
698        assert!(runtime_env_value_should_redact(
699            "DISCORD_WEBHOOK_URL",
700            "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz",
701        ));
702        assert!(runtime_env_value_should_redact(
703            "NOTIFY_HOOK",
704            "https://discordapp.com/api/webhooks/1/token-here",
705        ));
706        // Already redacted placeholders stay as-is.
707        assert!(!runtime_env_value_should_redact(
708            "DISCORD_WEBHOOK_URL",
709            "xbp:deploy:id:DISCORD_WEBHOOK_URL:abcdef012345",
710        ));
711    }
712
713    #[test]
714    fn redacts_secrets_and_revitalizes() {
715        let plan = sample_plan_with_secret();
716        let sanitized = record_from_plan_with_version(
717            &plan,
718            true,
719            "ok".into(),
720            None,
721            None,
722            Some("10.51.1".into()),
723        );
724        assert_eq!(sanitized.record.xbp_version.as_deref(), Some("10.51.1"));
725        assert_eq!(sanitized.record.runtime_env_redacted, Some(true));
726        assert!(sanitized.secrets.len() >= 2);
727
728        let env = &sanitized.record.plan.services[0].runtime_env;
729        assert_eq!(env.get("PORT").map(String::as_str), Some("5052"));
730        let secret_ph = env.get("ATHENA_DEBUG_JWT_SECRET").unwrap();
731        assert!(secret_ph.starts_with("xbp:deploy:"));
732        assert!(parse_deploy_secret_placeholder(secret_ph).is_some());
733
734        let mut secrets_map = BTreeMap::new();
735        for s in &sanitized.secrets {
736            secrets_map.insert(s.name.clone(), s.value.clone());
737        }
738        let mut restored = sanitized.record.clone();
739        let n = revitalize_record_runtime_env(&mut restored, &secrets_map);
740        assert!(n >= 2);
741        assert_eq!(
742            restored.plan.services[0]
743                .runtime_env
744                .get("ATHENA_DEBUG_JWT_SECRET")
745                .map(String::as_str),
746            Some("super-secret-token-value")
747        );
748    }
749
750    #[test]
751    fn placeholder_roundtrip() {
752        let ph = deploy_secret_placeholder("20260722T052353Z-production-athena", "FOO", "abc123def456");
753        let (id, name, hash) = parse_deploy_secret_placeholder(&ph).unwrap();
754        assert_eq!(id, "20260722T052353Z-production-athena");
755        assert_eq!(name, "FOO");
756        assert_eq!(hash, "abc123def456");
757    }
758
759    #[test]
760    fn classifies_on_record_write_path() {
761        let plan = sample_plan_with_secret();
762        let sanitized = record_from_plan_with_version(
763            &plan,
764            false,
765            "failed".into(),
766            Some("OpenNext WSL deploy failed with status exit code: 1.".into()),
767            None,
768            Some("10.54.0".into()),
769        );
770        assert_eq!(
771            sanitized.record.error_code.as_deref(),
772            Some("opennext_wsl_failed")
773        );
774        assert_eq!(sanitized.record.phase.as_deref(), Some("opennext"));
775        assert_eq!(sanitized.record.provider.as_deref(), Some("kubernetes"));
776        assert!(!sanitized.record.diagnostics.is_empty());
777    }
778
779    #[test]
780    fn index_salvages_merge_conflict_markers() {
781        let dir = std::env::temp_dir().join(format!(
782            "xbp-deploy-history-test-{}",
783            std::process::id()
784        ));
785        let _ = std::fs::remove_dir_all(&dir);
786        std::fs::create_dir_all(&dir).unwrap();
787        let store = DeployHistoryStore::new(&dir);
788
789        let plan = sample_plan_with_secret();
790        let sanitized = record_from_plan(&plan, true, "deploy succeeded".into(), None, None);
791        store.write_record(&sanitized.record).unwrap();
792
793        // Corrupt index with merge conflict markers (as seen on athena).
794        let corrupt = r#"{
795  "entries": [
796<<<<<<< Updated upstream
797=======
798    {
799      "id": "stale",
800      "status": "failed",
801      "path": "stale.json",
802      "timestamp": "2026-07-22T13:45:02.303897600Z",
803      "env": "production",
804      "target": "next-heroui-example"
805    },
806>>>>>>> Stashed changes
807    {
808      "id": "keep-me",
809      "status": "success",
810      "path": "keep-me.json",
811      "timestamp": "2026-07-22T05:23:53.154691300Z",
812      "env": "production",
813      "target": "athena"
814    }
815  ]
816}
817"#;
818        std::fs::write(dir.join("index.json"), corrupt).unwrap();
819
820        // load_index should rebuild from on-disk attempt records.
821        let index = store.load_index().unwrap();
822        assert!(
823            index.entries.iter().any(|e| e.id == sanitized.record.id),
824            "rebuilt index should include written record"
825        );
826        assert!(
827            !index.entries.iter().any(|e| e.id == "stale"),
828            "stale conflict-only entry without a file should not appear after rebuild"
829        );
830
831        let _ = std::fs::remove_dir_all(&dir);
832    }
833
834    #[test]
835    fn parse_index_strips_conflict_markers_when_json_salvageable() {
836        let raw = r#"{
837  "entries": [
838<<<<<<< Updated upstream
839=======
840    {
841      "id": "a",
842      "status": "failed",
843      "path": "a.json",
844      "timestamp": "2026-07-22T13:45:02.303897600Z",
845      "env": "production",
846      "target": "x"
847    }
848>>>>>>> Stashed changes
849  ]
850}
851"#;
852        // After stripping markers, JSON is valid with one entry.
853        let index = parse_index_raw(raw).unwrap();
854        assert_eq!(index.entries.len(), 1);
855        assert_eq!(index.entries[0].id, "a");
856    }
857}