use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::ServiceAccount;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DeploymentRecord {
pub version: u64,
pub container_id: Option<String>,
pub image: String,
pub endpoint: Option<String>,
#[serde(default)]
pub ip: Option<String>,
pub port: Option<u16>,
#[serde(default)]
pub price_per_second: f64,
#[serde(default)]
pub service_account: Option<ServiceAccount>,
#[serde(default = "super::default_warm")]
pub warm: bool,
pub phase: String,
pub previous_container_id: Option<String>,
#[serde(default)]
pub mesh_endpoint: Option<String>,
#[serde(default)]
pub mesh_port: Option<u16>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LocalState {
pub deployments: HashMap<String, DeploymentRecord>,
}
impl LocalState {
pub fn path() -> Option<PathBuf> {
crate::credentials::dir().map(|d| d.join("deployments.json"))
}
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(),
}
}
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() {
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);
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"),
}
}
}