Skip to main content

ssh_cli/vps/
crud.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: VPS CRUD dispatcher extracted from vps/mod (SRP; line budget).
3#![forbid(unsafe_code)]
4//! Dispatcher for `ssh-cli vps …` subcommands.
5
6use super::config_io::{load, resolve_config_path, save, validate_key_path_exists};
7use super::doctor::run_doctor_with_optional_probe;
8use super::health::run_health_check;
9use super::import_export::{run_export, run_import};
10use super::model::{self, VpsRecord};
11use super::secrets_cmd::take_auto_key_meta;
12use super::selection::HostSelection;
13use super::{read_secret_stdin, use_json};
14use crate::cli::{OutputFormat, VpsAction};
15use crate::errors::SshCliError;
16use anyhow::Result;
17use secrecy::SecretString;
18use std::path::{Path, PathBuf};
19
20/// Dispatcher dos subcomandos `vps`.
21pub async fn run_vps_command(
22    action: VpsAction,
23    config_override: Option<PathBuf>,
24    format: OutputFormat,
25) -> Result<()> {
26    let path = resolve_config_path(config_override.as_deref())?;
27
28    match action {
29        VpsAction::Add {
30            name,
31            host,
32            port,
33            user,
34            password,
35            password_stdin,
36            key,
37            key_passphrase,
38            use_agent,
39            agent_socket,
40            timeout,
41            max_command_chars,
42            max_output_chars,
43            max_chars,
44            sudo_password,
45            sudo_password_stdin,
46            su_password,
47            su_password_stdin,
48            disable_sudo,
49            tags,
50            tls,
51            tls_sni,
52            tls_client_cert,
53            tls_client_key,
54            check,
55        } => {
56            // GAP-SSH-VAL-001: validate na fronteira de escrita.
57            let name = crate::paths::validate_and_normalize(&name)
58                .map_err(|e| SshCliError::InvalidArgument(format!("invalid VPS name: {e}")))?;
59            let name_key = name.as_str().to_owned();
60            let mut file = load(&path)?;
61            if file.hosts.contains_key(&name_key) {
62                return Err(SshCliError::VpsDuplicate(name_key).into());
63            }
64            if password_stdin && (sudo_password_stdin || su_password_stdin) {
65                return Err(SshCliError::InvalidArgument(
66                    "only one --*-stdin per one-shot invocation; use vps edit for sudo/su".into(),
67                )
68                .into());
69            }
70            let password = if password_stdin {
71                read_secret_stdin()?
72            } else {
73                SecretString::from(password.unwrap_or_default())
74            };
75            let sudo_s = if sudo_password_stdin {
76                Some(read_secret_stdin()?)
77            } else {
78                sudo_password.map(SecretString::from)
79            };
80            let su_s = if su_password_stdin {
81                Some(read_secret_stdin()?)
82            } else {
83                su_password.map(SecretString::from)
84            };
85            let key = key.map(|p| p.to_string_lossy().into_owned());
86            if let Some(ref k) = key {
87                validate_key_path_exists(k)?;
88            }
89            // legacy max_chars → command if max_command was not set explicitly
90            // (clap already parses `none`/`0`/decimal via parse_cli_char_limit)
91            let max_cmd = max_command_chars
92                .or(max_chars)
93                .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
94            let max_out = max_output_chars.unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
95            // GAP-AUD-009: timeout is milliseconds; warn agents that use "5" meaning seconds.
96            if timeout > 0 && timeout < 1000 {
97                crate::output::print_warning_fmt(format_args!(
98                    "--timeout {timeout} is only {timeout}ms (< 1s); did you mean seconds? Use e.g. --timeout 5000 for 5s"
99                ));
100            }
101            let mut record = VpsRecord::try_new(
102                name.as_str(),
103                host,
104                port,
105                user,
106                password,
107                key,
108                key_passphrase.map(SecretString::from),
109                Some(timeout),
110                Some(max_cmd),
111                Some(max_out),
112                sudo_s,
113                su_s,
114                disable_sudo,
115            )
116            .map_err(SshCliError::InvalidArgument)?;
117            // G-E2E-19: registry auth triplo (password | key | agent).
118            if use_agent {
119                record.use_agent = true;
120                record.password = SecretString::from(String::new());
121                record.key_path = None;
122                record.key_passphrase = None;
123                record.agent_socket = agent_socket.map(|p| p.to_string_lossy().into_owned());
124            }
125            // G-O2: tags for fleet selection (dedupe preserve order).
126            let tag_list = crate::vps::selection::dedupe_host_names(tags);
127            record
128                .set_tags_from_raw(tag_list)
129                .map_err(SshCliError::from)?;
130            record.tls = tls;
131            record.tls_sni = tls_sni;
132            record.tls_client_cert = tls_client_cert.map(|p| p.to_string_lossy().into_owned());
133            record.tls_client_key = tls_client_key.map(|p| p.to_string_lossy().into_owned());
134            if record.tls {
135                // Validate options early (SNI empty / partial mTLS).
136                let sni = record
137                    .tls_sni
138                    .as_deref()
139                    .filter(|s| !s.trim().is_empty())
140                    .unwrap_or(record.host.as_str());
141                let _ = crate::tls::TlsConnectOptions::try_new(
142                    sni,
143                    record
144                        .tls_client_cert
145                        .as_ref()
146                        .map(std::path::PathBuf::from),
147                    record.tls_client_key.as_ref().map(std::path::PathBuf::from),
148                )?;
149            }
150            // GAP-SSH-VAL-002 / VAL-003: full domain validation on the write-path.
151            record.validate().map_err(SshCliError::from)?;
152            file.hosts.insert(name_key.clone(), record);
153            file.schema_version = model::CURRENT_SCHEMA_VERSION;
154            save(&path, &file)?;
155            // G-E2E-04 / one-shot: single stdout document (fold auto-key into vps-added).
156            // Workload: local single-file CRUD — sequential justified (≪ SSH RTT).
157            let auto_key = take_auto_key_meta();
158            let mut data = serde_json::json!({ "name": name_key });
159            if let Some(ref meta) = auto_key {
160                data["secrets_key_auto_created"] = serde_json::Value::Bool(true);
161                data["key_file"] = serde_json::Value::String(meta.key_file.clone());
162                data["key_source"] = serde_json::Value::String(meta.key_source.to_owned());
163            } else {
164                data["secrets_key_auto_created"] = serde_json::Value::Bool(false);
165            }
166            let msg = if let Some(ref meta) = auto_key {
167                format!(
168                    "{}; primary-key auto-created at {}",
169                    crate::i18n::t(crate::i18n::Message::VpsAdded {
170                        name: name_key.clone(),
171                    }),
172                    meta.key_file
173                )
174            } else {
175                crate::i18n::t(crate::i18n::Message::VpsAdded {
176                    name: name_key.clone(),
177                })
178            };
179            crate::output::emit_success("vps-added", data, &msg, format == OutputFormat::Json)?;
180            if check {
181                run_health_check(
182                    HostSelection::Single(name.clone()),
183                    config_override,
184                    format,
185                    false,
186                    None,
187                    None,
188                    None,
189                    None,
190                    false,
191                )
192                .await?;
193            }
194        }
195        VpsAction::List { json, tags } => {
196            let file = load(&path)?;
197            let records: Vec<_> = if tags.is_empty() {
198                file.hosts.values().cloned().collect()
199            } else {
200                {
201                    let wanted = crate::domain::try_tags(&tags)
202                        .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
203                    file.hosts
204                        .values()
205                        .filter(|r| r.has_any_tag(&wanted))
206                        .cloned()
207                        .collect()
208                }
209            };
210            // GAP-SSH-IO-001: respeitar format global.
211            if use_json(json, format) {
212                crate::output::print_list_json(&records)?;
213            } else {
214                crate::output::print_list_text(&records);
215            }
216        }
217        VpsAction::Remove { name } => {
218            let mut file = load(&path)?;
219            if file.hosts.remove(&name).is_none() {
220                return Err(SshCliError::VpsNotFound(name).into());
221            }
222            save(&path, &file)?;
223            // GAP-SSH-STATE-001: clear orphan active marker.
224            clear_active_if_name(&path, &name)?;
225            crate::output::emit_success(
226                "vps-removed",
227                serde_json::json!({ "name": name }),
228                &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
229                format == OutputFormat::Json,
230            )?;
231        }
232        VpsAction::Edit {
233            name,
234            host,
235            port,
236            user,
237            password,
238            password_stdin,
239            key,
240            key_passphrase,
241            use_agent,
242            agent_socket,
243            timeout,
244            max_command_chars,
245            max_output_chars,
246            max_chars,
247            sudo_password,
248            sudo_password_stdin,
249            su_password,
250            su_password_stdin,
251            disable_sudo,
252            enable_sudo,
253            tls,
254            no_tls,
255            tls_sni,
256            tls_client_cert,
257            tls_client_key,
258        } => {
259            let mut file = load(&path)?;
260            let record = file
261                .hosts
262                .get_mut(&name)
263                .ok_or(SshCliError::VpsNotFound(name.clone()))?;
264            use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
265            if let Some(h) = host {
266                record.host =
267                    SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
268            }
269            if let Some(p) = port {
270                record.port =
271                    SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
272            }
273            if let Some(u) = user {
274                record.username =
275                    SshUser::try_new(u).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
276            }
277            if use_agent {
278                record.use_agent = true;
279                record.password = SecretString::from(String::new());
280                record.key_path = None;
281                record.key_passphrase = None;
282                if let Some(s) = agent_socket {
283                    record.agent_socket = Some(s.to_string_lossy().into_owned());
284                }
285            } else {
286                if password_stdin {
287                    record.password = read_secret_stdin()?;
288                    record.use_agent = false;
289                } else if let Some(pw) = password {
290                    record.password = SecretString::from(pw);
291                    record.use_agent = false;
292                }
293                if let Some(k) = key {
294                    let k = k.to_string_lossy().into_owned();
295                    validate_key_path_exists(&k)?;
296                    record.key_path = Some(
297                        KeyPath::try_new(k)
298                            .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
299                    );
300                    record.use_agent = false;
301                }
302                if let Some(kp) = key_passphrase {
303                    record.key_passphrase = Some(SecretString::from(kp));
304                }
305                if let Some(s) = agent_socket {
306                    record.agent_socket = Some(s.to_string_lossy().into_owned());
307                }
308            }
309            if let Some(t) = timeout {
310                record.timeout_ms = TimeoutMs::try_new(t)
311                    .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
312            }
313            if let Some(m) = max_command_chars.or(max_chars) {
314                record.max_command_chars = CharLimit::try_new(m)
315                    .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
316            }
317            if let Some(m) = max_output_chars {
318                record.max_output_chars = CharLimit::try_new(m)
319                    .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
320            }
321            if sudo_password_stdin {
322                record.sudo_password = Some(read_secret_stdin()?);
323            } else if let Some(sp) = sudo_password {
324                record.sudo_password = Some(SecretString::from(sp));
325            }
326            if su_password_stdin {
327                record.su_password = Some(read_secret_stdin()?);
328            } else if let Some(sp) = su_password {
329                record.su_password = Some(SecretString::from(sp));
330            }
331            // G-10: tri-state edit without Option<bool> — exclusive SetTrue flags.
332            if disable_sudo {
333                record.disable_sudo = true;
334            } else if enable_sudo {
335                record.disable_sudo = false;
336            }
337            if tls {
338                record.tls = true;
339            } else if no_tls {
340                record.tls = false;
341            }
342            if let Some(sni) = tls_sni {
343                record.tls_sni = Some(sni);
344            }
345            if let Some(c) = tls_client_cert {
346                record.tls_client_cert = Some(c.to_string_lossy().into_owned());
347            }
348            if let Some(k) = tls_client_key {
349                record.tls_client_key = Some(k.to_string_lossy().into_owned());
350            }
351            if record.tls {
352                let sni = record
353                    .tls_sni
354                    .as_deref()
355                    .filter(|s| !s.trim().is_empty())
356                    .unwrap_or(record.host.as_str());
357                let _ = crate::tls::TlsConnectOptions::try_new(
358                    sni,
359                    record
360                        .tls_client_cert
361                        .as_ref()
362                        .map(std::path::PathBuf::from),
363                    record.tls_client_key.as_ref().map(std::path::PathBuf::from),
364                )?;
365            }
366            record.validate().map_err(SshCliError::from)?;
367            save(&path, &file)?;
368            crate::output::emit_success(
369                "vps-edited",
370                serde_json::json!({ "name": name }),
371                &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
372                format == OutputFormat::Json,
373            )?;
374        }
375        VpsAction::Show { name, json } => {
376            let file = load(&path)?;
377            let record = file
378                .hosts
379                .get(&name)
380                .ok_or(SshCliError::VpsNotFound(name.clone()))?;
381            if use_json(json, format) {
382                crate::output::print_details_json(record)?;
383            } else {
384                crate::output::print_details_text(record);
385            }
386        }
387        VpsAction::Path => {
388            // G-AUD-02: JSON envelope when format is Json; plain path in Text.
389            if use_json(false, format) {
390                let path_s = path.display().to_string();
391                crate::output::emit_success(
392                    "vps-path",
393                    serde_json::json!({ "path": path_s }),
394                    &path_s,
395                    true,
396                )?;
397            } else {
398                // G-MAC-01: format_args + write_fmt — no intermediate String for Display.
399                crate::output::write_line_fmt(format_args!("{}", path.display()))?;
400            }
401        }
402        VpsAction::Doctor {
403            json,
404            probe_ssh,
405            hosts,
406        } => {
407            // G-PAR-38/42: single envelope (local + optional ssh_probe); no dual JSON roots.
408            let as_json = use_json(json, format);
409            if hosts.is_some() && !probe_ssh {
410                return Err(SshCliError::InvalidArgument(
411                    "--hosts on vps doctor requires --probe-ssh".into(),
412                )
413                .into());
414            }
415            let selection = if probe_ssh {
416                match hosts {
417                    None => HostSelection::All,
418                    Some(raw) => {
419                        let names = crate::cli::parse_hosts_list(&raw);
420                        if names.is_empty() {
421                            return Err(SshCliError::InvalidArgument(
422                                "--hosts requires at least one host name".into(),
423                            )
424                            .into());
425                        }
426                        let names = names
427                            .into_iter()
428                            .map(crate::domain::VpsName::try_new)
429                            .collect::<Result<Vec<_>, _>>()
430                            .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
431                        HostSelection::Named(names)
432                    }
433                }
434            } else {
435                // unused when !probe_ssh
436                HostSelection::All
437            };
438            run_doctor_with_optional_probe(
439                config_override.as_deref(),
440                as_json,
441                probe_ssh,
442                if probe_ssh { Some(selection) } else { None },
443            )
444            .await?;
445        }
446        VpsAction::Export {
447            include_secrets,
448            output,
449            json,
450            i_understand_secrets_on_stdout,
451        } => {
452            // G-AUD-03: export body JSON when local --json or global format Json.
453            run_export(
454                &path,
455                include_secrets,
456                output.as_deref(),
457                json,
458                i_understand_secrets_on_stdout,
459                format,
460            )?;
461        }
462        VpsAction::Import {
463            file,
464            allow_incomplete,
465        } => {
466            run_import(&path, &file, allow_incomplete, format)?;
467        }
468    }
469    Ok(())
470}
471
472/// Removes the `active` file if its content matches the removed name (STATE-001).
473fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
474    let active = config_path
475        .parent()
476        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
477        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
478    if !active.exists() {
479        return Ok(());
480    }
481    let content = std::fs::read_to_string(&active).unwrap_or_default();
482    if content.trim() == name {
483        let _ = std::fs::remove_file(&active);
484    }
485    Ok(())
486}