zc2 0.0.29

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Persistent local reconciler state: what this node believes is running for
//! each deployment id, so a broker restart reconciles against reality
//! (`docker ps --filter label=zakuro.deployment`) instead of re-deploying
//! everything from scratch.
//!
//! Stored at `{ZAKURO_HOME or ~/.zakuro}/deployments.json`, same directory
//! `credentials::dir()` already resolves for the node key / roster cache /
//! WAL. Written atomically (temp file + rename) so a crash mid-write never
//! leaves a torn, unparsable file behind — the reconciler runs unattended on
//! a timer and a corrupt state file would otherwise wedge every future tick.

use std::collections::HashMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use super::ServiceAccount;

/// What this node knows about one deployment's current container.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DeploymentRecord {
    pub version: u64,
    /// The currently-live container for this deployment (`None` when stopped
    /// or never successfully started).
    pub container_id: Option<String>,
    pub image: String,
    /// `container_ip:port` — the broker's own `/serve/:id/*path` proxy
    /// target. Not a host-published address: a broker driving this
    /// reconciler is usually itself a container, so `run` joins the
    /// container to `runtime::deploy_network()` and this is its address on
    /// THAT network (see `runtime`'s module doc comment) — reachable by
    /// anything else on the same network, not just this broker.
    pub endpoint: Option<String>,
    /// The `ip` half of `endpoint`, kept alongside it so `/serve` doesn't
    /// have to re-parse it out on every request. `None` when the container
    /// has no network IP (e.g. `--network host`) — see
    /// `ContainerRuntime::container_ip`.
    #[serde(default)]
    pub ip: Option<String>,
    /// The `port` half of `endpoint`: the container's OWN listening port
    /// (from `Desired::healthcheck.port` or `Desired::ports[0]`), never a
    /// host-published one — nothing is published anymore (see `runtime`'s
    /// module doc comment).
    pub port: Option<u16>,
    /// The hub's `price_per_second` for this deployment as of the last
    /// successful `Start`, cached here so `/serve/:id/*path` can bill without
    /// re-fetching `desired` on the request path (see `deploy::serve`).
    #[serde(default)]
    pub price_per_second: f64,
    /// Passed through from `Desired::service_account` untouched (surfaced by
    /// `zc deployments`; also handed to the container as
    /// `ZAKURO_SERVICE_ACCOUNT`/`ZAKURO_GRANTS` env vars — see
    /// `reconcile::drive`'s `Start` handling).
    #[serde(default)]
    pub service_account: Option<ServiceAccount>,
    /// Passed through from `Desired::warm`. `false` is not yet implemented
    /// (see `Desired::warm`'s doc comment) — treated as `true`.
    #[serde(default = "super::default_warm")]
    pub warm: bool,
    pub phase: String,
    /// Set right before a version bump overwrites `container_id`, so a
    /// `StopOld` action (after the new version goes healthy) or a rollback
    /// (if it doesn't) knows what the "old" container was. Cleared once
    /// consumed either way.
    pub previous_container_id: Option<String>,
    /// `mesh_ip:port` where this deployment is reachable from the rest of the
    /// WireGuard mesh, as last reported to the hub (see `expose::sync`), or
    /// `None` while it isn't exposed. It mirrors what the hub last received,
    /// so a change is re-reported exactly once.
    #[serde(default)]
    pub mesh_endpoint: Option<String>,
    /// The port exposure last listened on. Kept while the deployment is not
    /// exposed, so re-exposing it (a broker restart, a new version, the
    /// tunnel coming back) reuses the port and the address stays the same.
    #[serde(default)]
    pub mesh_port: Option<u16>,
}

/// All deployments this node is tracking, keyed by deployment id.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LocalState {
    pub deployments: HashMap<String, DeploymentRecord>,
}

impl LocalState {
    /// `{ZAKURO_HOME or ~/.zakuro}/deployments.json`.
    pub fn path() -> Option<PathBuf> {
        crate::credentials::dir().map(|d| d.join("deployments.json"))
    }

    /// Load from disk; an empty state if the file is absent or unparsable
    /// (first run, or a manually-cleared state directory — either way,
    /// starting from "nothing known" just means the next reconcile treats
    /// every desired deployment as new, which is safe/idempotent).
    pub fn load() -> LocalState {
        let Some(path) = Self::path() else {
            return LocalState::default();
        };
        match std::fs::read_to_string(&path) {
            Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
            Err(_) => LocalState::default(),
        }
    }

    /// Persist atomically: write to a sibling temp file, then rename over the
    /// real path. A reader (including a concurrently-starting broker) never
    /// observes a partially-written file — `rename` on the same filesystem is
    /// atomic, unlike an in-place `write`.
    pub fn save(&self) -> std::io::Result<()> {
        let path = Self::path().ok_or_else(|| {
            std::io::Error::new(std::io::ErrorKind::NotFound, "no ZAKURO_HOME/HOME")
        })?;
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, json)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
        }
        std::fs::rename(&tmp, &path)?;
        Ok(())
    }
}

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

    #[test]
    fn load_defaults_when_file_absent() {
        // credentials::dir() with an env that resolves to a nonexistent dir.
        let dir = std::env::temp_dir().join(format!("zc-deploy-test-{}", uuid::Uuid::new_v4()));
        let _lock = crate::credentials::HOME_ENV_LOCK.lock();
        let prev = std::env::var_os("ZAKURO_HOME");
        std::env::set_var("ZAKURO_HOME", &dir);
        let loaded = LocalState::load();
        assert!(loaded.deployments.is_empty());
        match prev {
            Some(v) => std::env::set_var("ZAKURO_HOME", v),
            None => std::env::remove_var("ZAKURO_HOME"),
        }
    }

    #[test]
    fn save_then_load_round_trips() {
        let dir = std::env::temp_dir().join(format!("zc-deploy-test-{}", uuid::Uuid::new_v4()));
        let _lock = crate::credentials::HOME_ENV_LOCK.lock();
        let prev = std::env::var_os("ZAKURO_HOME");
        std::env::set_var("ZAKURO_HOME", &dir);

        let mut state = LocalState::default();
        state.deployments.insert(
            "dep_1".to_string(),
            DeploymentRecord {
                version: 3,
                container_id: Some("abc123".to_string()),
                image: "python:3.12-slim".to_string(),
                endpoint: Some("172.17.0.5:8000".to_string()),
                ip: Some("172.17.0.5".to_string()),
                port: Some(8000),
                price_per_second: 0.0001,
                service_account: Some(ServiceAccount {
                    name: "billing-svc".to_string(),
                    grants: vec!["kv:read".to_string(), "queue:publish".to_string()],
                }),
                warm: true,
                phase: "healthy".to_string(),
                previous_container_id: None,
                mesh_endpoint: Some("10.13.13.22:8000".to_string()),
                mesh_port: Some(8000),
            },
        );
        state.save().expect("save");

        let loaded = LocalState::load();
        assert_eq!(loaded, state);

        // No torn/temp file left behind.
        assert!(!LocalState::path()
            .unwrap()
            .with_extension("json.tmp")
            .exists());

        let _ = std::fs::remove_dir_all(&dir);
        match prev {
            Some(v) => std::env::set_var("ZAKURO_HOME", v),
            None => std::env::remove_var("ZAKURO_HOME"),
        }
    }
}