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::import_export::{run_export, run_import};
9use super::health::run_health_check;
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).map_err(SshCliError::from)?;
129            record.tls = tls;
130            record.tls_sni = tls_sni;
131            record.tls_client_cert = tls_client_cert
132                .map(|p| p.to_string_lossy().into_owned());
133            record.tls_client_key = tls_client_key
134                .map(|p| p.to_string_lossy().into_owned());
135            if record.tls {
136                // Validate options early (SNI empty / partial mTLS).
137                let sni = record
138                    .tls_sni
139                    .as_deref()
140                    .filter(|s| !s.trim().is_empty())
141                    .unwrap_or(record.host.as_str());
142                let _ = crate::tls::TlsConnectOptions::try_new(
143                    sni,
144                    record.tls_client_cert.as_ref().map(std::path::PathBuf::from),
145                    record.tls_client_key.as_ref().map(std::path::PathBuf::from),
146                )?;
147            }
148            // GAP-SSH-VAL-002 / VAL-003: full domain validation on the write-path.
149            record.validate().map_err(SshCliError::from)?;
150            file.hosts.insert(name_key.clone(), record);
151            file.schema_version = model::CURRENT_SCHEMA_VERSION;
152            save(&path, &file)?;
153            // G-E2E-04 / one-shot: single stdout document (fold auto-key into vps-added).
154            // Workload: local single-file CRUD — sequential justified (≪ SSH RTT).
155            let auto_key = take_auto_key_meta();
156            let mut data = serde_json::json!({ "name": name_key });
157            if let Some(ref meta) = auto_key {
158                data["secrets_key_auto_created"] = serde_json::Value::Bool(true);
159                data["key_file"] = serde_json::Value::String(meta.key_file.clone());
160                data["key_source"] = serde_json::Value::String(meta.key_source.to_owned());
161            } else {
162                data["secrets_key_auto_created"] = serde_json::Value::Bool(false);
163            }
164            let msg = if let Some(ref meta) = auto_key {
165                format!(
166                    "{}; primary-key auto-created at {}",
167                    crate::i18n::t(crate::i18n::Message::VpsAdded {
168                        name: name_key.clone(),
169                    }),
170                    meta.key_file
171                )
172            } else {
173                crate::i18n::t(crate::i18n::Message::VpsAdded {
174                    name: name_key.clone(),
175                })
176            };
177            crate::output::emit_success(
178                "vps-added",
179                data,
180                &msg,
181                format == OutputFormat::Json,
182            )?;
183            if check {
184                run_health_check(
185                    HostSelection::Single(name.clone()),
186                    config_override,
187                    format,
188                    false,
189                    None,
190                    None,
191                    None,
192                    None,
193                    false,
194                )
195                .await?;
196            }
197        }
198        VpsAction::List { json, tags } => {
199            let file = load(&path)?;
200            let records: Vec<_> = if tags.is_empty() {
201                file.hosts.values().cloned().collect()
202            } else {
203                {
204                    let wanted = crate::domain::try_tags(&tags)
205                        .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
206                    file.hosts
207                        .values()
208                        .filter(|r| r.has_any_tag(&wanted))
209                        .cloned()
210                        .collect()
211                }
212            };
213            // GAP-SSH-IO-001: respeitar format global.
214            if use_json(json, format) {
215                crate::output::print_list_json(&records)?;
216            } else {
217                crate::output::print_list_text(&records);
218            }
219        }
220        VpsAction::Remove { name } => {
221            let mut file = load(&path)?;
222            if file.hosts.remove(&name).is_none() {
223                return Err(SshCliError::VpsNotFound(name).into());
224            }
225            save(&path, &file)?;
226            // GAP-SSH-STATE-001: clear orphan active marker.
227            clear_active_if_name(&path, &name)?;
228            crate::output::emit_success(
229                "vps-removed",
230                serde_json::json!({ "name": name }),
231                &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
232                format == OutputFormat::Json,
233            )?;
234        }
235        VpsAction::Edit {
236            name,
237            host,
238            port,
239            user,
240            password,
241            password_stdin,
242            key,
243            key_passphrase,
244            use_agent,
245            agent_socket,
246            timeout,
247            max_command_chars,
248            max_output_chars,
249            max_chars,
250            sudo_password,
251            sudo_password_stdin,
252            su_password,
253            su_password_stdin,
254            disable_sudo,
255            enable_sudo,
256            tls,
257            no_tls,
258            tls_sni,
259            tls_client_cert,
260            tls_client_key,
261        } => {
262            let mut file = load(&path)?;
263            let record = file
264                .hosts
265                .get_mut(&name)
266                .ok_or(SshCliError::VpsNotFound(name.clone()))?;
267            use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
268            if let Some(h) = host {
269                record.host = SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
270            }
271            if let Some(p) = port {
272                record.port = SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
273            }
274            if let Some(u) = user {
275                record.username = 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).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
298                    );
299                    record.use_agent = false;
300                }
301                if let Some(kp) = key_passphrase {
302                    record.key_passphrase = Some(SecretString::from(kp));
303                }
304                if let Some(s) = agent_socket {
305                    record.agent_socket = Some(s.to_string_lossy().into_owned());
306                }
307            }
308            if let Some(t) = timeout {
309                record.timeout_ms =
310                    TimeoutMs::try_new(t).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
311            }
312            if let Some(m) = max_command_chars.or(max_chars) {
313                record.max_command_chars =
314                    CharLimit::try_new(m).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
315            }
316            if let Some(m) = max_output_chars {
317                record.max_output_chars =
318                    CharLimit::try_new(m).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
319            }
320            if sudo_password_stdin {
321                record.sudo_password = Some(read_secret_stdin()?);
322            } else if let Some(sp) = sudo_password {
323                record.sudo_password = Some(SecretString::from(sp));
324            }
325            if su_password_stdin {
326                record.su_password = Some(read_secret_stdin()?);
327            } else if let Some(sp) = su_password {
328                record.su_password = Some(SecretString::from(sp));
329            }
330            // G-10: tri-state edit without Option<bool> — exclusive SetTrue flags.
331            if disable_sudo {
332                record.disable_sudo = true;
333            } else if enable_sudo {
334                record.disable_sudo = false;
335            }
336            if tls {
337                record.tls = true;
338            } else if no_tls {
339                record.tls = false;
340            }
341            if let Some(sni) = tls_sni {
342                record.tls_sni = Some(sni);
343            }
344            if let Some(c) = tls_client_cert {
345                record.tls_client_cert = Some(c.to_string_lossy().into_owned());
346            }
347            if let Some(k) = tls_client_key {
348                record.tls_client_key = Some(k.to_string_lossy().into_owned());
349            }
350            if record.tls {
351                let sni = record
352                    .tls_sni
353                    .as_deref()
354                    .filter(|s| !s.trim().is_empty())
355                    .unwrap_or(record.host.as_str());
356                let _ = crate::tls::TlsConnectOptions::try_new(
357                    sni,
358                    record.tls_client_cert.as_ref().map(std::path::PathBuf::from),
359                    record.tls_client_key.as_ref().map(std::path::PathBuf::from),
360                )?;
361            }
362            record.validate().map_err(SshCliError::from)?;
363            save(&path, &file)?;
364            crate::output::emit_success(
365                "vps-edited",
366                serde_json::json!({ "name": name }),
367                &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
368                format == OutputFormat::Json,
369            )?;
370        }
371        VpsAction::Show { name, json } => {
372            let file = load(&path)?;
373            let record = file
374                .hosts
375                .get(&name)
376                .ok_or(SshCliError::VpsNotFound(name.clone()))?;
377            if use_json(json, format) {
378                crate::output::print_details_json(record)?;
379            } else {
380                crate::output::print_details_text(record);
381            }
382        }
383        VpsAction::Path => {
384            // G-AUD-02: JSON envelope when format is Json; plain path in Text.
385            if use_json(false, format) {
386                let path_s = path.display().to_string();
387                crate::output::emit_success(
388                    "vps-path",
389                    serde_json::json!({ "path": path_s }),
390                    &path_s,
391                    true,
392                )?;
393            } else {
394                // G-MAC-01: format_args + write_fmt — no intermediate String for Display.
395                crate::output::write_line_fmt(format_args!("{}", path.display()))?;
396            }
397        }
398        VpsAction::Doctor {
399            json,
400            probe_ssh,
401            hosts,
402        } => {
403            // G-PAR-38/42: single envelope (local + optional ssh_probe); no dual JSON roots.
404            let as_json = use_json(json, format);
405            if hosts.is_some() && !probe_ssh {
406                return Err(SshCliError::InvalidArgument(
407                    "--hosts on vps doctor requires --probe-ssh".into(),
408                )
409                .into());
410            }
411            let selection = if probe_ssh {
412                match hosts {
413                    None => HostSelection::All,
414                    Some(raw) => {
415                        let names = crate::cli::parse_hosts_list(&raw);
416                        if names.is_empty() {
417                            return Err(SshCliError::InvalidArgument(
418                                "--hosts requires at least one host name".into(),
419                            )
420                            .into());
421                        }
422                        let names = names
423                            .into_iter()
424                            .map(crate::domain::VpsName::try_new)
425                            .collect::<Result<Vec<_>, _>>()
426                            .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
427                        HostSelection::Named(names)
428                    }
429                }
430            } else {
431                // unused when !probe_ssh
432                HostSelection::All
433            };
434            run_doctor_with_optional_probe(
435                config_override.as_deref(),
436                as_json,
437                probe_ssh,
438                if probe_ssh { Some(selection) } else { None },
439            )
440            .await?;
441        }
442        VpsAction::Export {
443            include_secrets,
444            output,
445            json,
446            i_understand_secrets_on_stdout,
447        } => {
448            // G-AUD-03: export body JSON when local --json or global format Json.
449            run_export(
450                &path,
451                include_secrets,
452                output.as_deref(),
453                json,
454                i_understand_secrets_on_stdout,
455                format,
456            )?;
457        }
458        VpsAction::Import {
459            file,
460            allow_incomplete,
461        } => {
462            run_import(&path, &file, allow_incomplete, format)?;
463        }
464    }
465    Ok(())
466}
467
468/// Removes the `active` file if its content matches the removed name (STATE-001).
469fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
470    let active = config_path
471        .parent()
472        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
473        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
474    if !active.exists() {
475        return Ok(());
476    }
477    let content = std::fs::read_to_string(&active).unwrap_or_default();
478    if content.trim() == name {
479        let _ = std::fs::remove_file(&active);
480    }
481    Ok(())
482}