use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use greentic_deploy_spec::{CapabilitySlot, EnvId};
use serde::{Deserialize, Serialize};
use super::atomic_write::{atomic_write_bytes, atomic_write_json};
use super::store::{LocalFsStore, StoreError};
use super::trust_root::TRUST_ROOT_FILE;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SnapshotId(String);
impl SnapshotId {
#[allow(clippy::new_without_default)] pub fn new() -> Self {
Self(ulid::Ulid::new().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SnapshotId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct SnapshotManifest {
schema: String,
env_id: EnvId,
files: BTreeMap<String, bool>,
}
const SNAPSHOT_MANIFEST_SCHEMA: &str = "greentic.snapshot-manifest.v1";
const SNAPSHOTS_DIR: &str = "snapshots";
const MANIFEST_FILE: &str = "manifest.json";
pub fn snapshot_environment(
store: &LocalFsStore,
env_id: &EnvId,
) -> Result<SnapshotId, StoreError> {
store.transact(env_id, |_locked| snapshot_locked(store, env_id))
}
fn snapshot_locked(store: &LocalFsStore, env_id: &EnvId) -> Result<SnapshotId, StoreError> {
let env_dir = store.env_dir(env_id)?;
if !env_dir.exists() {
return Err(StoreError::NotFound(env_id.clone()));
}
let snap_id = SnapshotId::new();
let snap_dir = env_dir.join(SNAPSHOTS_DIR).join(snap_id.as_str());
let mut manifest_files: BTreeMap<String, bool> = BTreeMap::new();
let top_level = &[
"environment.json",
"runtime.json",
"runtime-config.json",
TRUST_ROOT_FILE,
];
for rel in top_level {
capture_file(&env_dir, rel, &snap_dir, &mut manifest_files)?;
}
for slot in CapabilitySlot::ALL {
let rel = format!("env-packs/{}/answers.json", slot.as_str());
capture_file(&env_dir, &rel, &snap_dir, &mut manifest_files)?;
}
let messaging_dir = env_dir.join("messaging");
if messaging_dir.is_dir() {
for entry in fs::read_dir(&messaging_dir).map_err(|source| StoreError::Io {
path: messaging_dir.clone(),
source,
})? {
let entry = entry.map_err(|source| StoreError::Io {
path: messaging_dir.clone(),
source,
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !name.ends_with(".json") {
continue;
}
let rel = format!("messaging/{name}");
capture_file(&env_dir, &rel, &snap_dir, &mut manifest_files)?;
}
}
let manifest = SnapshotManifest {
schema: SNAPSHOT_MANIFEST_SCHEMA.to_string(),
env_id: env_id.clone(),
files: manifest_files,
};
atomic_write_json(&snap_dir.join(MANIFEST_FILE), &manifest)?;
Ok(snap_id)
}
fn capture_file(
env_dir: &Path,
rel_path: &str,
snap_dir: &Path,
manifest: &mut BTreeMap<String, bool>,
) -> Result<(), StoreError> {
let src = env_dir.join(rel_path);
let present = src.is_file();
if present {
let dst = snap_dir.join(rel_path);
let dst_parent = dst.parent().expect("snapshot file always has a parent");
fs::create_dir_all(dst_parent).map_err(|source| StoreError::Io {
path: dst_parent.to_path_buf(),
source,
})?;
fs::copy(&src, &dst).map_err(|source| StoreError::Io {
path: src.clone(),
source,
})?;
}
manifest.insert(rel_path.to_string(), present);
Ok(())
}
pub fn restore_environment(
store: &LocalFsStore,
env_id: &EnvId,
snapshot: &SnapshotId,
) -> Result<(), StoreError> {
store.transact(env_id, |locked| {
let env_dir = store.env_dir(env_id)?;
let snap_dir = env_dir.join(SNAPSHOTS_DIR).join(snapshot.as_str());
let manifest_path = snap_dir.join(MANIFEST_FILE);
if !manifest_path.is_file() {
return Err(StoreError::DependentNotFound(format!(
"snapshot `{snapshot}` not found in env `{env_id}`"
)));
}
let manifest_bytes = fs::read(&manifest_path).map_err(|source| StoreError::Io {
path: manifest_path.clone(),
source,
})?;
let manifest: SnapshotManifest =
serde_json::from_slice(&manifest_bytes).map_err(|source| StoreError::Json {
path: manifest_path,
source,
})?;
let backups_dir = env_dir.join("backups");
for (rel_path, present) in &manifest.files {
if !is_safe_rel_path(rel_path) {
return Err(StoreError::InvalidArgument(format!(
"snapshot manifest contains an unsafe path `{rel_path}`"
)));
}
let live_path = env_dir.join(rel_path);
if *present {
let snap_file = snap_dir.join(rel_path);
let bytes = fs::read(&snap_file).map_err(|source| StoreError::Io {
path: snap_file,
source,
})?;
super::atomic_write::copy_to_backup(&live_path, &backups_dir)?;
atomic_write_bytes(&live_path, &bytes)?;
} else {
if live_path.is_file() {
super::atomic_write::copy_to_backup(&live_path, &backups_dir)?;
fs::remove_file(&live_path).map_err(|source| StoreError::Io {
path: live_path,
source,
})?;
}
}
}
let messaging_dir = env_dir.join("messaging");
if messaging_dir.is_dir() {
for entry in fs::read_dir(&messaging_dir).map_err(|source| StoreError::Io {
path: messaging_dir.clone(),
source,
})? {
let entry = entry.map_err(|source| StoreError::Io {
path: messaging_dir.clone(),
source,
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let rel = format!("messaging/{name}");
if !manifest.files.contains_key(&rel) {
super::atomic_write::copy_to_backup(&path, &backups_dir)?;
fs::remove_file(&path).map_err(|source| StoreError::Io {
path: path.clone(),
source,
})?;
}
}
}
let env = locked.load()?;
locked.refresh_runtime_config(&env)?;
locked.refresh_messaging_projection(&env)?;
Ok(())
})
}
fn is_safe_rel_path(rel: &str) -> bool {
use std::path::Component;
let p = Path::new(rel);
!p.is_absolute() && p.components().all(|c| matches!(c, Component::Normal(_)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
use chrono::Utc;
use greentic_deploy_spec::{
EnvId, Environment, EnvironmentHostConfig, HealthStatus, MessagingEndpoint,
MessagingEndpointId, RetentionPolicy, RevocationConfig, SchemaVersion, SecretRef,
};
use tempfile::TempDir;
use crate::environment::store::EnvironmentStore;
fn test_env(env_id: &EnvId) -> Environment {
Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: env_id.clone(),
name: "test-env".into(),
host_config: EnvironmentHostConfig {
env_id: env_id.clone(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
},
packs: vec![],
credentials_ref: None,
bundles: vec![],
revisions: vec![],
traffic_splits: vec![],
messaging_endpoints: vec![],
extensions: vec![],
revocation: RevocationConfig::default(),
retention: RetentionPolicy::default(),
health: HealthStatus::default(),
}
}
fn test_messaging_endpoint(env_id: &EnvId) -> MessagingEndpoint {
MessagingEndpoint {
schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
env_id: env_id.clone(),
endpoint_id: MessagingEndpointId::new(),
provider_id: "tg-test".into(),
provider_type: "telegram".into(),
display_name: "Test Bot".into(),
secret_refs: vec![
SecretRef::try_new(format!("secret://{}/default/_/messaging/token", env_id))
.unwrap(),
],
webhook_secret_ref: Some(
SecretRef::try_new(format!(
"secret://{}/default/_/messaging/webhook_secret",
env_id
))
.unwrap(),
),
linked_bundles: vec![],
welcome_flow: None,
generation: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
updated_by: "operator://test".into(),
}
}
fn seed_env(tmp: &TempDir) -> (LocalFsStore, EnvId, Environment) {
let store = LocalFsStore::new(tmp.path());
let env_id = EnvId::from_str("snapshot-test").unwrap();
let env_dir = tmp.path().join(env_id.as_str());
fs::create_dir_all(&env_dir).unwrap();
fs::write(env_dir.join(".lock"), b"").unwrap();
let env = test_env(&env_id);
store.save(&env).unwrap();
(store, env_id, env)
}
#[test]
fn snapshot_and_restore_byte_exact() {
let tmp = TempDir::new().unwrap();
let (store, env_id, mut env) = seed_env(&tmp);
let env_dir = tmp.path().join(env_id.as_str());
let runtime_content = br#"{"schema":"greentic.environment-runtime.v1","environment_id":"snapshot-test","discovered":{}}"#;
fs::write(env_dir.join("runtime.json"), runtime_content).unwrap();
let answers_dir = env_dir.join("env-packs").join("deployer");
fs::create_dir_all(&answers_dir).unwrap();
let answers_content = br#"{"cloud":"aws","region":"us-east-1"}"#;
fs::write(answers_dir.join("answers.json"), answers_content).unwrap();
let trust_root_content = br#"{"schema":"greentic.trust-root.v1","keys":[]}"#;
fs::write(env_dir.join(TRUST_ROOT_FILE), trust_root_content).unwrap();
env.messaging_endpoints
.push(test_messaging_endpoint(&env_id));
store.save(&env).unwrap();
store
.transact(&env_id, |locked| locked.refresh_messaging_projection(&env))
.unwrap();
let snap_id = snapshot_environment(&store, &env_id).unwrap();
let corrupted = b"CORRUPTED";
fs::write(env_dir.join("environment.json"), corrupted).unwrap();
fs::remove_file(env_dir.join(TRUST_ROOT_FILE)).unwrap();
fs::write(env_dir.join("messaging").join("stray.json"), b"stray").unwrap();
fs::write(answers_dir.join("answers.json"), b"{}").unwrap();
restore_environment(&store, &env_id, &snap_id).unwrap();
let restored_env_bytes = fs::read(env_dir.join("environment.json")).unwrap();
assert_ne!(
restored_env_bytes, corrupted,
"should not be corrupted anymore"
);
let restored_env: Environment = serde_json::from_slice(&restored_env_bytes).unwrap();
restored_env.validate().unwrap();
assert_eq!(restored_env.environment_id, env_id);
assert_eq!(
fs::read(env_dir.join("runtime.json")).unwrap(),
runtime_content
);
assert_eq!(
fs::read(env_dir.join(TRUST_ROOT_FILE)).unwrap(),
trust_root_content
);
assert_eq!(
fs::read(answers_dir.join("answers.json")).unwrap(),
answers_content
);
assert!(
!env_dir.join("messaging").join("stray.json").exists(),
"stray messaging file should be removed"
);
}
#[test]
fn meaningful_absence_trust_root() {
let tmp = TempDir::new().unwrap();
let (store, env_id, _env) = seed_env(&tmp);
let env_dir = tmp.path().join(env_id.as_str());
assert!(!env_dir.join(TRUST_ROOT_FILE).exists());
let snap_id = snapshot_environment(&store, &env_id).unwrap();
fs::write(
env_dir.join(TRUST_ROOT_FILE),
br#"{"schema":"greentic.trust-root.v1","keys":[]}"#,
)
.unwrap();
assert!(env_dir.join(TRUST_ROOT_FILE).exists());
restore_environment(&store, &env_id, &snap_id).unwrap();
assert!(
!env_dir.join(TRUST_ROOT_FILE).exists(),
"trust-root.json should be absent after restoring a snapshot that lacked it"
);
}
#[test]
fn meaningful_absence_runtime_config() {
let tmp = TempDir::new().unwrap();
let (store, env_id, _env) = seed_env(&tmp);
let env_dir = tmp.path().join(env_id.as_str());
assert!(!env_dir.join("runtime-config.json").exists());
let snap_id = snapshot_environment(&store, &env_id).unwrap();
fs::write(env_dir.join("runtime-config.json"), b"{}").unwrap();
restore_environment(&store, &env_id, &snap_id).unwrap();
assert!(
!env_dir.join("runtime-config.json").exists(),
"runtime-config.json should be absent after restoring a snapshot without one"
);
}
#[test]
fn restore_with_secret_refs_passes_validate() {
let tmp = TempDir::new().unwrap();
let (store, env_id, mut env) = seed_env(&tmp);
env.credentials_ref =
Some(SecretRef::try_new(format!("secret://{}/credentials/main", env_id)).unwrap());
env.messaging_endpoints
.push(test_messaging_endpoint(&env_id));
store.save(&env).unwrap();
store
.transact(&env_id, |locked| locked.refresh_messaging_projection(&env))
.unwrap();
let snap_id = snapshot_environment(&store, &env_id).unwrap();
restore_environment(&store, &env_id, &snap_id).unwrap();
let restored: Environment = store.load(&env_id).unwrap();
restored.validate().unwrap();
if let Some(cred) = &restored.credentials_ref {
assert_eq!(cred.env_segment(), env_id.as_str());
}
for ep in &restored.messaging_endpoints {
for sr in &ep.secret_refs {
assert_eq!(sr.env_segment(), env_id.as_str());
}
if let Some(wsr) = &ep.webhook_secret_ref {
assert_eq!(wsr.env_segment(), env_id.as_str());
}
}
}
#[test]
fn snapshot_id_is_unique() {
let a = SnapshotId::new();
let b = SnapshotId::new();
assert_ne!(a, b);
}
#[test]
fn is_safe_rel_path_rejects_escapes() {
assert!(is_safe_rel_path("environment.json"));
assert!(is_safe_rel_path("env-packs/deployer/answers.json"));
assert!(is_safe_rel_path("messaging/tg.json"));
assert!(!is_safe_rel_path("../other-env/environment.json"));
assert!(!is_safe_rel_path("/etc/passwd"));
assert!(!is_safe_rel_path("a/../../b"));
}
}