Skip to main content

xianyun_deploy/diagnostics/builtin/
orphaned_mirror_copies.rs

1//! Diagnoses `-caproni` mirror deployment copies left behind by an edit
2//! mode session that never shut down cleanly.
3//!
4//! Ported from `internal/diagnostics/builtin/orphaned_mirror_copies.go`.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use xianyun_backends::kubectl::Backend as KubectlBackend;
10use xianyun_core::config::latest::ReloaderConfig;
11
12use crate::diagnostics::base::{Diagnostic, Meta, SimpleDiagnostic};
13use crate::reloader::mirror::copy::{self, COPY_SUFFIX, SCALE_UP_REPLICA_COUNT, Target};
14
15/// This diagnostic's display title, matching the Go original's
16/// `Title`.
17pub const TITLE: &str = "Edit mode deployment copies";
18
19type BoxError = Box<dyn std::error::Error + Send + Sync>;
20
21/// Builds the "Edit mode deployment copies" diagnostic.
22#[must_use]
23pub fn diagnostic() -> Arc<dyn Diagnostic> {
24    Arc::new(SimpleDiagnostic::new(TITLE, true, run))
25}
26
27fn run(meta: &Meta) -> (String, Option<BoxError>) {
28    let Some(config) = meta.config.as_ref() else {
29        return (String::new(), None);
30    };
31    let Some(reloaders) = config.reloaders.as_ref() else {
32        return (String::new(), None);
33    };
34
35    let reloaders_map: HashMap<String, ReloaderConfig> = reloaders
36        .iter()
37        .map(|(k, v)| (k.clone(), v.clone()))
38        .collect();
39
40    let targets = match copy::collect_and_validate_targets(&reloaders_map) {
41        Ok(t) => t,
42        Err(e) => {
43            return (
44                String::new(),
45                Some(format!("collecting mirror targets: {e}").into()),
46            );
47        }
48    };
49
50    let findings = match inspect_mirror_targets(meta.kubectl.as_deref(), &targets) {
51        Ok(f) => f,
52        Err(e) => return (String::new(), Some(e)),
53    };
54
55    if findings.is_empty() {
56        return (String::new(), None);
57    }
58
59    (mirror_copy_warning(&findings), None)
60}
61
62/// Maps resource name to its `spec.replicas` for one resource type in
63/// one namespace.
64type ReplicaIndex = HashMap<String, i64>;
65
66fn inspect_mirror_targets(
67    kubectl: Option<&(dyn KubectlBackend + Send + Sync)>,
68    targets: &[Target],
69) -> Result<Vec<String>, BoxError> {
70    let mut findings = Vec::new();
71    let mut indexes: HashMap<String, ReplicaIndex> = HashMap::new();
72
73    for target in targets {
74        // PrepareCopies only mirrors these two, so nothing else can be left
75        // behind.
76        if target.kubernetes_type != "deployment" && target.kubernetes_type != "statefulset" {
77            continue;
78        }
79
80        let key = format!("{}/{}", target.kubernetes_type, target.namespace);
81
82        if !indexes.contains_key(&key) {
83            let kubectl =
84                kubectl.expect("a supported-type target requires a kubectl backend to inspect");
85            let index = list_replicas(kubectl, &target.kubernetes_type, &target.namespace)?;
86            indexes.insert(key.clone(), index);
87        }
88
89        let index = &indexes[&key];
90        let finding = inspect_mirror_target(target, index);
91        if !finding.is_empty() {
92            findings.push(finding);
93        }
94    }
95
96    Ok(findings)
97}
98
99/// Reports how a single mirror target deviates from its deployed
100/// state, along with the commands that restore it. Returns an empty
101/// string when the target is healthy or was never deployed.
102fn inspect_mirror_target(target: &Target, index: &ReplicaIndex) -> String {
103    let Some(&replicas) = index.get(&target.name) else {
104        return String::new();
105    };
106
107    let copy_name = format!("{}{COPY_SUFFIX}", target.name);
108    let copy_exists = index.contains_key(&copy_name);
109
110    if !copy_exists && replicas > 0 {
111        return String::new();
112    }
113
114    let mut commands = Vec::new();
115    let mut state = "no copy present".to_string();
116
117    if copy_exists {
118        state = "copy present".to_string();
119        commands.push(format!(
120            "caproni kubectl delete {}/{copy_name} -n {}",
121            target.kubernetes_type, target.namespace
122        ));
123    }
124
125    if replicas == 0 {
126        state = format!("scaled to 0, {state}");
127        commands.push(format!(
128            "caproni kubectl scale {}/{} -n {} --replicas {SCALE_UP_REPLICA_COUNT}",
129            target.kubernetes_type, target.name, target.namespace
130        ));
131    }
132
133    format!(
134        "- {}/{} in namespace {:?} ({state})\n    {}",
135        target.kubernetes_type,
136        target.name,
137        target.namespace,
138        commands.join("\n    ")
139    )
140}
141
142/// Indexes every resource of a type in a namespace with a single
143/// kubectl call. Listing beats probing each name: the copy is absent
144/// on every healthy cluster, and a per-name lookup would log a command
145/// error each time doctor confirmed nothing was wrong.
146fn list_replicas(
147    kubectl: &(dyn KubectlBackend + Send + Sync),
148    resource_type: &str,
149    namespace: &str,
150) -> Result<ReplicaIndex, BoxError> {
151    let out = match kubectl.get(resource_type, "", namespace) {
152        // A kubectl error, or an empty namespace, means there is nothing to
153        // check: the namespace does not exist yet.
154        Ok(out) if !out.is_empty() => out,
155        _ => return Ok(ReplicaIndex::new()),
156    };
157
158    #[derive(serde::Deserialize)]
159    struct ItemMetadata {
160        name: String,
161    }
162
163    #[derive(serde::Deserialize, Default)]
164    struct ItemSpec {
165        replicas: Option<i64>,
166    }
167
168    #[derive(serde::Deserialize)]
169    struct Item {
170        metadata: ItemMetadata,
171        #[serde(default)]
172        spec: ItemSpec,
173    }
174
175    #[derive(serde::Deserialize)]
176    struct ItemList {
177        items: Vec<Item>,
178    }
179
180    let list: ItemList = serde_json::from_str(&out).map_err(|e| -> BoxError {
181        format!("parsing {resource_type} list in namespace {namespace:?}: {e}").into()
182    })?;
183
184    let mut index = ReplicaIndex::with_capacity(list.items.len());
185
186    for item in list.items {
187        // Kubernetes defaults an omitted spec.replicas to 1.
188        let replicas = item.spec.replicas.unwrap_or(1);
189        index.insert(item.metadata.name, replicas);
190    }
191
192    Ok(index)
193}
194
195fn mirror_copy_warning(findings: &[String]) -> String {
196    format!(
197        "While `caproni run` is active, edit mode scales a deployment down and runs a\n\"{COPY_SUFFIX}\" copy in its place, then restores the original on exit. That restore is\nskipped if edit mode never got to shut down cleanly, which leaves the workload\nscaled to 0 and the cluster serving nothing.\n\nThese targets are not in their deployed state:\n\n{}\n\nIf `caproni run` is active right now, this is expected and you can ignore it.\nOtherwise, run the commands above to restore them.",
198        findings.join("\n")
199    )
200}
201
202#[cfg(test)]
203mod tests {
204    use xianyun_core::config::latest::{
205        CaproniConfig, MirrorConfig, MirrorTargetConfig, ReloaderConfig,
206    };
207    use xianyun_core::orderedmap::OrderedMap;
208
209    use super::*;
210    use crate::reloader::mirror::test_support::MockKubectlBackend;
211
212    fn config_with_mirror_target(name: &str) -> CaproniConfig {
213        let mut reloaders = OrderedMap::new();
214        reloaders.set(
215            "gitlab".to_string(),
216            ReloaderConfig {
217                name: "gitlab".to_string(),
218                reloader_type: "mirror".to_string(),
219                mirror: Some(MirrorConfig {
220                    target: Some(MirrorTargetConfig {
221                        namespace: "gitlab".to_string(),
222                        kubernetes_type: "deployment".to_string(),
223                        name: name.to_string(),
224                        container: String::new(),
225                    }),
226                    ..Default::default()
227                }),
228                ..Default::default()
229            },
230        );
231
232        CaproniConfig {
233            reloaders: Some(reloaders),
234            ..Default::default()
235        }
236    }
237
238    // Ported from internal/diagnostics/builtin/orphaned_mirror_copies_test.go:20 (TestInspectMirrorTarget)
239    #[test]
240    fn test_inspect_mirror_target() {
241        let target = Target::new("gitlab", "deployment", "gitlab-webservice-default");
242
243        struct Case {
244            name: &'static str,
245            index: ReplicaIndex,
246            want_empty: bool,
247            want_contains: Vec<&'static str>,
248        }
249
250        let cases = vec![
251            Case {
252                name: "healthy: original scaled up, no copy",
253                index: HashMap::from([("gitlab-webservice-default".to_string(), 1)]),
254                want_empty: true,
255                want_contains: vec![],
256            },
257            Case {
258                name: "target was never deployed",
259                index: HashMap::from([("something-else".to_string(), 1)]),
260                want_empty: true,
261                want_contains: vec![],
262            },
263            Case {
264                name: "orphan: scaled to 0 with a copy present",
265                index: HashMap::from([
266                    ("gitlab-webservice-default".to_string(), 0),
267                    ("gitlab-webservice-default-caproni".to_string(), 1),
268                ]),
269                want_empty: false,
270                want_contains: vec![
271                    r#"deployment/gitlab-webservice-default in namespace "gitlab" (scaled to 0, copy present)"#,
272                    "caproni kubectl delete deployment/gitlab-webservice-default-caproni -n gitlab",
273                    "caproni kubectl scale deployment/gitlab-webservice-default -n gitlab --replicas 1",
274                ],
275            },
276            Case {
277                name: "copy present while the original is still scaled up",
278                index: HashMap::from([
279                    ("gitlab-webservice-default".to_string(), 1),
280                    ("gitlab-webservice-default-caproni".to_string(), 1),
281                ]),
282                want_empty: false,
283                want_contains: vec![
284                    "(copy present)",
285                    "caproni kubectl delete deployment/gitlab-webservice-default-caproni -n gitlab",
286                ],
287            },
288            Case {
289                name: "scaled to 0 with no copy needs only a scale up",
290                index: HashMap::from([("gitlab-webservice-default".to_string(), 0)]),
291                want_empty: false,
292                want_contains: vec![
293                    "(scaled to 0, no copy present)",
294                    "caproni kubectl scale deployment/gitlab-webservice-default -n gitlab --replicas 1",
295                ],
296            },
297        ];
298
299        for case in cases {
300            let got = inspect_mirror_target(&target, &case.index);
301
302            if case.want_empty {
303                assert!(got.is_empty(), "{}: expected empty, got {got:?}", case.name);
304                continue;
305            }
306
307            for want in case.want_contains {
308                assert!(
309                    got.contains(want),
310                    "{}: expected {got:?} to contain {want:?}",
311                    case.name
312                );
313            }
314        }
315    }
316
317    // Ported from internal/diagnostics/builtin/orphaned_mirror_copies_test.go:99 (TestInspectMirrorTarget_ScaleUpOmitsCopyDelete)
318    #[test]
319    fn test_inspect_mirror_target_scale_up_omits_copy_delete() {
320        let target = Target::new("gitlab", "deployment", "gitlab-webservice-default");
321        let index = HashMap::from([("gitlab-webservice-default".to_string(), 0)]);
322
323        let got = inspect_mirror_target(&target, &index);
324        assert!(!got.contains("kubectl delete"));
325    }
326
327    // Ported from internal/diagnostics/builtin/orphaned_mirror_copies_test.go:111 (TestListReplicas)
328    #[test]
329    fn test_list_replicas() {
330        // indexes names and replicas
331        {
332            let mock_kubectl =
333                MockKubectlBackend::new().expect_get(|resource_type, name, namespace| {
334                    assert_eq!(resource_type, "deployment");
335                    assert_eq!(name, "");
336                    assert_eq!(namespace, "gitlab");
337                    Ok(r#"{"items":[
338                    {"metadata":{"name":"scaled-down"},"spec":{"replicas":0}},
339                    {"metadata":{"name":"scaled-up"},"spec":{"replicas":2}},
340                    {"metadata":{"name":"replicas-omitted"},"spec":{}}
341                ]}"#
342                    .to_string())
343                });
344
345            let index = list_replicas(&mock_kubectl, "deployment", "gitlab").unwrap();
346            assert_eq!(
347                index,
348                HashMap::from([
349                    ("scaled-down".to_string(), 0),
350                    ("scaled-up".to_string(), 2),
351                    ("replicas-omitted".to_string(), 1),
352                ])
353            );
354        }
355
356        // a missing namespace yields an empty index, not an error
357        {
358            let mock_kubectl = MockKubectlBackend::new().expect_get(|_, _, _| {
359                Err(xianyun_backends::kubectl::KubectlError::JobFailed(
360                    "command failed".to_string(),
361                ))
362            });
363
364            let index = list_replicas(&mock_kubectl, "deployment", "nope").unwrap();
365            assert!(index.is_empty());
366        }
367
368        // malformed JSON is an error
369        {
370            let mock_kubectl =
371                MockKubectlBackend::new().expect_get(|_, _, _| Ok(r#"{"items":"#.to_string()));
372
373            let result = list_replicas(&mock_kubectl, "deployment", "gitlab");
374            assert!(result.is_err());
375        }
376    }
377
378    // Ported from internal/diagnostics/builtin/orphaned_mirror_copies_test.go:161 (TestInspectMirrorTargets_ListsEachNamespaceOnce)
379    #[test]
380    fn test_inspect_mirror_targets_lists_each_namespace_once() {
381        // Two targets in the same namespace must share a single kubectl call.
382        let mock_kubectl =
383            MockKubectlBackend::new().expect_get(|resource_type, _name, namespace| {
384                assert_eq!(resource_type, "deployment");
385                assert_eq!(namespace, "gitlab");
386                Ok(r#"{"items":[
387                {"metadata":{"name":"webservice"},"spec":{"replicas":0}},
388                {"metadata":{"name":"webservice-caproni"},"spec":{"replicas":1}},
389                {"metadata":{"name":"sidekiq"},"spec":{"replicas":1}}
390            ]}"#
391                .to_string())
392            });
393
394        let targets = vec![
395            Target::new("gitlab", "deployment", "webservice"),
396            Target::new("gitlab", "deployment", "sidekiq"),
397        ];
398
399        let findings = inspect_mirror_targets(Some(&mock_kubectl), &targets).unwrap();
400        assert_eq!(findings.len(), 1);
401        assert!(findings[0].contains("deployment/webservice"));
402    }
403
404    // Ported from internal/diagnostics/builtin/orphaned_mirror_copies_test.go:185 (TestInspectMirrorTargets_SkipsUnsupportedTypes)
405    #[test]
406    fn test_inspect_mirror_targets_skips_unsupported_types() {
407        // No kubectl backend proves no lookup happens: PrepareCopies never
408        // mirrors a pod, so there can be no copy of one to find.
409        let targets = vec![Target::new("gitlab", "pod", "some-pod")];
410
411        let findings = inspect_mirror_targets(None, &targets).unwrap();
412        assert!(findings.is_empty());
413    }
414
415    // Ported from internal/diagnostics/builtin/orphaned_mirror_copies_test.go:200 (TestOrphanedMirrorCopiesDiagnostic)
416    #[test]
417    fn test_orphaned_mirror_copies_diagnostic() {
418        let d = diagnostic();
419        assert_eq!(d.name(), TITLE);
420        assert!(
421            d.needs_cluster(),
422            "the check queries the cluster, so it must be skipped when it is down"
423        );
424
425        // no config is not a failure
426        {
427            let (out, err) = d.run(&Meta::default());
428            assert!(err.is_none());
429            assert!(out.is_empty());
430        }
431
432        // warns for an orphaned copy
433        {
434            let mock_kubectl = MockKubectlBackend::new().expect_get(|_, _, _| {
435                Ok(r#"{"items":[
436                    {"metadata":{"name":"gitlab-webservice-default"},"spec":{"replicas":0}},
437                    {"metadata":{"name":"gitlab-webservice-default-caproni"},"spec":{"replicas":1}}
438                ]}"#
439                .to_string())
440            });
441
442            let meta = Meta {
443                kubectl: Some(std::sync::Arc::new(mock_kubectl)),
444                config: Some(config_with_mirror_target("gitlab-webservice-default")),
445            };
446
447            let (out, err) = d.run(&meta);
448            assert!(err.is_none());
449            assert!(out.contains(
450                "caproni kubectl delete deployment/gitlab-webservice-default-caproni -n gitlab"
451            ));
452            assert!(out.contains(
453                "caproni kubectl scale deployment/gitlab-webservice-default -n gitlab --replicas 1"
454            ));
455            assert!(out.contains("`caproni run` is active right now"));
456        }
457
458        // silent when every target is healthy
459        {
460            let mock_kubectl = MockKubectlBackend::new().expect_get(|_, _, _| {
461                Ok(r#"{"items":[{"metadata":{"name":"gitlab-webservice-default"},"spec":{"replicas":1}}]}"#
462                    .to_string())
463            });
464
465            let meta = Meta {
466                kubectl: Some(std::sync::Arc::new(mock_kubectl)),
467                config: Some(config_with_mirror_target("gitlab-webservice-default")),
468            };
469
470            let (out, err) = d.run(&meta);
471            assert!(err.is_none());
472            assert!(out.is_empty());
473        }
474
475        // non-mirror reloaders are ignored
476        {
477            let mut reloaders = OrderedMap::new();
478            reloaders.set(
479                "frontend".to_string(),
480                ReloaderConfig {
481                    name: "frontend".to_string(),
482                    reloader_type: "skaffold".to_string(),
483                    ..Default::default()
484                },
485            );
486
487            let meta = Meta {
488                kubectl: None,
489                config: Some(CaproniConfig {
490                    reloaders: Some(reloaders),
491                    ..Default::default()
492                }),
493            };
494
495            // No kubectl backend proves no lookup happens for a skaffold
496            // reloader.
497            let (out, err) = d.run(&meta);
498            assert!(err.is_none());
499            assert!(out.is_empty());
500        }
501    }
502}