Skip to main content

xianyun_cluster/clusterdomain/
resolve.rs

1//! Port of `internal/clusterdomain/resolve.go`: settles
2//! `cluster.network.cluster_domain` against the zone the cluster actually
3//! serves.
4//!
5//! `xianyun_core::config::container::Container` now carries the
6//! `global.cluster_domain`/`global.cluster_domain_source` fields this
7//! module reads and writes (added since this crate's Phase 3 scaffold was
8//! written), so this is a direct port against the real type rather than
9//! the local shim the original scaffold anticipated needing.
10
11use super::{ClusterDomainError, discover, sources_for};
12use crate::driver::{DnsReader, Driver, DriverError, State};
13use thiserror::Error;
14use xianyun_core::config::container::Container;
15use xianyun_core::config::global::{self, GlobalConfig};
16
17/// Port of `clusterdomain.ConfiguredSource`.
18pub const CONFIGURED_SOURCE: &str = "cluster.network.cluster_domain";
19
20/// Port of `clusterdomain.ErrClusterDomainUnknown` and the
21/// `cluster.ErrConfigMismatch`/`cluster.ErrClusterNotRunning` sentinels
22/// `Resolve`/`ResolveRunning` wrap.
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum ResolveError {
25    #[error("cluster DNS domain could not be determined: {0}")]
26    ClusterDomainUnknown(String),
27    #[error(
28        "cluster configuration mismatch: {zone_source} reports {discovered} but cluster.network.cluster_domain is set to {configured}"
29    )]
30    ConfigMismatch {
31        zone_source: String,
32        discovered: String,
33        configured: String,
34    },
35    #[error("cluster is not running (state: {0:?})")]
36    ClusterNotRunning(Option<State>),
37    #[error("{0}")]
38    Domain(#[from] ClusterDomainError),
39    #[error("{0}")]
40    Driver(#[from] DriverError),
41}
42
43/// Port of `clusterdomain.Resolve`: settles `cluster.network.cluster_domain`
44/// against the zone `sources_for(mgr)` discovers through `reader`, and
45/// records the outcome on `cfg`.
46pub fn resolve(
47    cfg: &mut Container,
48    mgr: Option<&dyn Driver>,
49    reader: &dyn DnsReader,
50) -> Result<(), ResolveError> {
51    let configured = configured_domain(cfg);
52    let discovered = discover(reader, &sources_for(mgr));
53
54    match &discovered {
55        Err(e) if configured.is_empty() => {
56            return Err(ResolveError::ClusterDomainUnknown(format!(
57                "{e}; set cluster.network.cluster_domain"
58            )));
59        }
60        Err(e) => {
61            tracing::warn!(
62                configured,
63                error = %e,
64                "could not confirm cluster.network.cluster_domain against cluster DNS"
65            );
66        }
67        Ok(result) if !configured.is_empty() && configured != result.zone => {
68            return Err(ResolveError::ConfigMismatch {
69                zone_source: result.source.clone(),
70                discovered: result.zone.clone(),
71                configured,
72            });
73        }
74        Ok(result) if configured.is_empty() => {
75            if let Some(network) = cfg.caproni.cluster.network.as_mut() {
76                network.cluster_domain = result.zone.clone();
77            }
78            cfg.global.cluster_domain = result.zone.clone();
79            cfg.global.cluster_domain_source = result.source.clone();
80
81            tracing::info!(
82                domain = result.zone,
83                source = result.source,
84                "cluster DNS domain read from the cluster"
85            );
86
87            return Ok(());
88        }
89        Ok(_) => {}
90    }
91
92    cfg.global.cluster_domain = configured;
93    cfg.global.cluster_domain_source = CONFIGURED_SOURCE.to_string();
94
95    Ok(())
96}
97
98/// Port of `clusterdomain.ResolveAndReload`: resolves the domain, then
99/// reloads the config so `${CAPRONI_CLUSTER_DOMAIN}` expands with it.
100///
101/// The Go original relies on `cfg.ClusterDomain` and the `GlobalConfig`
102/// backing `config.LoadFromContext(ctx)` being the *same* mutable value
103/// (`Resolve` writes through a shared pointer that the ambient context
104/// also holds). This port's `global::Context` holds an owned
105/// `GlobalConfig` snapshot instead (see `xianyun-core`'s own
106/// `PORT_NOTES.md`), so this function explicitly re-injects the resolved
107/// `cfg.global` into a fresh `Context` before reloading, achieving the
108/// same visible effect without relying on shared mutable state.
109pub fn resolve_and_reload(
110    cfg: &mut Container,
111    mgr: Option<&dyn Driver>,
112    reader: &dyn DnsReader,
113) -> Result<Container, ResolveError> {
114    resolve(cfg, mgr, reader)?;
115
116    let ctx = global::with_context(global::Context::default(), cfg.global.clone());
117
118    xianyun_core::config::caproni::load_from_context(&ctx)
119        .map_err(|e| ResolveError::ClusterDomainUnknown(format!("reloading configuration: {e}")))
120}
121
122/// Port of `clusterdomain.Connector`: opens a DNS reader on a cluster that
123/// is known to be running.
124pub type Connector = fn(&dyn Driver) -> Result<Box<dyn DnsReader>, ResolveError>;
125
126/// Port of `clusterdomain.ResolveRunning`: `Resolve` for commands that
127/// never start a cluster. Returns `(skipped, err)`: `skipped` carries the
128/// "cluster is not running, kept the configured value" case, `err` is a
129/// hard failure.
130pub fn resolve_running(
131    cfg: &mut Container,
132    mgr: &dyn Driver,
133    connect: Connector,
134) -> (Option<ResolveError>, Option<ResolveError>) {
135    let status = match mgr.status() {
136        Ok(s) => s,
137        Err(e) => return (None, Some(ResolveError::Driver(e))),
138    };
139
140    if status.state != Some(State::Ready) {
141        let not_running = ResolveError::ClusterNotRunning(status.state);
142
143        let configured = configured_domain(cfg);
144        if configured.is_empty() {
145            return (
146                None,
147                Some(ResolveError::ClusterDomainUnknown(format!(
148                    "cluster.network.cluster_domain is not set and the {not_running}; set it or run `caproni up`"
149                ))),
150            );
151        }
152
153        cfg.global.cluster_domain = configured.clone();
154        cfg.global.cluster_domain_source = CONFIGURED_SOURCE.to_string();
155
156        tracing::info!(
157            domain = configured,
158            reason = %not_running,
159            "cluster DNS domain left as configured"
160        );
161
162        return (Some(not_running), None);
163    }
164
165    match connect(mgr) {
166        Ok(reader) => (None, resolve(cfg, Some(mgr), reader.as_ref()).err()),
167        Err(e) => (None, Some(e)),
168    }
169}
170
171/// Port of `clusterdomain.configuredDomain`: returns
172/// `cluster.network.cluster_domain`, or `""` when unset.
173pub fn configured_domain(cfg: &Container) -> String {
174    cfg.caproni
175        .cluster
176        .network
177        .as_ref()
178        .map(|n| n.cluster_domain.clone())
179        .unwrap_or_default()
180}
181
182/// Test-only helper: builds a minimal [`Container`] carrying just the
183/// cluster driver and configured cluster domain `Resolve`/`ResolveRunning`
184/// need, mirroring the Go test suite's `containerWithDomain`.
185#[doc(hidden)]
186#[must_use]
187pub fn container_with_domain(driver: &str, profile_name: &str, configured: &str) -> Container {
188    use xianyun_core::config::latest::CaproniConfig;
189    use xianyun_core::config::types::{ClusterConfig, NetworkConfig};
190
191    let cfg = CaproniConfig {
192        cluster: ClusterConfig {
193            driver: driver.to_string(),
194            network: Some(NetworkConfig {
195                hostname: "gitlab.test".to_string(),
196                cluster_domain: configured.to_string(),
197                ..Default::default()
198            }),
199            ..Default::default()
200        },
201        ..Default::default()
202    };
203
204    let mut global = GlobalConfig::new(None);
205    global.profile_name = profile_name.to_string();
206
207    xianyun_core::config::container::new_container(global, cfg)
208}