Skip to main content

xianyun_cli/commands/
run.rs

1//! `caproni run`: run reloaders in edit mode until interrupted.
2//!
3//! Port of Go's `cmd/run` package.
4//!
5//! `runRun`'s setup pipeline (repository cloning, cluster DNS domain
6//! resolution) and its concurrent core (`runAndWait`: one goroutine per
7//! reloader, signal handling) are both ported for real below. The
8//! concurrent core uses [`tokio::task::JoinSet`] for structured
9//! concurrency -- see [`execute_reloaders_concurrently`]'s doc comment
10//! for the full design and what's intentionally out of scope (the TUI,
11//! mirror deployment-copy orchestration). See
12//! `PORT_NOTES.md`'s "fifth pass" entry for the complete rationale.
13
14use std::collections::HashMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19use anyhow::Context as _;
20use clap::Args;
21use xianyun_backends::git::Backend as GitBackend;
22use xianyun_backends::kubectl;
23use xianyun_cluster::clusterdomain;
24use xianyun_core::config::caproni::load_from_context;
25use xianyun_core::config::global::Context;
26use xianyun_core::config::latest::CaproniConfig;
27pub use xianyun_core::config::latest::ReloaderConfig;
28use xianyun_executor::exec::CommandRunner;
29use xianyun_update::lifecyclehooks::{self, HookType};
30
31#[derive(Args, Debug)]
32pub struct RunArgs {}
33
34/// Port of `run.ErrNoEditModeReloaders`/`run.ErrRepositoryNotDefined`.
35#[derive(Debug, thiserror::Error)]
36pub enum RunError {
37    #[error("no reloaders have edit_mode=true, set edit_mode: true on at least one reloader")]
38    NoEditModeReloaders,
39    #[error("repository {0} referenced by reloader: not defined in config")]
40    RepositoryNotDefined(String),
41    #[error("unsupported reloader type for `caproni run`: {0:?}")]
42    UnsupportedReloaderType(String),
43}
44
45/// A single reloader's execution, as an owned, boxed future. Production
46/// implementations wrap a blocking call (mirror/skaffold's `run`) in
47/// [`tokio::task::spawn_blocking`]; test doubles can be a plain `async`
48/// block.
49pub type ReloaderFuture = Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>;
50
51/// Runs one reloader by name, returning its outcome. Mirrors Go's
52/// `runReloader`/`runWithHooks` folded into a single injected function,
53/// the same DI pattern `up`/`ca`/`deploy`/`run`'s setup pipeline already
54/// use for their `KubectlFactory`/`Connector`/`git::Backend` parameters.
55pub type ReloaderRunFn = Arc<dyn Fn(String, ReloaderConfig) -> ReloaderFuture + Send + Sync>;
56
57pub fn run(_args: &RunArgs, ctx: &Context, cli: &crate::Cli) -> anyhow::Result<()> {
58    let executor: Arc<dyn CommandRunner + Send + Sync> =
59        Arc::new(xianyun_executor::exec::Client::new());
60    let git_backend: Arc<dyn GitBackend + Send + Sync> =
61        Arc::new(xianyun_backends::git::Client::new(executor.clone()));
62
63    let builder_executor = executor.clone();
64
65    // Resolved up front (pure parsing, no I/O) so `run_run` can build the
66    // TUI-routed dispatch once it creates the TUI itself -- see
67    // `run_run`'s doc comment on the TUI section for why the `Config` is
68    // threaded through rather than a `Dispatch` built here: the `Dispatch`
69    // needs `tui.new_writer()`, and the `Tui` doesn't exist yet at this
70    // point.
71    let logging_config = if xianyun_executor::exec::streamed::is_stderr_tty() {
72        let mut config = crate::hooks::resolve_logging_config(cli)?;
73        // Ported from `setupTUI`'s `cfgCopy.Color = &colorEnabled`: force
74        // color on for the TUI-routed logger regardless of the configured
75        // default, since the TUI only ever runs when stderr is already a
76        // real terminal.
77        config.color = Some(true);
78        Some(config)
79    } else {
80        None
81    };
82
83    run_run(
84        ctx,
85        &executor,
86        &git_backend,
87        crate::commands::up::connect_kubectl,
88        logging_config,
89        &move |caproni_cfg, static_vars, kubeconfig, tui_dispatch| {
90            build_reloader_run_fn(
91                builder_executor.clone(),
92                caproni_cfg,
93                static_vars,
94                kubeconfig,
95                tui_dispatch,
96            )
97        },
98    )
99}
100
101/// Builds the [`ReloaderRunFn`] `run_run` hands to
102/// [`execute_reloaders_concurrently`], given the resolved configuration,
103/// cluster kubeconfig, and (if the log-filter TUI is active) the
104/// `tracing::Dispatch` each reloader task should log through for the
105/// duration of its own work. Injected (rather than hard-coded inside
106/// `run_run`) so tests can substitute a fake, instantly-resolving
107/// `ReloaderRunFn` without a live cluster, real `mirrord`/`skaffold`
108/// binaries, or real subprocess execution -- exactly like every other
109/// injected seam in this crate (see the module doc comment).
110pub type RunOneBuilder = dyn Fn(
111    &CaproniConfig,
112    &HashMap<String, String>,
113    &str,
114    Option<tracing::Dispatch>,
115) -> ReloaderRunFn;
116
117/// Port of `run.runRun`: loads the configuration, finds `edit_mode`
118/// reloaders, clones their repositories, resolves the cluster's DNS
119/// domain, fetches its kubeconfig, runs `edit_mode_start` lifecycle
120/// hooks, launches every reloader concurrently via
121/// [`execute_reloaders_concurrently`], then runs `edit_mode_stop`
122/// lifecycle hooks.
123///
124/// `git_backend`/`connect`/`build_run_one` are injected (mirroring
125/// `up::run_start`'s `KubectlFactory` and `ca`/`deploy`'s `Connector`
126/// parameter) so tests can exercise this pipeline without a live
127/// cluster, real git clones, or real reloader subprocesses.
128///
129/// # Errors
130///
131/// Returns [`RunError::NoEditModeReloaders`] if no reloader has
132/// `edit_mode: true`; [`RunError::RepositoryNotDefined`] if a reloader
133/// references a repository absent from `repositories:`; an error if
134/// configuration loading, cloning, creating the cluster manager,
135/// resolving the DNS domain, or getting the kubeconfig fails; and,
136/// once every reloader has finished, whatever
137/// [`collect_reloader_errors`] reports.
138pub fn run_run(
139    ctx: &Context,
140    executor: &Arc<dyn CommandRunner + Send + Sync>,
141    git_backend: &Arc<dyn GitBackend + Send + Sync>,
142    connect: clusterdomain::resolve::Connector,
143    tui_logging_config: Option<xianyun_executor::logging::Config>,
144    build_run_one: &RunOneBuilder,
145) -> anyhow::Result<()> {
146    crate::drivers::ensure_registered();
147
148    let container = load_from_context(ctx).context("loading configuration")?;
149
150    tracing::info!(config_file = %container.global.config_file, "loading configuration");
151
152    let edit_mode_reloaders = find_edit_mode_reloaders(&container.caproni);
153
154    if edit_mode_reloaders.is_empty() {
155        return Err(RunError::NoEditModeReloaders.into());
156    }
157
158    tracing::info!(
159        count = edit_mode_reloaders.len(),
160        "found reloaders with edit mode enabled"
161    );
162
163    clone_repos(
164        &container.caproni,
165        &edit_mode_reloaders,
166        git_backend.as_ref(),
167    )?;
168
169    let mut container = container;
170
171    let manager = xianyun_cluster::registry::new_manager(
172        container.caproni.cluster.clone(),
173        container.global.profile_name.clone(),
174    )
175    .context("creating cluster manager")?;
176
177    let (skipped, err) =
178        clusterdomain::resolve::resolve_running(&mut container, manager.as_ref(), connect);
179
180    if let Some(err) = err {
181        return Err(anyhow::Error::new(err).context("resolving cluster DNS domain"));
182    }
183
184    let container = if skipped.is_none() {
185        // `${CAPRONI_CLUSTER_DOMAIN}` expands with the discovered domain.
186        let ctx = xianyun_core::config::global::with_context(
187            Context::default(),
188            container.global.clone(),
189        );
190        load_from_context(&ctx).context("reloading configuration")?
191    } else {
192        container
193    };
194
195    let edit_mode_reloaders = find_edit_mode_reloaders(&container.caproni);
196
197    let kubeconfig = manager
198        .get_kubeconfig()
199        .context("getting kubeconfig from cluster")?;
200
201    let static_vars = container.resolver.all();
202
203    run_edit_mode_hooks(
204        executor.as_ref(),
205        &container.caproni,
206        &static_vars,
207        &edit_mode_reloaders,
208        HookType::EditModeStart,
209    )
210    .context("running edit-mode-start hooks")?;
211
212    // Ported from `setupTUI`: only started when stderr is a real terminal
213    // (checked by the caller -- `tui_logging_config` is `Some` exactly
214    // when that was true), matching Go's "if running in a terminal, start
215    // the TUI and route the default logger through it" behavior. A
216    // piped/non-interactive invocation runs with no TUI at all, logging
217    // on whatever dispatcher `dispatch()` already installed as the
218    // process-wide default.
219    let tui_dispatch = tui_logging_config
220        .map(|config| {
221            let tui = std::sync::Arc::new(xianyun_executor::logfilter::Tui::new());
222            let writer: Box<dyn std::io::Write + Send> = Box::new(tui.new_writer());
223            let dispatch = xianyun_executor::logging::new_logger_with_writer(&config, writer)
224                .map_err(|err| anyhow::anyhow!("creating TUI logger: {err}"))?;
225
226            let tui_thread = tui.clone();
227            let tui_handle = std::thread::spawn(move || {
228                let out: Box<dyn std::io::Write + Send> = Box::new(std::io::stdout());
229                if let Err(err) = tui_thread.start(out) {
230                    tracing::warn!(error = %err, "log filter TUI exited with an error");
231                }
232            });
233
234            Ok::<_, anyhow::Error>((tui, tui_handle, dispatch))
235        })
236        .transpose()?;
237
238    let run_one = build_run_one(
239        &container.caproni,
240        &static_vars,
241        &kubeconfig,
242        tui_dispatch
243            .as_ref()
244            .map(|(_, _, dispatch)| dispatch.clone()),
245    );
246
247    let runtime = tokio::runtime::Runtime::new().context("creating async runtime")?;
248    let result = runtime.block_on(execute_reloaders_concurrently(
249        edit_mode_reloaders.clone(),
250        run_one,
251        Box::pin(wait_for_shutdown_signal()),
252    ));
253
254    if let Some((tui, tui_handle, _)) = tui_dispatch {
255        tui.quit();
256        let _ = tui_handle.join();
257    }
258
259    if let Err(err) = run_edit_mode_hooks(
260        executor.as_ref(),
261        &container.caproni,
262        &static_vars,
263        &edit_mode_reloaders,
264        HookType::EditModeStop,
265    ) {
266        tracing::error!(error = %err, "running edit-mode-stop hooks");
267    }
268
269    result
270}
271
272/// Waits for `SIGINT` (Ctrl-C) or, on Unix, `SIGTERM`. Port of
273/// `runAndWait`'s signal-handling goroutine (`signal.Notify` +
274/// `syscall.SIGINT`/`SIGTERM`).
275async fn wait_for_shutdown_signal() {
276    #[cfg(unix)]
277    {
278        let mut sigterm =
279            match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
280                Ok(s) => s,
281                Err(err) => {
282                    tracing::warn!(error = %err, "failed to install SIGTERM handler");
283                    let _ = tokio::signal::ctrl_c().await;
284                    return;
285                }
286            };
287
288        tokio::select! {
289            _ = tokio::signal::ctrl_c() => {}
290            _ = sigterm.recv() => {}
291        }
292    }
293
294    #[cfg(not(unix))]
295    {
296        let _ = tokio::signal::ctrl_c().await;
297    }
298}
299
300/// Port of `runAndWait`'s concurrent core: launches every edit-mode
301/// reloader's `run_one` concurrently, waits for all of them to finish
302/// (whether they succeed, fail, or a shutdown signal arrives first),
303/// and collects any real failures.
304///
305/// Uses [`tokio::task::JoinSet`] for structured concurrency: every
306/// spawned task is owned by the `JoinSet` and is guaranteed to be
307/// awaited (via `join_next`) before this function returns, unlike Go's
308/// bare goroutines-plus-`sync.WaitGroup`, where nothing at the type
309/// level stops a goroutine from leaking past `wg.Done()`.
310///
311/// **Structural gap, not a stub** (see `PORT_NOTES.md`): Go's
312/// `runReloader` runs inside a context that's cancelled on shutdown,
313/// and its `reloader.Manager.Run(ctx, ...)` implementations react to
314/// `ctx.Done()` to actually stop early. This port's
315/// `xianyun_deploy::reloader::Manager`-shaped `run` calls (`mirror`/
316/// `skaffold`'s concrete managers) take no context/cancellation
317/// parameter at all -- they block until the underlying subprocess
318/// exits on its own. So when `shutdown` resolves while reloaders are
319/// still running, this function has no way to make them stop early
320/// either; it can only stop *waiting new work* and, like Go's
321/// `wg.Wait()` after `cancelRun()`, still block until every already
322/// -running task finishes naturally. In practice (a real terminal
323/// delivering `SIGINT` to the whole foreground process group) the
324/// underlying `mirrord`/local child processes receive the signal
325/// directly regardless of this crate's own cancellation plumbing, which
326/// is the same mechanism Go's version ultimately relies on for a
327/// process that ignores `ctx.Done()`.
328///
329/// # Errors
330///
331/// Returns whatever [`collect_reloader_errors`] reports once every
332/// reloader has finished.
333pub async fn execute_reloaders_concurrently(
334    reloaders: HashMap<String, ReloaderConfig>,
335    run_one: ReloaderRunFn,
336    shutdown: Pin<Box<dyn Future<Output = ()> + Send>>,
337) -> anyhow::Result<()> {
338    let mut join_set: tokio::task::JoinSet<(String, anyhow::Result<()>)> =
339        tokio::task::JoinSet::new();
340
341    for (name, reloader_config) in reloaders {
342        let run_one = run_one.clone();
343        let task_name = name.clone();
344        join_set.spawn(async move {
345            let result = run_one(task_name.clone(), reloader_config).await;
346            (task_name, result)
347        });
348    }
349
350    tracing::info!(
351        reloaders = join_set.len(),
352        "all edit mode processes started, press Ctrl-C to stop"
353    );
354
355    let mut errors = Vec::new();
356    let mut shutdown_requested = false;
357    let mut shutdown = shutdown;
358
359    loop {
360        tokio::select! {
361            () = &mut shutdown, if !shutdown_requested => {
362                tracing::info!("shutting down edit mode, reverting cluster to production mode");
363                shutdown_requested = true;
364            }
365            joined = join_set.join_next() => {
366                match joined {
367                    None => break,
368                    Some(Ok((name, Ok(())))) => {
369                        tracing::info!(reloader = %name, "reloader stopped");
370                    }
371                    Some(Ok((name, Err(err)))) => {
372                        tracing::error!(reloader = %name, error = %err, "reloader error");
373                        errors.push(err);
374                    }
375                    Some(Err(join_err)) => {
376                        errors.push(anyhow::anyhow!("reloader task panicked: {join_err}"));
377                    }
378                }
379            }
380        }
381    }
382
383    tracing::info!("edit mode processes stopped");
384
385    collect_reloader_errors(errors)
386}
387
388/// Production [`ReloaderRunFn`]: dispatches to the concrete `mirror`/
389/// `skaffold` reloader manager for `reloader_config.reloader_type`,
390/// running it (via [`tokio::task::spawn_blocking`], since both
391/// managers' `run` blocks synchronously until their subprocess exits)
392/// against a fresh [`kubectl::Client`] built from the cluster's
393/// kubeconfig.
394///
395/// **Not ported** (see `PORT_NOTES.md`'s "fifth pass" entry): precondition
396/// checks (`reloader.Manager.CheckPreconditions`) and mirror
397/// deployment-copy preparation/cleanup
398/// (`mirror.Manager.{PrepareFromReloaderConfigs,
399/// CleanupCopyDeployments,CleanupStaleMirrordAgents}`). Neither is part
400/// of the concurrent execution engine this pass was scoped to port;
401/// each is a self-contained follow-up.
402///
403/// The log filter TUI (`logfilter.NewTUI`) *is* now wired, in
404/// [`run_run`]: a [`xianyun_executor::logfilter::Tui`] runs its
405/// interactive event loop on a dedicated OS thread (it blocks on
406/// terminal I/O, so it can't run on an async executor's worker thread)
407/// for the lifetime of [`execute_reloaders_concurrently`], and is
408/// told to quit once that finishes -- mirroring Go's `tui.Quit()`
409/// deferred at the end of `runRun`. Each reloader's own output is also
410/// now routed through it, via `tui_dispatch` below.
411///
412/// `tui_dispatch`, when `Some` (the log-filter TUI is active), is
413/// installed as the `tracing` default for the duration of this
414/// reloader's `spawn_blocking` work via
415/// `tracing::dispatcher::with_default` -- scoped to that one worker
416/// thread, not process-wide. This is the Rust equivalent of Go's
417/// `setupTUI` swapping `slog.Default()` for the whole process:
418/// `tracing::dispatcher::set_global_default` can only succeed once per
419/// process (see `hooks::resolve_logging_config`'s doc comment), so
420/// routing `run`'s reloader output through the TUI for `run`'s lifetime
421/// only -- while every *other* command keeps logging on the dispatcher
422/// `commands::dispatch` already installed -- has to be a scoped
423/// override per task rather than a second global swap. Go's reloaders
424/// log via the ambient `slog` package-level functions with no awareness
425/// of the swap; this port's reloader code (`mirror`/`skaffold` managers,
426/// `run_single_reloader`) is unchanged for the same reason -- it logs
427/// via plain `tracing::info!`/`tracing::debug!` macros, which resolve to
428/// whatever dispatcher is active on the calling thread.
429#[must_use]
430pub fn build_reloader_run_fn(
431    executor: Arc<dyn CommandRunner + Send + Sync>,
432    caproni_cfg: &CaproniConfig,
433    static_vars: &HashMap<String, String>,
434    kubeconfig: &str,
435    tui_dispatch: Option<tracing::Dispatch>,
436) -> ReloaderRunFn {
437    let caproni_cfg = caproni_cfg.clone();
438    let static_vars = static_vars.clone();
439    let kubeconfig = kubeconfig.to_string();
440
441    Arc::new(move |name, reloader_config| {
442        let executor = executor.clone();
443        let caproni_cfg = caproni_cfg.clone();
444        let static_vars = static_vars.clone();
445        let kubeconfig = kubeconfig.clone();
446        let tui_dispatch = tui_dispatch.clone();
447
448        Box::pin(async move {
449            tokio::task::spawn_blocking(move || {
450                let run = || {
451                    run_single_reloader(
452                        &executor,
453                        &kubeconfig,
454                        &name,
455                        &reloader_config,
456                        &caproni_cfg,
457                        &static_vars,
458                    )
459                };
460
461                match &tui_dispatch {
462                    Some(dispatch) => tracing::dispatcher::with_default(dispatch, run),
463                    None => run(),
464                }
465                // Port of `runReloader`'s outer `fmt.Errorf("running
466                // reloader %s: %w", reloaderName, err)` wrap, applied to
467                // *any* failure from `runWithHooks` (hook failure or
468                // reloader failure alike) -- `run_single_reloader` only
469                // adds its own, more specific "running hooks
470                // before/after reloader"/no wrap (reloader failure)
471                // context; this is the second, outer layer Go's
472                // `runReloader` always adds on top.
473                .with_context(|| format!("running reloader {name}"))
474            })
475            .await
476            .context("reloader task panicked")?
477        }) as ReloaderFuture
478    })
479}
480
481/// Runs a single reloader to completion, wrapped with its
482/// `edit_mode_start`/`edit_mode_stop` hooks. Port of Go's
483/// `runWithHooks`.
484fn run_single_reloader(
485    executor: &Arc<dyn CommandRunner + Send + Sync>,
486    kubeconfig: &str,
487    name: &str,
488    reloader_config: &ReloaderConfig,
489    caproni_cfg: &CaproniConfig,
490    static_vars: &HashMap<String, String>,
491) -> anyhow::Result<()> {
492    tracing::info!(
493        reloader = name,
494        kind = %reloader_config.reloader_type,
495        "starting reloader in edit mode"
496    );
497
498    let repo_config = caproni_cfg
499        .repositories
500        .as_ref()
501        .and_then(|repos| repos.get(&reloader_config.repository))
502        .ok_or_else(|| RunError::RepositoryNotDefined(reloader_config.repository.clone()))?;
503
504    let resolver = StaticResolver(static_vars.clone());
505
506    let start_hooks =
507        lifecyclehooks::resolve_reloader_hooks(reloader_config, HookType::EditModeStart);
508    lifecyclehooks::run_hooks(
509        executor.as_ref(),
510        caproni_cfg,
511        &resolver,
512        repo_config,
513        &reloader_config.repository,
514        &start_hooks,
515        None,
516    )
517    .with_context(|| format!("running hooks before reloader {name}"))?;
518
519    let kubectl_backend = kubectl::Client::with_config(executor.clone(), kubeconfig);
520
521    let run_result =
522        ReloaderManager::new(&reloader_config.reloader_type, Box::new(kubectl_backend))
523            .and_then(|mgr| mgr.run(executor.as_ref(), reloader_config, caproni_cfg, ""))
524            .with_context(|| format!("running reloader {name}"));
525
526    let stop_hooks =
527        lifecyclehooks::resolve_reloader_hooks(reloader_config, HookType::EditModeStop);
528    let hook_result = lifecyclehooks::run_hooks(
529        executor.as_ref(),
530        caproni_cfg,
531        &resolver,
532        repo_config,
533        &reloader_config.repository,
534        &stop_hooks,
535        None,
536    )
537    .with_context(|| format!("running hooks after reloader {name}"));
538
539    match (run_result, hook_result) {
540        (Ok(()), Ok(())) => Ok(()),
541        (Err(err), Ok(())) => Err(err),
542        (Ok(()), Err(hook_err)) => Err(hook_err),
543        (Err(err), Err(hook_err)) => Err(err.context(format!("also: {hook_err}"))),
544    }
545}
546
547/// A [`xianyun_core::variables::resolver::Resolver`] backed by a plain,
548/// owned `HashMap`, so lifecycle hooks can be run inside a
549/// `spawn_blocking` task without moving `Container::resolver`'s
550/// `Box<dyn Resolver>` across a thread boundary (that trait object has
551/// no `Send`/`Sync` bound, unlike this crate's own concrete type here).
552struct StaticResolver(HashMap<String, String>);
553
554impl xianyun_core::variables::resolver::Resolver for StaticResolver {
555    fn expand(&self, name: &str) -> String {
556        self.0
557            .get(name)
558            .cloned()
559            .unwrap_or_else(|| std::env::var(name).unwrap_or_default())
560    }
561
562    fn all(&self) -> HashMap<String, String> {
563        self.0.clone()
564    }
565
566    fn lookup(&self, name: &str) -> Option<String> {
567        self.0
568            .get(name)
569            .cloned()
570            .or_else(|| std::env::var(name).ok())
571    }
572}
573
574/// Dispatches to the concrete `mirror`/`skaffold` reloader manager for a
575/// given `reloader_type`. Both managers share the same `run`/
576/// `check_preconditions` method shapes but have no common trait to
577/// dispatch through generically in this workspace (see
578/// `PORT_NOTES.md`), so this small enum stands in for one.
579enum ReloaderManager {
580    Mirror(xianyun_deploy::reloader::mirror::manager::Manager),
581    Skaffold(xianyun_deploy::reloader::skaffold::manager::Manager),
582}
583
584impl ReloaderManager {
585    fn new(
586        reloader_type: &str,
587        kubectl_backend: Box<dyn kubectl::Backend + Send + Sync>,
588    ) -> anyhow::Result<Self> {
589        match reloader_type {
590            "mirror" => Ok(Self::Mirror(
591                xianyun_deploy::reloader::mirror::manager::Manager::new(kubectl_backend),
592            )),
593            "skaffold" => Ok(Self::Skaffold(
594                xianyun_deploy::reloader::skaffold::manager::Manager::new(kubectl_backend),
595            )),
596            other => Err(RunError::UnsupportedReloaderType(other.to_string()).into()),
597        }
598    }
599
600    fn run(
601        &self,
602        executor: &dyn CommandRunner,
603        reloader_config: &ReloaderConfig,
604        caproni_config: &CaproniConfig,
605        kubeconfig_path: &str,
606    ) -> anyhow::Result<()> {
607        match self {
608            Self::Mirror(mgr) => mgr
609                .run(executor, reloader_config, caproni_config, kubeconfig_path)
610                .map_err(anyhow::Error::from),
611            Self::Skaffold(mgr) => mgr
612                .run(executor, reloader_config, caproni_config, kubeconfig_path)
613                .map_err(anyhow::Error::from),
614        }
615    }
616}
617
618/// Port of `run.runEditModeStartHooks`/the `edit_mode_stop` half of
619/// `runAndWait`'s cleanup goroutine: runs the given hook type for every
620/// repository that has an active edit-mode reloader.
621fn run_edit_mode_hooks(
622    executor: &dyn CommandRunner,
623    caproni_cfg: &CaproniConfig,
624    static_vars: &HashMap<String, String>,
625    edit_mode_reloaders: &HashMap<String, ReloaderConfig>,
626    hook_type: HookType,
627) -> anyhow::Result<()> {
628    let Some(repositories) = &caproni_cfg.repositories else {
629        return Ok(());
630    };
631
632    let resolver = StaticResolver(static_vars.clone());
633
634    for (name, repo) in repositories.iter() {
635        if !has_repository_active_reloader(name, edit_mode_reloaders) {
636            continue;
637        }
638
639        let hooks = lifecyclehooks::filter_hooks(&repo.lifecycle_hooks, hook_type);
640
641        lifecyclehooks::run_hooks(executor, caproni_cfg, &resolver, repo, name, &hooks, None)
642            .with_context(|| format!("running hooks in repository '{name}'"))?;
643    }
644
645    Ok(())
646}
647
648/// Port of `run.cloneRepos`: ensures git repositories are cloned for
649/// `edit_mode` reloaders.
650fn clone_repos(
651    caproni_cfg: &CaproniConfig,
652    edit_mode_reloaders: &HashMap<String, ReloaderConfig>,
653    git_backend: &dyn GitBackend,
654) -> anyhow::Result<()> {
655    let mut repos_to_clone: std::collections::HashSet<&str> = std::collections::HashSet::new();
656
657    for reloader_config in edit_mode_reloaders.values() {
658        repos_to_clone.insert(&reloader_config.repository);
659    }
660
661    for repo_name in repos_to_clone {
662        let Some(repo_config) = caproni_cfg
663            .repositories
664            .as_ref()
665            .and_then(|repos| repos.get(&repo_name.to_string()))
666        else {
667            return Err(RunError::RepositoryNotDefined(repo_name.to_string()).into());
668        };
669
670        let repo_dir = caproni_cfg.resolve_path(&repo_config.directory);
671
672        tracing::info!(name = repo_name, directory = %repo_dir, "ensuring repository is cloned");
673
674        git_backend
675            .ensure_cloned(&repo_config.repository, &repo_dir, "on-demand")
676            .with_context(|| format!("cloning repository {repo_name}"))?;
677    }
678
679    Ok(())
680}
681
682/// Port of `findEditModeReloaders`: returns every reloader with
683/// `edit_mode: true`.
684#[must_use]
685pub fn find_edit_mode_reloaders(caproni_cfg: &CaproniConfig) -> HashMap<String, ReloaderConfig> {
686    let mut reloaders = HashMap::new();
687
688    if let Some(configured) = &caproni_cfg.reloaders {
689        for (name, reloader_config) in configured.iter() {
690            if reloader_config.edit_mode {
691                reloaders.insert(name.clone(), reloader_config.clone());
692            }
693        }
694    }
695
696    reloaders
697}
698
699/// Port of `reloaderNames`: the sorted names of a reloader map, for
700/// deterministic logging.
701#[must_use]
702pub fn reloader_names(reloaders: &HashMap<String, ReloaderConfig>) -> Vec<String> {
703    let mut names: Vec<String> = reloaders.keys().cloned().collect();
704    names.sort();
705    names
706}
707
708/// Port of `hasRepositoryActiveReloader`: whether any edit-mode reloader
709/// uses the given repository.
710#[must_use]
711pub fn has_repository_active_reloader(
712    repo_name: &str,
713    edit_mode_reloaders: &HashMap<String, ReloaderConfig>,
714) -> bool {
715    edit_mode_reloaders
716        .values()
717        .any(|r| r.edit_mode && r.repository == repo_name)
718}
719
720/// Port of `collectMirrorNamespaces`: the deduplicated set of target
721/// namespaces from all `mirror` reloaders, in first-seen order.
722#[must_use]
723pub fn collect_mirror_namespaces(reloaders: &HashMap<String, ReloaderConfig>) -> Vec<String> {
724    let mut namespaces = Vec::new();
725
726    for reloader in reloaders.values() {
727        if reloader.reloader_type != "mirror" {
728            continue;
729        }
730
731        let Some(mirror) = &reloader.mirror else {
732            continue;
733        };
734        let Some(target) = &mirror.target else {
735            continue;
736        };
737
738        if !namespaces.contains(&target.namespace) {
739            namespaces.push(target.namespace.clone());
740        }
741    }
742
743    namespaces
744}
745
746/// Port of `collectReloaderErrors`: filters out cancellation errors
747/// (expected during graceful shutdown) and returns any real failures.
748///
749/// Deviation from Go: `errors.Join` supports unwrapping into multiple
750/// underlying errors via `Unwrap() []error`, which `anyhow::Error` has no
751/// equivalent for. When more than one real (non-cancellation) error
752/// remains, they're joined into a single `anyhow::Error` whose message
753/// contains every original error's `Display` output, but only the single
754/// error case preserves the original error's type for `downcast_ref`
755/// (matching every test that exercises this: they only ever inject one
756/// real failure alongside cancellations).
757///
758/// # Errors
759///
760/// Returns an error if any input error is not a cancellation
761/// ([`xianyun_executor::exec::ExecError::Cancelled`]).
762pub fn collect_reloader_errors(errors: Vec<anyhow::Error>) -> anyhow::Result<()> {
763    let mut real_errors: Vec<anyhow::Error> = errors
764        .into_iter()
765        .filter(|err| !is_cancelled(err))
766        .collect();
767
768    match real_errors.len() {
769        0 => Ok(()),
770        1 => Err(real_errors.remove(0)),
771        _ => {
772            let combined = real_errors
773                .iter()
774                .map(std::string::ToString::to_string)
775                .collect::<Vec<_>>()
776                .join("\n");
777            Err(anyhow::anyhow!(combined))
778        }
779    }
780}
781
782/// Whether `err`'s chain contains
783/// [`xianyun_executor::exec::ExecError::Cancelled`], the closest
784/// equivalent this workspace has to Go's `context.Canceled`.
785fn is_cancelled(err: &anyhow::Error) -> bool {
786    err.chain().any(|cause| {
787        matches!(
788            cause.downcast_ref::<xianyun_executor::exec::ExecError>(),
789            Some(xianyun_executor::exec::ExecError::Cancelled)
790        )
791    })
792}