pharos_native/runtime/
backup.rs1use std::path::{Path, PathBuf};
7
8use crate::config::{self, AppRuntime, ServeConfig};
9
10pub use pharos_runtime_ops::BackupRestoreError;
11
12fn non_empty<'a>(
13 value: Option<&'a str>,
14 error: &'static str,
15) -> Result<&'a str, BackupRestoreError> {
16 value
17 .map(str::trim)
18 .filter(|value| !value.is_empty())
19 .ok_or(BackupRestoreError::InvalidInput(error))
20}
21
22fn app_root(app: &AppRuntime) -> Result<PathBuf, BackupRestoreError> {
23 let value = non_empty(app.app_root.as_deref(), "backup requires an app root")?;
24 let path = PathBuf::from(value);
25 if !path.is_dir() {
26 return Err(BackupRestoreError::InvalidPath(
27 "app root is not a directory",
28 ));
29 }
30 Ok(path)
31}
32
33fn configured_value(app: &AppRuntime, key: &'static str) -> Result<String, BackupRestoreError> {
34 let root = app_root(app)?;
35 config::dynamic_config_effective_value(
36 &root,
37 app.runtime_conf_path.as_deref().map(Path::new),
38 key,
39 )
40 .filter(|value| !value.trim().is_empty())
41 .ok_or(BackupRestoreError::InvalidInput(match key {
42 "db_backend" => "db_backend is required for backup or restore",
43 "db_path" => "db_path is required for backup or restore",
44 "backup_dir" => "backup_dir is required for backup",
45 _ => "required runtime configuration is missing",
46 }))
47}
48
49fn backup_context(
50 config: &ServeConfig,
51 app: &AppRuntime,
52) -> Result<pharos_runtime_ops::BackupBundleContext, BackupRestoreError> {
53 pharos_runtime_ops::backup_bundle_context_from_core_config(config, app)
54 .map_err(|_| BackupRestoreError::InvalidInput("backup runtime configuration is invalid"))
55}
56
57pub fn json_top_level_long(text: &str, key: &str, fallback_value: i64) -> i64 {
58 pharos_runtime_ops::json_top_level_long(text, key, fallback_value)
59}
60
61pub fn write_text_file(path: &Path, content: &str) -> Result<(), BackupRestoreError> {
62 pharos_runtime_ops::write_text_file(path, content)
63}
64
65pub fn default_backend_local(app: &AppRuntime) -> Result<String, BackupRestoreError> {
66 configured_value(app, "db_backend")
67}
68
69pub fn backup_base_dir(app: &AppRuntime) -> Result<PathBuf, BackupRestoreError> {
70 Ok(PathBuf::from(configured_value(app, "backup_dir")?))
71}
72
73pub fn backup_timestamp_utc() -> Result<String, BackupRestoreError> {
74 pharos_runtime_ops::backup_timestamp_utc()
75}
76
77pub fn migration_fixture_path(app: &AppRuntime) -> Result<PathBuf, BackupRestoreError> {
78 pharos_runtime_ops::migration_contract_path_from_manifest(&app_root(app)?)
79}
80
81pub fn migration_schema_version(migration_fixture_path: &Path) -> Result<i64, BackupRestoreError> {
82 pharos_runtime_ops::migration_schema_version(migration_fixture_path)
83}
84
85pub fn write_backup_state_snapshot(
86 app: &AppRuntime,
87 output_path: &Path,
88) -> Result<(), BackupRestoreError> {
89 let state_path = non_empty(app.state_path.as_deref(), "app state_path is required")?;
90 pharos_runtime_ops::write_backup_state_snapshot(Path::new(state_path), output_path)
91}
92
93pub fn metadata_artifact_value(
94 metadata_path: &Path,
95 key: &str,
96) -> Result<String, BackupRestoreError> {
97 pharos_runtime_ops::metadata_artifact_value(metadata_path, key)
98}
99
100pub fn build_backup_report_local_json(
101 config: &ServeConfig,
102 app: &AppRuntime,
103 backend_override: Option<&str>,
104 output_dir_override: Option<&Path>,
105) -> Result<String, BackupRestoreError> {
106 let context = backup_context(config, app)?;
107 let adapter = super::database::operations::resolve(&context.database)
108 .map_err(BackupRestoreError::Database)?;
109 pharos_runtime_ops::build_backup_report_from_context(
110 &context,
111 adapter.as_ref(),
112 backend_override,
113 output_dir_override,
114 )
115}
116
117pub fn build_restore_report_local_json(
118 config: &ServeConfig,
119 app: &AppRuntime,
120 backend_override: Option<&str>,
121 input_dir: &Path,
122) -> Result<String, BackupRestoreError> {
123 let context = backup_context(config, app)?;
124 let adapter = super::database::operations::resolve(&context.database)
125 .map_err(BackupRestoreError::Database)?;
126 pharos_runtime_ops::build_restore_report_from_context(
127 &context,
128 adapter.as_ref(),
129 backend_override,
130 input_dir,
131 )
132}
133
134#[cfg(test)]
135mod tests {
136 use super::migration_fixture_path;
137 use crate::config::AppRuntime;
138 use std::fs;
139 use std::path::{Path, PathBuf};
140 use std::time::{SystemTime, UNIX_EPOCH};
141
142 fn unique_temp_dir(label: &str) -> PathBuf {
143 let nonce = SystemTime::now()
144 .duration_since(UNIX_EPOCH)
145 .expect("clock should be after epoch")
146 .as_nanos();
147 let path = std::env::temp_dir().join(format!(
148 "pharos-backup-test-{label}-{}-{nonce}",
149 std::process::id()
150 ));
151 fs::create_dir_all(&path).expect("temp dir should be created");
152 path
153 }
154
155 fn write_manifest(root: &Path, manifest_text: &str) {
156 fs::write(root.join("pharos.app.json"), manifest_text).expect("manifest should be written");
157 let contracts_dir = root.join("data/contracts");
158 fs::create_dir_all(&contracts_dir).expect("contracts dir should be created");
159 fs::write(
160 contracts_dir.join("migrations.json"),
161 "{\"schema_version\":1}\n",
162 )
163 .expect("migration contract should be written");
164 }
165
166 #[test]
167 fn migration_fixture_path_reads_database_contract_from_built_manifest() {
168 let root = unique_temp_dir("database-contract");
169 write_manifest(
170 &root,
171 r#"{
172 "database": {
173 "migration_contract": "data/contracts/migrations.json"
174 }
175}
176"#,
177 );
178 let app = AppRuntime {
179 app_root: Some(root.to_string_lossy().into_owned()),
180 ..AppRuntime::default()
181 };
182 let fixture =
183 migration_fixture_path(&app).expect("database migration contract should resolve");
184 assert_eq!(
185 fixture,
186 root.join("data/contracts/migrations.json")
187 .canonicalize()
188 .expect("fixture path should canonicalize")
189 );
190 fs::remove_dir_all(&root).expect("temp dir should be removed");
191 }
192
193 #[test]
194 fn migration_fixture_path_rejects_undeclared_legacy_contract() {
195 let root = unique_temp_dir("legacy-contract");
196 write_manifest(
197 &root,
198 r#"{
199 "contracts": {
200 "migration_contract": "data/contracts/migrations.json"
201 }
202}
203"#,
204 );
205 let app = AppRuntime {
206 app_root: Some(root.to_string_lossy().into_owned()),
207 ..AppRuntime::default()
208 };
209 assert!(migration_fixture_path(&app).is_err());
210 fs::remove_dir_all(&root).expect("temp dir should be removed");
211 }
212}