Skip to main content

xianyun_cli/commands/
updateetchosts.rs

1//! `caproni update-etc-hosts`: manage /etc/hosts entries for the cluster.
2//!
3//! Port of Go's `cmd/updateetchosts` package.
4
5use anyhow::Context as _;
6use clap::Args;
7use xianyun_backends::hosts::{self, Backend as HostsBackend};
8use xianyun_cluster::driver::State;
9use xianyun_core::config::caproni::load_from_context;
10use xianyun_core::config::container::Container;
11use xianyun_core::config::global::Context;
12
13#[derive(Args, Debug)]
14pub struct UpdateEtcHostsArgs {
15    /// Show what would be changed without modifying /etc/hosts.
16    #[arg(long = "dry-run")]
17    pub dry_run: bool,
18
19    /// Remove hostnames from /etc/hosts instead of adding them.
20    #[arg(long = "remove")]
21    pub remove: bool,
22
23    /// IP address to use (default: auto-detect from cluster).
24    #[arg(long = "ip")]
25    pub ip: Option<String>,
26}
27
28/// Port of `updateetchosts.ErrNoHostnames`/`ErrClusterNoIP`/`ErrClusterStopped`.
29#[derive(Debug, thiserror::Error)]
30pub enum UpdateEtcHostsError {
31    #[error("no hostnames configured in network section")]
32    NoHostnames,
33    #[error("cluster has no IP address (state: {0:?})")]
34    ClusterNoIP(Option<State>),
35    #[error("cluster is not running, run 'caproni up' to start it")]
36    ClusterStopped,
37}
38
39/// Port of Go's `writeFunc`.
40pub type WriteFn = dyn Fn(&str) -> anyhow::Result<()>;
41
42pub fn run(args: &UpdateEtcHostsArgs, ctx: &Context) -> anyhow::Result<()> {
43    run_update_etc_hosts(
44        ctx,
45        args.dry_run,
46        args.remove,
47        args.ip.as_deref().unwrap_or_default(),
48        &hosts::Client::new(),
49        &write_etc_hosts_via_sudo,
50    )
51}
52
53/// Port of `updateetchosts.runUpdateEtcHosts`.
54///
55/// # Errors
56///
57/// Returns [`UpdateEtcHostsError::NoHostnames`] if no hostnames are
58/// configured; an error if configuration loading, resolving the cluster
59/// IP, validating, computing the update/removal, or writing fails.
60pub fn run_update_etc_hosts(
61    ctx: &Context,
62    dry_run: bool,
63    remove: bool,
64    ip_address: &str,
65    backend: &dyn HostsBackend,
66    write_fn: &WriteFn,
67) -> anyhow::Result<()> {
68    crate::drivers::ensure_registered();
69
70    let container = load_from_context(ctx).context("loading configuration")?;
71
72    let hostnames = container
73        .caproni
74        .cluster
75        .get_all_hostnames(&container.global.profile_name);
76
77    if hostnames.is_empty() {
78        return Err(UpdateEtcHostsError::NoHostnames.into());
79    }
80
81    if remove {
82        return run_remove(backend, &hostnames, dry_run, write_fn);
83    }
84
85    let cluster_ip = get_cluster_ip(&container, ip_address)?;
86
87    let hosts_config = hosts::HostsConfig {
88        ip_address: cluster_ip,
89        hostnames,
90    };
91
92    run_update(backend, &hosts_config, dry_run, write_fn)
93}
94
95/// Port of `updateetchosts.getClusterIP`.
96fn get_cluster_ip(container: &Container, ip_address: &str) -> anyhow::Result<String> {
97    if !ip_address.is_empty() {
98        tracing::info!(ip = ip_address, "using provided IP address");
99        return Ok(ip_address.to_string());
100    }
101
102    let manager = xianyun_cluster::registry::new_manager(
103        container.caproni.cluster.clone(),
104        container.global.profile_name.clone(),
105    )
106    .context("creating cluster manager")?;
107
108    let status = manager.status().context("getting cluster status")?;
109
110    if status.state == Some(State::Stopped) {
111        return Err(UpdateEtcHostsError::ClusterStopped.into());
112    }
113
114    if status.ip_address.is_empty() {
115        return Err(UpdateEtcHostsError::ClusterNoIP(status.state).into());
116    }
117
118    let ip = status.host_ip;
119
120    tracing::info!(ip = %ip, "auto-detected cluster IP address");
121
122    Ok(ip)
123}
124
125/// Port of `updateetchosts.runUpdate`.
126fn run_update(
127    backend: &dyn HostsBackend,
128    hosts_config: &hosts::HostsConfig,
129    dry_run: bool,
130    write_fn: &WriteFn,
131) -> anyhow::Result<()> {
132    let issues = backend
133        .validate(hosts_config)
134        .context("validating /etc/hosts")?;
135
136    if issues.is_empty() {
137        tracing::info!("all hostnames are already correctly configured in /etc/hosts");
138        return Ok(());
139    }
140
141    display_issues_table(&issues);
142
143    if dry_run {
144        display_dry_run_info(hosts_config);
145        return Ok(());
146    }
147
148    let content = backend
149        .update(hosts_config)
150        .context("computing /etc/hosts update")?;
151
152    tracing::info!(hostnames = ?hosts_config.hostnames, ip = %hosts_config.ip_address, "updating /etc/hosts");
153
154    write_fn(&content)?;
155
156    tracing::info!("successfully updated /etc/hosts");
157
158    Ok(())
159}
160
161/// Port of `updateetchosts.runRemove`.
162fn run_remove(
163    backend: &dyn HostsBackend,
164    hostnames: &[String],
165    dry_run: bool,
166    write_fn: &WriteFn,
167) -> anyhow::Result<()> {
168    if dry_run {
169        tracing::info!(hostnames = ?hostnames, "dry-run mode: would remove the following hostnames");
170        tracing::info!("run without --dry-run to apply changes");
171        return Ok(());
172    }
173
174    let content = backend
175        .remove(hostnames)
176        .context("computing /etc/hosts removal")?;
177
178    write_fn(&content)?;
179
180    tracing::info!(
181        count = hostnames.len(),
182        "successfully removed hostnames from /etc/hosts"
183    );
184
185    Ok(())
186}
187
188/// Port of `updateetchosts.writeEtcHostsViaSudo`: writes content to
189/// `/etc/hosts` with elevated privileges via `tee`. `apply_sudo` already
190/// runs the command directly, without a `sudo` prefix, when already
191/// running as root (e.g. in CI) -- matching Go's direct-write branch's
192/// effect (root can write `/etc/hosts` unassisted) without needing a
193/// separate `geteuid` check here.
194fn write_etc_hosts_via_sudo(content: &str) -> anyhow::Result<()> {
195    tracing::info!("writing /etc/hosts with elevated privileges");
196
197    let (name, args) = xianyun_executor::exec::apply_sudo("tee", &["/etc/hosts".to_string()]);
198
199    let opts = xianyun_executor::exec::Options {
200        stdin: Some(Box::new(std::io::Cursor::new(content.as_bytes().to_vec()))),
201        disable_streaming: true,
202        ..Default::default()
203    };
204
205    let result = xianyun_executor::exec::run(&name, &args, Some(opts));
206
207    if let Some(error) = result.error {
208        anyhow::bail!("writing /etc/hosts via sudo tee: {error}");
209    }
210
211    Ok(())
212}
213
214/// Port of `updateetchosts.displayIssuesTable`.
215fn display_issues_table(issues: &[hosts::ValidationResult]) {
216    tracing::info!(count = issues.len(), "found hostnames that need updating");
217
218    for issue in issues {
219        let current_ip = if issue.current.is_empty() {
220            "(not set)"
221        } else {
222            issue.current.as_str()
223        };
224
225        tracing::info!(
226            hostname = %issue.hostname,
227            state = %issue.state,
228            expected_ip = %issue.expected,
229            current_ip = current_ip,
230            "hostname needs updating"
231        );
232    }
233}
234
235/// Port of `updateetchosts.displayDryRunInfo`.
236fn display_dry_run_info(hosts_config: &hosts::HostsConfig) {
237    tracing::info!(
238        ip = %hosts_config.ip_address,
239        hostnames = ?hosts_config.hostnames,
240        "dry-run mode: would add/update the following entries"
241    );
242    tracing::info!("run without --dry-run to apply changes");
243}