xbp-deploy 10.43.0

Service-centric declarative deploy engine for XBP.
Documentation
use std::path::{Path, PathBuf};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::error::{DeployError, Result};
use crate::types::DeployPlan;

#[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,
}

#[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>,
    pub digests: std::collections::BTreeMap<String, String>,
    pub kubernetes_context: Option<String>,
    pub namespace: Option<String>,
    pub summary: String,
    pub error: Option<String>,
    pub plan: DeployPlan,
}

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()))?;
        serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))
    }

    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()))?;
        std::fs::write(&path, body).map_err(|e| DeployError::Io(e.to_string()))?;

        let mut index = self.load_index()?;
        index.entries.retain(|e| e.id != record.id);
        index.entries.insert(
            0,
            HistoryEntry {
                id: record.id.clone(),
                status: record.status.clone(),
                path: file_name,
                timestamp: record.timestamp,
                env: record.env.clone(),
                target: record.target.clone(),
            },
        );
        // Cap index size
        index.entries.truncate(200);
        let index_body =
            serde_json::to_string_pretty(&index).map_err(|e| DeployError::Io(e.to_string()))?;
        std::fs::write(self.index_path(), index_body)
            .map_err(|e| DeployError::Io(e.to_string()))?;
        Ok(path)
    }

    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 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 record_from_plan(
    plan: &DeployPlan,
    ok: bool,
    summary: String,
    error: Option<String>,
    kubernetes_context: Option<String>,
) -> DeployHistoryRecord {
    let ts = Utc::now();
    let id = make_record_id(&plan.env, &plan.target.label(), ts);
    let mut digests = std::collections::BTreeMap::new();
    for svc in &plan.services {
        if let Some(d) = &svc.digest {
            digests.insert(svc.name.clone(), d.clone());
        }
    }
    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()),
        digests,
        kubernetes_context,
        namespace: plan
            .services
            .first()
            .and_then(|s| s.deploy.namespace.clone()),
        summary,
        error,
        plan: plan.clone(),
    }
}

/// Path helper for tests.
#[allow(dead_code)]
pub fn history_dir(root: &Path, rel: &Path) -> PathBuf {
    root.join(rel)
}