xianyun_cli/commands/
updateetchosts.rs1use 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 #[arg(long = "dry-run")]
17 pub dry_run: bool,
18
19 #[arg(long = "remove")]
21 pub remove: bool,
22
23 #[arg(long = "ip")]
25 pub ip: Option<String>,
26}
27
28#[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
39pub 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
53pub 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
95fn 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
125fn 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
161fn 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
188fn 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
214fn 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
235fn 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}