1use 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
15pub const TITLE: &str = "Edit mode deployment copies";
18
19type BoxError = Box<dyn std::error::Error + Send + Sync>;
20
21#[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
62type 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 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
99fn 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(©_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
142fn 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 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 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 #[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 #[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 #[test]
329 fn test_list_replicas() {
330 {
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 {
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 {
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 #[test]
380 fn test_inspect_mirror_targets_lists_each_namespace_once() {
381 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 #[test]
406 fn test_inspect_mirror_targets_skips_unsupported_types() {
407 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 #[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 {
427 let (out, err) = d.run(&Meta::default());
428 assert!(err.is_none());
429 assert!(out.is_empty());
430 }
431
432 {
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 {
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 {
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 let (out, err) = d.run(&meta);
498 assert!(err.is_none());
499 assert!(out.is_empty());
500 }
501 }
502}