Skip to main content

alopex_cli/commands/
lifecycle.rs

1//! Lifecycle command handlers.
2
3use 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}