greentic-operator 0.4.43

Greentic operator CLI for local dev and demo orchestration.
Documentation
use std::path::{Path, PathBuf};

use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};

pub struct RuntimePaths {
    state_dir: PathBuf,
    log_root: PathBuf,
    tenant: String,
    team: String,
}

impl RuntimePaths {
    pub fn new(
        state_dir: impl Into<PathBuf>,
        tenant: impl Into<String>,
        team: impl Into<String>,
    ) -> Self {
        let state_dir = state_dir.into();
        let log_root = state_dir
            .parent()
            .map(|parent| parent.to_path_buf())
            .unwrap_or_else(|| PathBuf::from("."))
            .join("logs");
        Self {
            state_dir,
            log_root,
            tenant: tenant.into(),
            team: team.into(),
        }
    }

    pub fn key(&self) -> String {
        format!("{}.{}", self.tenant, self.team)
    }

    pub fn runtime_root(&self) -> PathBuf {
        self.state_dir.join("runtime").join(self.key())
    }

    pub fn pids_dir(&self) -> PathBuf {
        self.state_dir.join("pids").join(self.key())
    }

    pub fn logs_dir(&self) -> PathBuf {
        self.log_root.join(self.key())
    }

    pub fn dlq_log_path(&self) -> PathBuf {
        self.logs_dir().join("dlq.log")
    }

    pub fn resolved_dir(&self) -> PathBuf {
        self.runtime_root().join("resolved")
    }

    pub fn pid_path(&self, service_id: &str) -> PathBuf {
        self.pids_dir().join(format!("{service_id}.pid"))
    }

    pub fn log_path(&self, service_id: &str) -> PathBuf {
        self.logs_dir().join(format!("{service_id}.log"))
    }

    pub fn resolved_path(&self, service_id: &str) -> PathBuf {
        self.resolved_dir().join(format!("{service_id}.json"))
    }

    pub fn logs_root(&self) -> PathBuf {
        self.log_root.clone()
    }

    pub fn service_manifest_path(&self) -> PathBuf {
        self.runtime_root().join("services.json")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn logs_dir_uses_bundle_logs() {
        let paths = RuntimePaths::new("/tmp/bundle/state", "demo", "default");
        assert_eq!(
            paths.logs_dir(),
            PathBuf::from("/tmp/bundle/logs").join("demo.default")
        );
    }
}

pub fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
    let bytes = serde_json::to_vec_pretty(value)?;
    atomic_write(path, &bytes)
}

pub fn read_json<T: DeserializeOwned>(path: &Path) -> anyhow::Result<Option<T>> {
    if !path.exists() {
        return Ok(None);
    }
    let data = std::fs::read(path)?;
    let value = serde_json::from_slice(&data)?;
    Ok(Some(value))
}

pub fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
    use std::io::Write;

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut tmp = path.to_path_buf();
    tmp.set_extension("tmp");
    let mut file = std::fs::File::create(&tmp)?;
    file.write_all(bytes)?;
    file.sync_all()?;
    std::fs::rename(&tmp, path)?;
    if let Some(parent) = path.parent()
        && let Ok(dir) = std::fs::File::open(parent)
    {
        let _ = dir.sync_all();
    }
    Ok(())
}

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ServiceManifest {
    #[serde(default)]
    pub log_dir: Option<String>,
    #[serde(default)]
    pub services: Vec<ServiceEntry>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ServiceEntry {
    pub id: String,
    pub kind: String,
    pub log_path: Option<String>,
}

impl ServiceEntry {
    pub fn new(id: impl Into<String>, kind: impl Into<String>, log_path: Option<&Path>) -> Self {
        Self {
            id: id.into(),
            kind: kind.into(),
            log_path: log_path.map(|path| path.display().to_string()),
        }
    }
}

pub fn persist_service_manifest(
    paths: &RuntimePaths,
    manifest: &ServiceManifest,
) -> anyhow::Result<()> {
    write_json(&paths.service_manifest_path(), manifest)
}

pub fn read_service_manifest(paths: &RuntimePaths) -> anyhow::Result<Option<ServiceManifest>> {
    read_json(&paths.service_manifest_path())
}

pub fn remove_service_manifest(paths: &RuntimePaths) -> anyhow::Result<()> {
    let path = paths.service_manifest_path();
    if path.exists() {
        std::fs::remove_file(path)?;
    }
    Ok(())
}