1use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::cli::{LifecycleBackupCommand, LifecycleCommand, LifecycleRestoreCommand, OutputFormat};
9use crate::client::http::{ClientError, HttpClient};
10use crate::error::{CliError, Result};
11use crate::models::{Column, DataType, Row, Value};
12use crate::output::formatter::{create_formatter, Formatter};
13use reqwest::StatusCode;
14use serde::{Deserialize, Serialize};
15use serde_json::Value as JsonValue;
16
17#[derive(Debug, Clone, Copy)]
18pub enum SupportLevel {
19 Supported,
20 Unsupported,
21 Unknown,
22}
23
24#[derive(Debug, Clone, Copy)]
25pub struct RemoteLifecycleSupport {
26 pub backup: SupportLevel,
27 pub restore: SupportLevel,
28}
29
30impl RemoteLifecycleSupport {
31 pub fn unknown() -> Self {
32 Self {
33 backup: SupportLevel::Unknown,
34 restore: SupportLevel::Unknown,
35 }
36 }
37}
38
39pub fn execute_with_formatter<W: Write>(
40 command: &LifecycleCommand,
41 data_dir: Option<&Path>,
42 writer: &mut W,
43 mut formatter: Box<dyn Formatter>,
44) -> Result<()> {
45 let message = perform_lifecycle_action(command, data_dir)?;
46 let columns = vec![
47 Column::new("Status", DataType::Text),
48 Column::new("Message", DataType::Text),
49 ];
50 let rows = vec![Row::new(vec![
51 Value::Text("OK".to_string()),
52 Value::Text(message),
53 ])];
54 formatter.write_header(writer, &columns)?;
55 for row in &rows {
56 formatter.write_row(writer, row)?;
57 }
58 formatter.write_footer(writer)
59}
60
61#[derive(Serialize)]
62struct RemoteLegacyRequest {
63 action: String,
64}
65
66#[derive(Deserialize)]
67struct RemoteLegacyResponse {
68 status: String,
69 message: String,
70}
71
72#[derive(Serialize)]
73struct RemoteRestoreRequest {
74 #[serde(skip_serializing_if = "Option::is_none")]
75 source: Option<String>,
76}
77
78#[derive(Debug, Deserialize)]
79struct RemoteLifecycleStatusResponse {
80 status: Option<String>,
81 handle: Option<String>,
82 state: Option<JsonValue>,
83 location: Option<String>,
84 message: Option<String>,
85 reason: Option<String>,
86 error: Option<String>,
87 metadata: Option<JsonValue>,
88}
89
90pub async fn execute_remote_with_formatter<W: Write>(
91 client: &HttpClient,
92 command: &LifecycleCommand,
93 support: RemoteLifecycleSupport,
94 writer: &mut W,
95 mut formatter: Box<dyn Formatter>,
96) -> Result<()> {
97 match command {
98 LifecycleCommand::Archive => {
99 execute_remote_legacy(client, "archive", writer, &mut formatter).await
100 }
101 LifecycleCommand::Export => {
102 execute_remote_legacy(client, "export", writer, &mut formatter).await
103 }
104 LifecycleCommand::Backup { command } => match command {
105 None => {
106 ensure_supported(support.backup, "backup")?;
107 let response: RemoteLifecycleStatusResponse = client
108 .post_json("api/admin/backup", &serde_json::json!({}))
109 .await
110 .map_err(|err| map_remote_error(err, "backup"))?;
111 write_remote_status(writer, &mut formatter, response)
112 }
113 Some(LifecycleBackupCommand::Status { handle }) => {
114 ensure_supported(support.backup, "backup status")?;
115 let handle = handle.trim();
116 if handle.is_empty() {
117 return Err(CliError::InvalidArgument(
118 "Backup handle must be provided.".to_string(),
119 ));
120 }
121 let path = format!("api/admin/backup/{handle}");
122 let mut response: RemoteLifecycleStatusResponse = client
123 .get_json(&path)
124 .await
125 .map_err(|err| map_remote_error(err, "backup status"))?;
126 if response.handle.is_none() {
127 response.handle = Some(handle.to_string());
128 }
129 write_remote_status(writer, &mut formatter, response)
130 }
131 },
132 LifecycleCommand::Restore { command, source } => match command {
133 None => {
134 ensure_supported(support.restore, "restore")?;
135 let source = source
136 .as_ref()
137 .map(|value| value.trim())
138 .filter(|value| !value.is_empty())
139 .map(|value| value.to_string());
140 let request = RemoteRestoreRequest { source };
141 let response: RemoteLifecycleStatusResponse = client
142 .post_json("api/admin/restore", &request)
143 .await
144 .map_err(|err| map_remote_error(err, "restore"))?;
145 write_remote_status(writer, &mut formatter, response)
146 }
147 Some(LifecycleRestoreCommand::Status { handle }) => {
148 ensure_supported(support.restore, "restore status")?;
149 let handle = handle.trim();
150 if handle.is_empty() {
151 return Err(CliError::InvalidArgument(
152 "Restore handle must be provided.".to_string(),
153 ));
154 }
155 let path = format!("api/admin/restore/{handle}");
156 let mut response: RemoteLifecycleStatusResponse = client
157 .get_json(&path)
158 .await
159 .map_err(|err| map_remote_error(err, "restore status"))?;
160 if response.handle.is_none() {
161 response.handle = Some(handle.to_string());
162 }
163 write_remote_status(writer, &mut formatter, response)
164 }
165 },
166 }
167}
168
169pub fn execute<W: Write>(
170 command: &LifecycleCommand,
171 data_dir: Option<&Path>,
172 writer: &mut W,
173 output: OutputFormat,
174) -> Result<()> {
175 let formatter = create_formatter(output);
176 execute_with_formatter(command, data_dir, writer, formatter)
177}
178
179fn map_client_error(err: ClientError) -> CliError {
180 match err {
181 ClientError::Request { source, .. } => {
182 CliError::ServerConnection(format!("request failed: {source}"))
183 }
184 ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
185 ClientError::Build(message) => CliError::InvalidArgument(message),
186 ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
187 ClientError::HttpStatus { status, body } => {
188 CliError::ServerConnection(format!("server error {status}: {body}"))
189 }
190 }
191}
192
193async fn execute_remote_legacy<W: Write>(
194 client: &HttpClient,
195 action: &str,
196 writer: &mut W,
197 formatter: &mut Box<dyn Formatter>,
198) -> Result<()> {
199 let request = RemoteLegacyRequest {
200 action: action.to_string(),
201 };
202 let response: RemoteLegacyResponse = client
203 .post_json("api/admin/lifecycle", &request)
204 .await
205 .map_err(map_client_error)?;
206
207 let columns = vec![
208 Column::new("Status", DataType::Text),
209 Column::new("Message", DataType::Text),
210 ];
211 let rows = vec![Row::new(vec![
212 Value::Text(response.status),
213 Value::Text(response.message),
214 ])];
215 formatter.write_header(writer, &columns)?;
216 for row in &rows {
217 formatter.write_row(writer, row)?;
218 }
219 formatter.write_footer(writer)
220}
221
222fn write_remote_status<W: Write>(
223 writer: &mut W,
224 formatter: &mut Box<dyn Formatter>,
225 response: RemoteLifecycleStatusResponse,
226) -> Result<()> {
227 let columns = remote_status_columns();
228 let rows = vec![remote_status_row(response)];
229 formatter.write_header(writer, &columns)?;
230 for row in &rows {
231 formatter.write_row(writer, row)?;
232 }
233 formatter.write_footer(writer)
234}
235
236fn remote_status_columns() -> Vec<Column> {
237 vec![
238 Column::new("Status", DataType::Text),
239 Column::new("Handle", DataType::Text),
240 Column::new("State", DataType::Text),
241 Column::new("Location", DataType::Text),
242 Column::new("Metadata", DataType::Text),
243 Column::new("Message", DataType::Text),
244 ]
245}
246
247fn remote_status_row(response: RemoteLifecycleStatusResponse) -> Row {
248 let status = resolve_status(&response);
249 let message = resolve_message(&response);
250 Row::new(vec![
251 Value::Text(status),
252 text_or_null(response.handle),
253 text_or_null(state_label(&response.state)),
254 text_or_null(response.location),
255 metadata_to_value(response.metadata),
256 text_or_null(message),
257 ])
258}
259
260fn resolve_status(response: &RemoteLifecycleStatusResponse) -> String {
261 if let Some(status) = response
262 .status
263 .as_ref()
264 .map(|value| value.trim())
265 .filter(|value| !value.is_empty())
266 {
267 return status.to_string();
268 }
269 if let Some(state) = state_label(&response.state)
270 .as_ref()
271 .map(|value| value.trim().to_string())
272 .filter(|value| !value.is_empty())
273 {
274 if is_failure_state(&state) {
275 return "Error".to_string();
276 }
277 }
278 "OK".to_string()
279}
280
281fn resolve_message(response: &RemoteLifecycleStatusResponse) -> Option<String> {
282 for candidate in [&response.message, &response.reason, &response.error] {
283 if let Some(value) = candidate
284 .as_ref()
285 .map(|value| value.trim())
286 .filter(|value| !value.is_empty())
287 {
288 return Some(value.to_string());
289 }
290 }
291 if let Some(reason) = state_reason(&response.state) {
292 return Some(reason);
293 }
294 None
295}
296
297fn is_failure_state(state: &str) -> bool {
298 matches!(
299 state.to_lowercase().as_str(),
300 "failed" | "error" | "failure"
301 )
302}
303
304fn state_label(state: &Option<JsonValue>) -> Option<String> {
305 let value = state.as_ref()?;
306 match value {
307 JsonValue::String(text) => Some(text.clone()),
308 JsonValue::Object(map) => map
309 .get("status")
310 .and_then(|status| status.as_str())
311 .map(|status| status.to_string())
312 .or_else(|| Some(value.to_string())),
313 _ => Some(value.to_string()),
314 }
315}
316
317fn state_reason(state: &Option<JsonValue>) -> Option<String> {
318 let JsonValue::Object(map) = state.as_ref()? else {
319 return None;
320 };
321 map.get("reason")
322 .and_then(|value| value.as_str())
323 .map(|value| value.to_string())
324}
325
326fn text_or_null(value: Option<String>) -> Value {
327 match value {
328 Some(value) if !value.trim().is_empty() => Value::Text(value),
329 _ => Value::Null,
330 }
331}
332
333fn metadata_to_value(metadata: Option<JsonValue>) -> Value {
334 match metadata {
335 Some(value) => Value::Text(value.to_string()),
336 None => Value::Null,
337 }
338}
339
340fn ensure_supported(level: SupportLevel, action: &str) -> Result<()> {
341 if matches!(level, SupportLevel::Unsupported) {
342 return Err(CliError::ServerUnsupported(unsupported_message(action)));
343 }
344 Ok(())
345}
346
347fn map_remote_error(err: ClientError, action: &str) -> CliError {
348 match err {
349 ClientError::HttpStatus { status, .. } if is_unsupported_status(status) => {
350 CliError::ServerUnsupported(unsupported_message(action))
351 }
352 _ => map_client_error(err),
353 }
354}
355
356fn is_unsupported_status(status: StatusCode) -> bool {
357 matches!(
358 status,
359 StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED | StatusCode::NOT_IMPLEMENTED
360 )
361}
362
363fn unsupported_message(action: &str) -> String {
364 format!(
365 "Remote {action} requires a server that supports admin API v0.5. Upgrade the server or use archive/export instead."
366 )
367}
368
369fn perform_lifecycle_action(command: &LifecycleCommand, data_dir: Option<&Path>) -> Result<String> {
370 let data_dir = data_dir.ok_or_else(|| {
371 CliError::InvalidArgument("Lifecycle actions require a local data directory.".to_string())
372 })?;
373 if !data_dir.exists() {
374 return Err(CliError::InvalidArgument(format!(
375 "Data directory does not exist: {}",
376 data_dir.display()
377 )));
378 }
379 if !data_dir.is_dir() {
380 return Err(CliError::InvalidArgument(format!(
381 "Data directory is not a directory: {}",
382 data_dir.display()
383 )));
384 }
385
386 let lifecycle_root = data_dir.join(".lifecycle");
387 fs::create_dir_all(&lifecycle_root)?;
388
389 match command {
390 LifecycleCommand::Archive => {
391 let dest = lifecycle_root.join("archive").join(timestamp_dir());
392 copy_data_dir(data_dir, &dest)?;
393 write_latest_marker(&lifecycle_root.join("archive"), &dest)?;
394 Ok(format!("Archived data to {}", dest.display()))
395 }
396 LifecycleCommand::Restore { command, .. } => {
397 if command.is_some() {
398 return Err(CliError::InvalidArgument(
399 "Restore status is only available for server profiles.".to_string(),
400 ));
401 }
402 let archive_root = lifecycle_root.join("archive");
403 let latest = read_latest_marker(&archive_root)?;
404 let backup_dir = lifecycle_root.join("restore-backup").join(timestamp_dir());
405 copy_data_dir(data_dir, &backup_dir)?;
406 clear_data_dir(data_dir)?;
407 copy_data_dir(&latest, data_dir)?;
408 Ok(format!(
409 "Restored data from {} (backup at {})",
410 latest.display(),
411 backup_dir.display()
412 ))
413 }
414 LifecycleCommand::Backup { command } => {
415 if command.is_some() {
416 return Err(CliError::InvalidArgument(
417 "Backup status is only available for server profiles.".to_string(),
418 ));
419 }
420 let dest = lifecycle_root.join("backup").join(timestamp_dir());
421 copy_data_dir(data_dir, &dest)?;
422 write_latest_marker(&lifecycle_root.join("backup"), &dest)?;
423 Ok(format!("Backup created at {}", dest.display()))
424 }
425 LifecycleCommand::Export => {
426 let dest = lifecycle_root.join("export").join(timestamp_dir());
427 copy_data_dir(data_dir, &dest)?;
428 write_latest_marker(&lifecycle_root.join("export"), &dest)?;
429 Ok(format!("Exported data to {}", dest.display()))
430 }
431 }
432}
433
434fn timestamp_dir() -> String {
435 let seconds = SystemTime::now()
436 .duration_since(UNIX_EPOCH)
437 .unwrap_or_default()
438 .as_secs();
439 format!("ts-{seconds}")
440}
441
442fn copy_data_dir(src: &Path, dest: &Path) -> Result<()> {
443 fs::create_dir_all(dest)?;
444 copy_dir_filtered(src, dest)
445}
446
447fn copy_dir_filtered(src: &Path, dest: &Path) -> Result<()> {
448 for entry in fs::read_dir(src)? {
449 let entry = entry?;
450 let file_type = entry.file_type()?;
451 let name = entry.file_name();
452 if name == ".lifecycle" {
453 continue;
454 }
455 let src_path = entry.path();
456 let dest_path = dest.join(&name);
457 if file_type.is_dir() {
458 fs::create_dir_all(&dest_path)?;
459 copy_dir_filtered(&src_path, &dest_path)?;
460 } else if file_type.is_file() {
461 fs::copy(&src_path, &dest_path)?;
462 }
463 }
464 Ok(())
465}
466
467fn clear_data_dir(data_dir: &Path) -> Result<()> {
468 for entry in fs::read_dir(data_dir)? {
469 let entry = entry?;
470 let name = entry.file_name();
471 if name == ".lifecycle" {
472 continue;
473 }
474 let path = entry.path();
475 if path.is_dir() {
476 fs::remove_dir_all(&path)?;
477 } else if path.is_file() {
478 fs::remove_file(&path)?;
479 }
480 }
481 Ok(())
482}
483
484fn write_latest_marker(root: &Path, latest: &Path) -> Result<()> {
485 fs::create_dir_all(root)?;
486 let marker = root.join("latest");
487 fs::write(marker, latest.display().to_string().as_bytes())?;
488 Ok(())
489}
490
491fn read_latest_marker(root: &Path) -> Result<PathBuf> {
492 let marker = root.join("latest");
493 if !marker.exists() {
494 return Err(CliError::InvalidArgument(
495 "No archive snapshot found to restore.".to_string(),
496 ));
497 }
498 let path = fs::read_to_string(&marker)?;
499 let path = PathBuf::from(path.trim());
500 if !path.exists() {
501 return Err(CliError::InvalidArgument(format!(
502 "Latest archive path does not exist: {}",
503 path.display()
504 )));
505 }
506 Ok(path)
507}