Skip to main content

devrig/cluster/
addon.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3use std::time::{Duration, Instant};
4
5use anyhow::{bail, Context, Result};
6use chrono::Utc;
7use tokio::io::AsyncReadExt;
8use tokio::process::Command;
9use tokio_util::sync::CancellationToken;
10use tokio_util::task::TaskTracker;
11use tracing::{debug, error, info, warn};
12
13use std::collections::HashMap;
14
15use crate::config::model::AddonConfig;
16use crate::config::interpolate::resolve_template;
17use crate::orchestrator::state::AddonState;
18
19// ---------------------------------------------------------------------------
20// Helm value conversion
21// ---------------------------------------------------------------------------
22
23/// Convert a TOML value to a string suitable for `helm --set key=value`.
24pub fn toml_value_to_helm_set(value: &toml::Value) -> String {
25    match value {
26        toml::Value::String(s) => s.clone(),
27        toml::Value::Boolean(b) => b.to_string(),
28        toml::Value::Integer(i) => i.to_string(),
29        toml::Value::Float(f) => f.to_string(),
30        toml::Value::Array(arr) => {
31            let items: Vec<String> = arr.iter().map(toml_value_to_helm_set).collect();
32            format!("{{{}}}", items.join(","))
33        }
34        toml::Value::Table(_) | toml::Value::Datetime(_) => value.to_string(),
35    }
36}
37
38// ---------------------------------------------------------------------------
39// Helm/kubectl command helpers
40// ---------------------------------------------------------------------------
41
42/// Run a helm command with the given args and KUBECONFIG env var.
43async fn run_helm(args: &[&str], kubeconfig: &Path, cancel: &CancellationToken) -> Result<String> {
44    let child = Command::new("helm")
45        .args(args)
46        .env("KUBECONFIG", kubeconfig)
47        .output();
48
49    let output = tokio::select! {
50        result = child => result.context("running helm")?,
51        _ = cancel.cancelled() => bail!("cancelled"),
52    };
53
54    if !output.status.success() {
55        let stderr = String::from_utf8_lossy(&output.stderr);
56        bail!(
57            "helm {} failed: {}",
58            args.first().unwrap_or(&""),
59            stderr.trim()
60        );
61    }
62
63    Ok(String::from_utf8_lossy(&output.stdout).to_string())
64}
65
66/// Run a kubectl command with the given args and KUBECONFIG env var.
67async fn run_kubectl(
68    args: &[&str],
69    kubeconfig: &Path,
70    cancel: &CancellationToken,
71) -> Result<String> {
72    let child = Command::new("kubectl")
73        .args(args)
74        .env("KUBECONFIG", kubeconfig)
75        .output();
76
77    let output = tokio::select! {
78        result = child => result.context("running kubectl")?,
79        _ = cancel.cancelled() => bail!("cancelled"),
80    };
81
82    if !output.status.success() {
83        let stderr = String::from_utf8_lossy(&output.stderr);
84        bail!(
85            "kubectl {} failed: {}",
86            args.first().unwrap_or(&""),
87            stderr.trim()
88        );
89    }
90
91    Ok(String::from_utf8_lossy(&output.stdout).to_string())
92}
93
94// ---------------------------------------------------------------------------
95// Individual addon installers
96// ---------------------------------------------------------------------------
97
98#[allow(clippy::too_many_arguments)]
99async fn install_helm_addon(
100    name: &str,
101    chart: &str,
102    repo: Option<&str>,
103    namespace: &str,
104    version: Option<&str>,
105    values: &BTreeMap<String, toml::Value>,
106    values_files: &[String],
107    wait: bool,
108    timeout: &str,
109    skip_crds: bool,
110    kubeconfig: &Path,
111    config_dir: &Path,
112    cancel: &CancellationToken,
113) -> Result<()> {
114    // Resolve chart reference: OCI registry, remote repo, or local path
115    let resolved_chart = if chart.starts_with("oci://") {
116        // OCI chart — use directly, no repo add/update needed
117        chart.to_string()
118    } else if let Some(repo_url) = repo {
119        // Derive the repo name from the chart reference (e.g. "fluxcd-community/flux2"
120        // → "fluxcd-community"). Fall back to the addon name if there's no slash.
121        let repo_name = chart
122            .split('/')
123            .next()
124            .filter(|s| !s.is_empty())
125            .unwrap_or(name);
126
127        // Remote chart — add and update repo
128        run_helm(
129            &["repo", "add", repo_name, repo_url, "--force-update"],
130            kubeconfig,
131            cancel,
132        )
133        .await
134        .with_context(|| format!("adding helm repo for addon '{}'", name))?;
135
136        run_helm(&["repo", "update", repo_name], kubeconfig, cancel)
137            .await
138            .with_context(|| format!("updating helm repo for addon '{}'", name))?;
139
140        chart.to_string()
141    } else {
142        // Local chart — resolve relative to config dir
143        let chart_path = if Path::new(chart).is_absolute() {
144            std::path::PathBuf::from(chart)
145        } else {
146            config_dir.join(chart)
147        };
148        if !chart_path.exists() {
149            bail!(
150                "local helm chart path '{}' does not exist (resolved from '{}')",
151                chart_path.display(),
152                chart
153            );
154        }
155        chart_path.to_string_lossy().to_string()
156    };
157
158    // Build install args
159    let mut args: Vec<String> = vec![
160        "upgrade".to_string(),
161        "--install".to_string(),
162        name.to_string(),
163        resolved_chart,
164        "--namespace".to_string(),
165        namespace.to_string(),
166        "--create-namespace".to_string(),
167    ];
168
169    if skip_crds {
170        args.push("--skip-crds".to_string());
171    }
172
173    if wait {
174        args.push("--wait".to_string());
175        args.push("--timeout".to_string());
176        args.push(timeout.to_string());
177    }
178
179    if let Some(v) = version {
180        args.push("--version".to_string());
181        args.push(v.to_string());
182    }
183
184    // Add -f for each values file
185    for vf in values_files {
186        let vf_path = if Path::new(vf).is_absolute() {
187            std::path::PathBuf::from(vf)
188        } else {
189            config_dir.join(vf)
190        };
191        args.push("-f".to_string());
192        args.push(vf_path.to_string_lossy().to_string());
193    }
194
195    // Add --set for each value
196    for (k, v) in values {
197        args.push("--set".to_string());
198        args.push(format!("{}={}", k, toml_value_to_helm_set(v)));
199    }
200
201    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
202    run_helm(&arg_refs, kubeconfig, cancel)
203        .await
204        .with_context(|| format!("installing helm addon '{}'", name))?;
205
206    debug!(addon = %name, chart = %chart, "helm addon installed");
207    Ok(())
208}
209
210/// Returns true if the error looks like a missing-CRD / resource-mapping failure,
211/// which is transient when a prior addon is still registering its CRDs.
212fn is_crd_not_ready(err: &anyhow::Error) -> bool {
213    let msg = err.to_string();
214    msg.contains("resource mapping not found") || msg.contains("no matches for kind")
215}
216
217/// Max time to wait for CRDs to appear before giving up.
218const CRD_RETRY_TIMEOUT: Duration = Duration::from_secs(300);
219
220/// If the file contains `{{ }}` templates, resolve them and write to a temp
221/// file; otherwise return the original path unchanged.
222fn resolve_manifest_templates(
223    manifest_path: &Path,
224    template_vars: &HashMap<String, String>,
225    addon_name: &str,
226) -> Result<Option<std::path::PathBuf>> {
227    let content = std::fs::read_to_string(manifest_path)
228        .with_context(|| format!("reading manifest '{}'", manifest_path.display()))?;
229
230    if !content.contains("{{") {
231        return Ok(None);
232    }
233
234    let field_ctx = format!("cluster.addons.{addon_name}.path");
235    let resolved = resolve_template(&content, template_vars, &field_ctx).map_err(|errs| {
236        let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
237        anyhow::anyhow!("{}", msgs.join("; "))
238    })?;
239
240    let tmp_path = std::env::temp_dir().join(format!("devrig-addon-{addon_name}.yaml"));
241    std::fs::write(&tmp_path, resolved.as_bytes())
242        .with_context(|| format!("writing resolved manifest to '{}'", tmp_path.display()))?;
243    Ok(Some(tmp_path))
244}
245
246async fn install_manifest_addon(
247    name: &str,
248    path: &str,
249    namespace: Option<&str>,
250    template_vars: &HashMap<String, String>,
251    kubeconfig: &Path,
252    config_dir: &Path,
253    cancel: &CancellationToken,
254) -> Result<()> {
255    let manifest_path = if Path::new(path).is_absolute() {
256        std::path::PathBuf::from(path)
257    } else {
258        config_dir.join(path)
259    };
260
261    // Resolve {{ }} templates if present.
262    let resolved = resolve_manifest_templates(&manifest_path, template_vars, name)?;
263    let apply_path = resolved
264        .as_ref()
265        .map(|p| p.to_string_lossy().to_string())
266        .unwrap_or_else(|| manifest_path.to_string_lossy().to_string());
267
268    let mut args = vec!["apply", "-f", &apply_path];
269    let ns_str;
270    if let Some(ns) = namespace {
271        ns_str = ns.to_string();
272        args.push("--namespace");
273        args.push(&ns_str);
274    }
275
276    let deadline = Instant::now() + CRD_RETRY_TIMEOUT;
277    let mut backoff = Duration::from_secs(2);
278
279    loop {
280        match run_kubectl(&args, kubeconfig, cancel).await {
281            Ok(_) => {
282                debug!(addon = %name, path = %path, "manifest addon installed");
283                return Ok(());
284            }
285            Err(e) if is_crd_not_ready(&e) && Instant::now() < deadline => {
286                info!(
287                    addon = %name,
288                    "CRDs not yet available, retrying in {:?}",
289                    backoff
290                );
291                tokio::select! {
292                    _ = tokio::time::sleep(backoff) => {}
293                    _ = cancel.cancelled() => bail!("cancelled"),
294                }
295                backoff = (backoff * 2).min(Duration::from_secs(30));
296            }
297            Err(e) => {
298                return Err(e)
299                    .with_context(|| format!("applying manifest addon '{}'", name));
300            }
301        }
302    }
303}
304
305async fn install_kustomize_addon(
306    name: &str,
307    path: &str,
308    namespace: Option<&str>,
309    _template_vars: &HashMap<String, String>,
310    kubeconfig: &Path,
311    config_dir: &Path,
312    cancel: &CancellationToken,
313) -> Result<()> {
314    let kustomize_path = if Path::new(path).is_absolute() {
315        std::path::PathBuf::from(path)
316    } else {
317        config_dir.join(path)
318    };
319    let kustomize_str = kustomize_path.to_string_lossy().to_string();
320
321    let mut args = vec!["apply", "-k", &kustomize_str];
322    let ns_str;
323    if let Some(ns) = namespace {
324        ns_str = ns.to_string();
325        args.push("--namespace");
326        args.push(&ns_str);
327    }
328
329    let deadline = Instant::now() + CRD_RETRY_TIMEOUT;
330    let mut backoff = Duration::from_secs(2);
331
332    loop {
333        match run_kubectl(&args, kubeconfig, cancel).await {
334            Ok(_) => {
335                debug!(addon = %name, path = %path, "kustomize addon installed");
336                return Ok(());
337            }
338            Err(e) if is_crd_not_ready(&e) && Instant::now() < deadline => {
339                info!(
340                    addon = %name,
341                    "CRDs not yet available, retrying in {:?}",
342                    backoff
343                );
344                tokio::select! {
345                    _ = tokio::time::sleep(backoff) => {}
346                    _ = cancel.cancelled() => bail!("cancelled"),
347                }
348                backoff = (backoff * 2).min(Duration::from_secs(30));
349            }
350            Err(e) => {
351                return Err(e)
352                    .with_context(|| format!("applying kustomize addon '{}'", name));
353            }
354        }
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Topological sort for addon install ordering
360// ---------------------------------------------------------------------------
361
362/// Topologically sort addons by `depends_on` using Kahn's algorithm.
363///
364/// Tie-breaking is alphabetical (deterministic). Returns an error if a cycle
365/// is detected.
366pub fn topo_sort_addons(addons: &BTreeMap<String, AddonConfig>) -> Result<Vec<String>> {
367    // Build in-degree map and adjacency list
368    let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
369    let mut dependents: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
370
371    for name in addons.keys() {
372        in_degree.entry(name.as_str()).or_insert(0);
373    }
374
375    for (name, addon) in addons {
376        for dep in addon.depends_on() {
377            // Only count deps that are actually in the addons map
378            if addons.contains_key(dep.as_str()) {
379                dependents
380                    .entry(dep.as_str())
381                    .or_default()
382                    .insert(name.as_str());
383                *in_degree.entry(name.as_str()).or_insert(0) += 1;
384            }
385        }
386    }
387
388    // Seed the queue with nodes that have zero in-degree (BTreeSet for alphabetical order)
389    let mut ready: BTreeSet<&str> = BTreeSet::new();
390    for (&name, &deg) in &in_degree {
391        if deg == 0 {
392            ready.insert(name);
393        }
394    }
395
396    let mut sorted: Vec<String> = Vec::with_capacity(addons.len());
397
398    while let Some(&name) = ready.iter().next() {
399        ready.remove(name);
400        sorted.push(name.to_string());
401
402        if let Some(deps) = dependents.get(name) {
403            for &dependent in deps {
404                let deg = in_degree.get_mut(dependent).unwrap();
405                *deg -= 1;
406                if *deg == 0 {
407                    ready.insert(dependent);
408                }
409            }
410        }
411    }
412
413    if sorted.len() != addons.len() {
414        // Find cycle participants
415        let in_cycle: Vec<String> = in_degree
416            .iter()
417            .filter(|(_, &deg)| deg > 0)
418            .map(|(&name, _)| name.to_string())
419            .collect();
420        bail!(
421            "addon dependency cycle detected involving: {}",
422            in_cycle.join(", ")
423        );
424    }
425
426    Ok(sorted)
427}
428
429// ---------------------------------------------------------------------------
430// Bulk install/uninstall
431// ---------------------------------------------------------------------------
432
433/// Resolve `{{ }}` templates in the string values of a TOML values map.
434fn resolve_values_templates(
435    values: &BTreeMap<String, toml::Value>,
436    template_vars: &HashMap<String, String>,
437    addon_name: &str,
438) -> Result<BTreeMap<String, toml::Value>> {
439    let mut resolved = BTreeMap::new();
440    for (key, value) in values {
441        let resolved_val = match value {
442            toml::Value::String(s) => {
443                let field_ctx = format!("cluster.addons.{addon_name}.values.{key}");
444                match resolve_template(s, template_vars, &field_ctx) {
445                    Ok(r) => toml::Value::String(r),
446                    Err(errs) => {
447                        let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
448                        bail!("{}", msgs.join("; "));
449                    }
450                }
451            }
452            other => other.clone(),
453        };
454        resolved.insert(key.clone(), resolved_val);
455    }
456    Ok(resolved)
457}
458
459/// Install all addons in dependency order (topological sort, alphabetical tie-break).
460/// Returns a map of addon states for persistence.
461pub async fn install_addons(
462    addons: &BTreeMap<String, AddonConfig>,
463    template_vars: &HashMap<String, String>,
464    kubeconfig: &Path,
465    config_dir: &Path,
466    cancel: &CancellationToken,
467) -> Result<BTreeMap<String, AddonState>> {
468    let mut states = BTreeMap::new();
469    let install_order = topo_sort_addons(addons)?;
470
471    for name in &install_order {
472        let addon = &addons[name];
473        debug!(addon = %name, type_ = %addon.addon_type(), "installing addon");
474
475        match addon {
476            AddonConfig::Helm {
477                chart,
478                repo,
479                namespace,
480                version,
481                values,
482                values_files,
483                wait,
484                timeout,
485                skip_crds,
486                ..
487            } => {
488                let resolved_values =
489                    resolve_values_templates(values, template_vars, name)?;
490                install_helm_addon(
491                    name,
492                    chart,
493                    repo.as_deref(),
494                    namespace,
495                    version.as_deref(),
496                    &resolved_values,
497                    values_files,
498                    *wait,
499                    timeout,
500                    *skip_crds,
501                    kubeconfig,
502                    config_dir,
503                    cancel,
504                )
505                .await?;
506                states.insert(
507                    name.clone(),
508                    AddonState {
509                        addon_type: "helm".to_string(),
510                        namespace: namespace.clone(),
511                        installed_at: Utc::now(),
512                    },
513                );
514            }
515            AddonConfig::Manifest {
516                path, namespace, ..
517            } => {
518                install_manifest_addon(
519                    name,
520                    path,
521                    namespace.as_deref(),
522                    template_vars,
523                    kubeconfig,
524                    config_dir,
525                    cancel,
526                )
527                .await?;
528                states.insert(
529                    name.clone(),
530                    AddonState {
531                        addon_type: "manifest".to_string(),
532                        namespace: namespace.as_deref().unwrap_or("default").to_string(),
533                        installed_at: Utc::now(),
534                    },
535                );
536            }
537            AddonConfig::Kustomize {
538                path, namespace, ..
539            } => {
540                install_kustomize_addon(
541                    name,
542                    path,
543                    namespace.as_deref(),
544                    template_vars,
545                    kubeconfig,
546                    config_dir,
547                    cancel,
548                )
549                .await?;
550                states.insert(
551                    name.clone(),
552                    AddonState {
553                        addon_type: "kustomize".to_string(),
554                        namespace: namespace.as_deref().unwrap_or("default").to_string(),
555                        installed_at: Utc::now(),
556                    },
557                );
558            }
559        }
560    }
561
562    Ok(states)
563}
564
565/// Uninstall all addons. Errors are logged but do not stop other uninstalls.
566pub async fn uninstall_addons(
567    addons: &BTreeMap<String, AddonConfig>,
568    kubeconfig: &Path,
569    config_dir: &Path,
570    cancel: &CancellationToken,
571) {
572    // Uninstall in reverse dependency order (dependents first)
573    let uninstall_order: Vec<String> = match topo_sort_addons(addons) {
574        Ok(order) => order.into_iter().rev().collect(),
575        Err(_) => addons.keys().rev().cloned().collect(), // fallback to reverse-alpha
576    };
577
578    for name in &uninstall_order {
579        let addon = &addons[name];
580        debug!(addon = %name, "uninstalling addon");
581        let result = match addon {
582            AddonConfig::Helm { namespace, .. } => {
583                run_helm(
584                    &["uninstall", name, "--namespace", namespace],
585                    kubeconfig,
586                    cancel,
587                )
588                .await
589            }
590            AddonConfig::Manifest {
591                path, namespace, ..
592            } => {
593                let manifest_path = if Path::new(path.as_str()).is_absolute() {
594                    std::path::PathBuf::from(path)
595                } else {
596                    config_dir.join(path)
597                };
598                let manifest_str = manifest_path.to_string_lossy().to_string();
599                let mut args = vec!["delete", "-f", &manifest_str, "--ignore-not-found"];
600                let ns_str;
601                if let Some(ns) = namespace.as_deref() {
602                    ns_str = ns.to_string();
603                    args.push("--namespace");
604                    args.push(&ns_str);
605                }
606                run_kubectl(&args, kubeconfig, cancel).await
607            }
608            AddonConfig::Kustomize {
609                path, namespace, ..
610            } => {
611                let kustomize_path = if Path::new(path.as_str()).is_absolute() {
612                    std::path::PathBuf::from(path)
613                } else {
614                    config_dir.join(path)
615                };
616                let kustomize_str = kustomize_path.to_string_lossy().to_string();
617                let mut args = vec!["delete", "-k", &kustomize_str, "--ignore-not-found"];
618                let ns_str;
619                if let Some(ns) = namespace.as_deref() {
620                    ns_str = ns.to_string();
621                    args.push("--namespace");
622                    args.push(&ns_str);
623                }
624                run_kubectl(&args, kubeconfig, cancel).await
625            }
626        };
627
628        if let Err(e) = result {
629            warn!(addon = %name, error = %e, "failed to uninstall addon");
630        }
631    }
632}
633
634// ---------------------------------------------------------------------------
635// Port-forward manager
636// ---------------------------------------------------------------------------
637
638/// Manages port-forward processes for addon UIs.
639pub struct PortForwardManager {
640    tracker: TaskTracker,
641    cancel: CancellationToken,
642}
643
644impl Default for PortForwardManager {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650impl PortForwardManager {
651    /// Create a new PortForwardManager.
652    pub fn new() -> Self {
653        Self {
654            tracker: TaskTracker::new(),
655            cancel: CancellationToken::new(),
656        }
657    }
658
659    /// Start port-forwards for all addons that have port_forward entries.
660    pub fn start_port_forwards(&self, addons: &BTreeMap<String, AddonConfig>, kubeconfig: &Path) {
661        for (name, addon) in addons {
662            let namespace = addon.namespace().unwrap_or("default").to_string();
663
664            for (port_str, target) in addon.port_forward() {
665                let local_port = match port_str.parse::<u16>() {
666                    Ok(p) => p,
667                    Err(_) => {
668                        warn!(addon = %name, port = %port_str, "invalid port-forward port, skipping");
669                        continue;
670                    }
671                };
672
673                // Parse target: "svc/name:port" -> ("svc/name", "port")
674                let (resource, remote_port) = match target.rsplit_once(':') {
675                    Some((r, p)) => (r.to_string(), p.to_string()),
676                    None => {
677                        warn!(addon = %name, target = %target, "invalid port-forward target, expected resource:port");
678                        continue;
679                    }
680                };
681
682                let cancel = self.cancel.clone();
683                let kubeconfig = kubeconfig.to_path_buf();
684                let addon_name = name.clone();
685                let ns = namespace.clone();
686
687                self.tracker.spawn(async move {
688                    let mut backoff = Duration::from_secs(1);
689                    let max_backoff = Duration::from_secs(30);
690
691                    loop {
692                        debug!(
693                            addon = %addon_name,
694                            local_port = local_port,
695                            target = format!("{}:{}", resource, remote_port),
696                            "starting port-forward"
697                        );
698
699                        let mut child = match Command::new("kubectl")
700                            .args([
701                                "port-forward",
702                                "--namespace",
703                                &ns,
704                                "--address",
705                                "127.0.0.1",
706                                &resource,
707                                &format!("{}:{}", local_port, remote_port),
708                            ])
709                            .env("KUBECONFIG", &kubeconfig)
710                            .stdout(std::process::Stdio::null())
711                            .stderr(std::process::Stdio::piped())
712                            .kill_on_drop(true)
713                            .spawn()
714                        {
715                            Ok(child) => child,
716                            Err(e) => {
717                                error!(addon = %addon_name, error = %e, "failed to spawn port-forward");
718                                break;
719                            }
720                        };
721
722                        let stderr_handle = child.stderr.take();
723                        let started = Instant::now();
724
725                        tokio::select! {
726                            status = child.wait() => {
727                                // Read captured stderr for a concise reason.
728                                let reason = if let Some(mut stderr) = stderr_handle {
729                                    let mut buf = String::new();
730                                    let _ = stderr.read_to_string(&mut buf).await;
731                                    if !buf.is_empty() {
732                                        debug!(
733                                            addon = %addon_name,
734                                            stderr = %buf.trim(),
735                                            "kubectl port-forward stderr"
736                                        );
737                                    }
738                                    // Extract the last "error: ..." line as a concise reason.
739                                    buf.lines()
740                                        .rev()
741                                        .find(|l| l.starts_with("error:"))
742                                        .map(|l| l.trim_start_matches("error:").trim().to_string())
743                                } else {
744                                    None
745                                };
746
747                                match status {
748                                    Ok(s) => {
749                                        warn!(
750                                            addon = %addon_name,
751                                            local_port = local_port,
752                                            exit = %s,
753                                            reason = reason.as_deref().unwrap_or("unknown"),
754                                            "port-forward exited, reconnecting in {:?}",
755                                            backoff
756                                        );
757                                    }
758                                    Err(e) => {
759                                        warn!(
760                                            addon = %addon_name,
761                                            error = %e,
762                                            reason = reason.as_deref().unwrap_or("unknown"),
763                                            "port-forward error, reconnecting in {:?}",
764                                            backoff
765                                        );
766                                    }
767                                }
768
769                                tokio::time::sleep(backoff).await;
770
771                                // Reset backoff if the connection was stable (>60s).
772                                if started.elapsed() > Duration::from_secs(60) {
773                                    backoff = Duration::from_secs(1);
774                                } else {
775                                    backoff = (backoff * 2).min(max_backoff);
776                                }
777                            }
778                            _ = cancel.cancelled() => {
779                                let _ = child.kill().await;
780                                debug!(addon = %addon_name, local_port = local_port, "port-forward stopped");
781                                break;
782                            }
783                        }
784                    }
785                });
786            }
787        }
788    }
789
790    /// Stop all port-forward processes.
791    pub async fn stop(&self) {
792        self.cancel.cancel();
793        self.tracker.close();
794        match tokio::time::timeout(Duration::from_secs(5), self.tracker.wait()).await {
795            Ok(()) => debug!("all port-forwards stopped"),
796            Err(_) => warn!("port-forward shutdown timed out"),
797        }
798    }
799}
800
801// ---------------------------------------------------------------------------
802// Tests
803// ---------------------------------------------------------------------------
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn toml_value_string() {
811        let val = toml::Value::String("hello".to_string());
812        assert_eq!(toml_value_to_helm_set(&val), "hello");
813    }
814
815    #[test]
816    fn toml_value_bool_true() {
817        let val = toml::Value::Boolean(true);
818        assert_eq!(toml_value_to_helm_set(&val), "true");
819    }
820
821    #[test]
822    fn toml_value_bool_false() {
823        let val = toml::Value::Boolean(false);
824        assert_eq!(toml_value_to_helm_set(&val), "false");
825    }
826
827    #[test]
828    fn toml_value_integer() {
829        let val = toml::Value::Integer(42);
830        assert_eq!(toml_value_to_helm_set(&val), "42");
831    }
832
833    #[test]
834    fn toml_value_float() {
835        let val = toml::Value::Float(3.14);
836        assert_eq!(toml_value_to_helm_set(&val), "3.14");
837    }
838
839    #[test]
840    fn toml_value_array() {
841        let val = toml::Value::Array(vec![
842            toml::Value::String("a".to_string()),
843            toml::Value::String("b".to_string()),
844            toml::Value::String("c".to_string()),
845        ]);
846        assert_eq!(toml_value_to_helm_set(&val), "{a,b,c}");
847    }
848
849    /// Helper to build a minimal Manifest addon for topo-sort tests.
850    fn manifest_addon(deps: Vec<&str>) -> AddonConfig {
851        AddonConfig::Manifest {
852            path: "./test.yaml".to_string(),
853            namespace: None,
854            port_forward: BTreeMap::new(),
855            depends_on: deps.into_iter().map(String::from).collect(),
856        }
857    }
858
859    #[test]
860    fn topo_sort_no_deps_is_alphabetical() {
861        let mut addons = BTreeMap::new();
862        addons.insert("charlie".to_string(), manifest_addon(vec![]));
863        addons.insert("alpha".to_string(), manifest_addon(vec![]));
864        addons.insert("bravo".to_string(), manifest_addon(vec![]));
865
866        let order = topo_sort_addons(&addons).unwrap();
867        assert_eq!(order, vec!["alpha", "bravo", "charlie"]);
868    }
869
870    #[test]
871    fn topo_sort_respects_depends_on() {
872        let mut addons = BTreeMap::new();
873        addons.insert("app".to_string(), manifest_addon(vec!["cert-manager"]));
874        addons.insert("cert-manager".to_string(), manifest_addon(vec![]));
875
876        let order = topo_sort_addons(&addons).unwrap();
877        assert_eq!(order, vec!["cert-manager", "app"]);
878    }
879
880    #[test]
881    fn topo_sort_diamond_deps() {
882        let mut addons = BTreeMap::new();
883        addons.insert("d".to_string(), manifest_addon(vec!["b", "c"]));
884        addons.insert("b".to_string(), manifest_addon(vec!["a"]));
885        addons.insert("c".to_string(), manifest_addon(vec!["a"]));
886        addons.insert("a".to_string(), manifest_addon(vec![]));
887
888        let order = topo_sort_addons(&addons).unwrap();
889        // a must come before b and c; b and c before d
890        let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
891        assert!(pos("a") < pos("b"));
892        assert!(pos("a") < pos("c"));
893        assert!(pos("b") < pos("d"));
894        assert!(pos("c") < pos("d"));
895    }
896
897    #[test]
898    fn topo_sort_detects_cycle() {
899        let mut addons = BTreeMap::new();
900        addons.insert("a".to_string(), manifest_addon(vec!["b"]));
901        addons.insert("b".to_string(), manifest_addon(vec!["a"]));
902
903        let result = topo_sort_addons(&addons);
904        assert!(result.is_err());
905        assert!(result.unwrap_err().to_string().contains("cycle"));
906    }
907
908    #[test]
909    fn topo_sort_ignores_external_deps() {
910        // depends_on referencing non-addon names should be silently ignored
911        let mut addons = BTreeMap::new();
912        addons.insert("app".to_string(), manifest_addon(vec!["external-thing"]));
913
914        let order = topo_sort_addons(&addons).unwrap();
915        assert_eq!(order, vec!["app"]);
916    }
917}