Skip to main content

xianyun_deploy/diagnostics/builtin/
cluster_domain.rs

1//! Warns when the cluster's actual DNS zone and
2//! `cluster.network.cluster_domain` disagree.
3//!
4//! Ported from `internal/diagnostics/builtin/cluster_domain.go`.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use xianyun_backends::kubectl::Backend as KubectlBackend;
10use xianyun_cluster::clusterdomain::{discover, sources_for};
11use xianyun_cluster::driver::{DnsReader, DriverError};
12
13use crate::diagnostics::base::{Diagnostic, Meta, SimpleDiagnostic};
14
15/// This diagnostic's display title, matching the Go original's `Title`.
16pub const TITLE: &str = "Cluster DNS domain";
17
18type BoxError = Box<dyn std::error::Error + Send + Sync>;
19
20/// Builds the "Cluster DNS domain" diagnostic.
21#[must_use]
22pub fn diagnostic() -> Arc<dyn Diagnostic> {
23    Arc::new(SimpleDiagnostic::new(TITLE, true, run))
24}
25
26/// Adapts a [`KubectlBackend`] to [`DnsReader`], the narrower interface
27/// `xianyun_cluster::clusterdomain`'s DNS sources read through. Go's
28/// `kubectl.Backend` already satisfies `cluster.DNSReader` structurally;
29/// Rust traits need an explicit adapter.
30struct KubectlDnsReader<'a>(&'a (dyn KubectlBackend + Send + Sync));
31
32impl DnsReader for KubectlDnsReader<'_> {
33    fn get(&self, resource_type: &str, name: &str, namespace: &str) -> Result<String, DriverError> {
34        self.0
35            .get(resource_type, name, namespace)
36            .map_err(|e| DriverError::Other(e.to_string()))
37    }
38
39    fn get_raw(&self, path: &str) -> Result<String, DriverError> {
40        self.0
41            .get_raw(path)
42            .map_err(|e| DriverError::Other(e.to_string()))
43    }
44
45    fn run_job(
46        &self,
47        image: &str,
48        namespace: &str,
49        timeout: Duration,
50        command: &[&str],
51    ) -> Result<String, DriverError> {
52        self.0
53            .run_job(image, namespace, timeout, command)
54            .map_err(|e| DriverError::Other(e.to_string()))
55    }
56}
57
58fn run(meta: &Meta) -> (String, Option<BoxError>) {
59    let configured = meta
60        .config
61        .as_ref()
62        .and_then(|c| c.cluster.network.as_ref())
63        .map(|n| n.cluster_domain.clone())
64        .unwrap_or_default();
65
66    let Some(kubectl) = meta.kubectl.as_deref() else {
67        return (String::new(), None);
68    };
69
70    let reader = KubectlDnsReader(kubectl);
71    let sources = sources_for(None);
72    let result = discover(&reader, &sources);
73
74    match result {
75        Err(err) if !configured.is_empty() => (
76            format!(
77                "cluster.network.cluster_domain is {configured} but the cluster's DNS zone could not be confirmed ({err})."
78            ),
79            None,
80        ),
81        Err(err) => (
82            format!(
83                "The cluster's DNS zone could not be confirmed ({err}); \
84                set cluster.network.cluster_domain if the cluster does not serve cluster.local."
85            ),
86            None,
87        ),
88        Ok(result) if !configured.is_empty() && configured != result.zone => (
89            format!(
90                "cluster.network.cluster_domain is {configured} but {} reports {}; \
91                recreate the cluster or set the key to {}.",
92                result.source, result.zone, result.zone
93            ),
94            None,
95        ),
96        Ok(result) if configured.is_empty() && result.zone != "cluster.local" => (
97            format!(
98                "{} reports {} but cluster.network.cluster_domain is not set; \
99                set it so the dev CA covers it.",
100                result.source, result.zone
101            ),
102            None,
103        ),
104        Ok(_) => (String::new(), None),
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use xianyun_backends::kubectl::KubectlError;
111    use xianyun_core::config::latest::CaproniConfig;
112    use xianyun_core::config::types::{ClusterConfig, NetworkConfig};
113
114    use super::*;
115    use crate::diagnostics::builtin::all;
116
117    /// A hand-rolled [`KubectlBackend`] test double for this module's own
118    /// tests, in the same convention as `orphaned_mirror_copies`'s
119    /// `MockKubectlBackend`, but simpler: every case here needs at most
120    /// one canned response per resource, not a FIFO queue.
121    struct StubKubectl {
122        run_job_ok: Option<&'static str>,
123        corefile_stdout: &'static str,
124    }
125
126    impl KubectlBackend for StubKubectl {
127        fn get(
128            &self,
129            resource_type: &str,
130            name: &str,
131            namespace: &str,
132        ) -> Result<String, KubectlError> {
133            match (resource_type, name, namespace) {
134                ("configmap", "", "kube-system") => Ok(self.corefile_stdout.to_string()),
135                ("deployment", "kube-dns", "kube-system") => Err(KubectlError::Other(
136                    r#"deployments.apps "kube-dns" not found"#.to_string(),
137                )),
138                ("nodes", "", "") => Err(KubectlError::Other("nodes is forbidden".to_string())),
139                other => panic!("StubKubectl::get: unexpected call {other:?}"),
140            }
141        }
142
143        fn get_raw(&self, path: &str) -> Result<String, KubectlError> {
144            panic!("StubKubectl::get_raw: unexpected call {path:?}")
145        }
146
147        fn run_job(
148            &self,
149            _image: &str,
150            _namespace: &str,
151            _timeout: Duration,
152            _command: &[&str],
153        ) -> Result<String, KubectlError> {
154            match self.run_job_ok {
155                Some(out) => Ok(out.to_string()),
156                None => Err(KubectlError::Other("pod could not run".to_string())),
157            }
158        }
159
160        fn delete_pods_by_label(&self, _: &str, _: &str) -> Result<usize, KubectlError> {
161            panic!("StubKubectl::delete_pods_by_label: unexpected call")
162        }
163
164        fn get_version(
165            &self,
166            _: Duration,
167            _: Duration,
168        ) -> Result<xianyun_backends::kubectl::VersionResponse, KubectlError> {
169            panic!("StubKubectl::get_version: unexpected call")
170        }
171
172        fn scale(&self, _: &str, _: &str, _: &str, _: i64) -> Result<(), KubectlError> {
173            panic!("StubKubectl::scale: unexpected call")
174        }
175
176        fn apply(&self, _: &[u8], _: &str) -> Result<(), KubectlError> {
177            panic!("StubKubectl::apply: unexpected call")
178        }
179
180        fn delete_manifest(&self, _: &[u8], _: &str) -> Result<(), KubectlError> {
181            panic!("StubKubectl::delete_manifest: unexpected call")
182        }
183
184        fn delete(&self, _: &str, _: &str, _: &str) -> Result<(), KubectlError> {
185            panic!("StubKubectl::delete: unexpected call")
186        }
187
188        fn wait(
189            &self,
190            _: &str,
191            _: &str,
192            _: &str,
193            _: &str,
194            _: Duration,
195        ) -> Result<(), KubectlError> {
196            panic!("StubKubectl::wait: unexpected call")
197        }
198
199        fn wait_for_rollout_status(
200            &self,
201            _: &str,
202            _: &str,
203            _: &str,
204            _: Duration,
205        ) -> Result<(), KubectlError> {
206            panic!("StubKubectl::wait_for_rollout_status: unexpected call")
207        }
208
209        fn wait_for_pod_ready_by_label(
210            &self,
211            _: &str,
212            _: &str,
213            _: Duration,
214        ) -> Result<(), KubectlError> {
215            panic!("StubKubectl::wait_for_pod_ready_by_label: unexpected call")
216        }
217
218        fn wait_for_job_complete(&self, _: &str, _: &str, _: Duration) -> Result<(), KubectlError> {
219            panic!("StubKubectl::wait_for_job_complete: unexpected call")
220        }
221
222        fn wait_for_job_created(&self, _: &str, _: &str, _: Duration) -> Result<(), KubectlError> {
223            panic!("StubKubectl::wait_for_job_created: unexpected call")
224        }
225
226        fn list_resource_names(
227            &self,
228            _: &str,
229            _: &str,
230            _: &str,
231        ) -> Result<Vec<String>, KubectlError> {
232            panic!("StubKubectl::list_resource_names: unexpected call")
233        }
234
235        fn ensure_namespace(&self, _: &str) -> Result<(), KubectlError> {
236            panic!("StubKubectl::ensure_namespace: unexpected call")
237        }
238
239        fn delete_namespace(&self, _: &str) -> Result<(), KubectlError> {
240            panic!("StubKubectl::delete_namespace: unexpected call")
241        }
242
243        fn namespace_exists(&self, _: &str) -> Result<bool, KubectlError> {
244            panic!("StubKubectl::namespace_exists: unexpected call")
245        }
246
247        fn wait_for_namespace_ready(&self, _: &str, _: Duration) -> Result<(), KubectlError> {
248            panic!("StubKubectl::wait_for_namespace_ready: unexpected call")
249        }
250    }
251
252    fn corefile(zone: &str) -> String {
253        format!(
254            r#"{{"items":[{{"data":{{"Corefile":"kubernetes {zone} in-addr.arpa {{\n}}"}}}}]}}"#
255        )
256    }
257
258    // Ported from internal/diagnostics/builtin/cluster_domain_test.go:22 (TestClusterDomainDiagnostic)
259    #[test]
260    fn test_cluster_domain_diagnostic() {
261        let diag = diagnostic();
262
263        assert!(
264            all().iter().any(|d| d.name() == TITLE),
265            "ClusterDomain must be registered in diagnostics::builtin::all()"
266        );
267        assert!(diag.needs_cluster());
268
269        struct Case {
270            name: &'static str,
271            configured: &'static str,
272            stdout: String,
273            want_warn: &'static str,
274        }
275
276        let cases = vec![
277            Case {
278                name: "unset and CoreDNS serves the default",
279                configured: "",
280                stdout: corefile("cluster.local"),
281                want_warn: "",
282            },
283            Case {
284                name: "configured and CoreDNS agrees",
285                configured: "corp.example",
286                stdout: corefile("corp.example"),
287                want_warn: "",
288            },
289            Case {
290                name: "configured but CoreDNS differs",
291                configured: "corp.example",
292                stdout: corefile("cluster.local"),
293                want_warn: "cluster.network.cluster_domain is corp.example but CoreDNS Corefile reports cluster.local; recreate the cluster or set the key",
294            },
295            Case {
296                name: "unset but CoreDNS serves another domain",
297                configured: "",
298                stdout: corefile("corp.example"),
299                want_warn: "CoreDNS Corefile reports corp.example but cluster.network.cluster_domain is not set",
300            },
301            Case {
302                name: "unset and unreadable",
303                configured: "",
304                stdout: r#"{"items":[]}"#.to_string(),
305                want_warn: "The cluster's DNS zone could not be confirmed (no cluster DNS zone could be determined",
306            },
307            Case {
308                name: "configured and unreadable",
309                configured: "corp.example",
310                stdout: r#"{"items":[]}"#.to_string(),
311                want_warn: "cluster.network.cluster_domain is corp.example but the cluster's DNS zone could not be confirmed",
312            },
313        ];
314
315        for case in cases {
316            let backend = StubKubectl {
317                run_job_ok: None,
318                corefile_stdout: Box::leak(case.stdout.clone().into_boxed_str()),
319            };
320
321            let caproni_config = CaproniConfig {
322                cluster: ClusterConfig {
323                    network: Some(NetworkConfig {
324                        hostname: "gitlab.test".to_string(),
325                        cluster_domain: case.configured.to_string(),
326                        ..Default::default()
327                    }),
328                    ..Default::default()
329                },
330                ..Default::default()
331            };
332
333            let meta = Meta {
334                kubectl: Some(Arc::new(backend)),
335                config: Some(caproni_config),
336            };
337
338            let (out, err) = diag.run(&meta);
339
340            assert!(err.is_none(), "{}: {err:?}", case.name);
341
342            if case.want_warn.is_empty() {
343                assert!(out.is_empty(), "{}: expected empty, got {out:?}", case.name);
344            } else {
345                assert!(
346                    out.contains(case.want_warn),
347                    "{}: expected {out:?} to contain {:?}",
348                    case.name,
349                    case.want_warn
350                );
351            }
352        }
353    }
354
355    // Ported from internal/diagnostics/builtin/cluster_domain_test.go:87 (TestClusterDomainDiagnostic_NamesTheSourceThatAnswered)
356    #[test]
357    fn test_cluster_domain_diagnostic_names_the_source_that_answered() {
358        // The pod resolv.conf source, not the CoreDNS Corefile, answers
359        // here -- the warning must name it instead of hard-coding
360        // "CoreDNS".
361        let backend = StubKubectl {
362            run_job_ok: Some("search default.svc.corp.example svc.corp.example corp.example\n"),
363            corefile_stdout: "",
364        };
365
366        let caproni_config = CaproniConfig {
367            cluster: ClusterConfig {
368                network: Some(NetworkConfig {
369                    hostname: "gitlab.test".to_string(),
370                    ..Default::default()
371                }),
372                ..Default::default()
373            },
374            ..Default::default()
375        };
376
377        let meta = Meta {
378            kubectl: Some(Arc::new(backend)),
379            config: Some(caproni_config),
380        };
381
382        let (out, err) = diagnostic().run(&meta);
383
384        assert!(err.is_none());
385        assert!(out.contains("pod /etc/resolv.conf search list reports corp.example"));
386        assert!(!out.contains("CoreDNS"));
387    }
388}