1use 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
15pub const TITLE: &str = "Cluster DNS domain";
17
18type BoxError = Box<dyn std::error::Error + Send + Sync>;
19
20#[must_use]
22pub fn diagnostic() -> Arc<dyn Diagnostic> {
23 Arc::new(SimpleDiagnostic::new(TITLE, true, run))
24}
25
26struct 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 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 #[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 #[test]
357 fn test_cluster_domain_diagnostic_names_the_source_that_answered() {
358 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}