1use 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#[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
45pub type ReloaderFuture = Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>;
50
51pub 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 let logging_config = if xianyun_executor::exec::streamed::is_stderr_tty() {
72 let mut config = crate::hooks::resolve_logging_config(cli)?;
73 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
101pub type RunOneBuilder = dyn Fn(
111 &CaproniConfig,
112 &HashMap<String, String>,
113 &str,
114 Option<tracing::Dispatch>,
115) -> ReloaderRunFn;
116
117pub 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 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 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
272async 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
300pub 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#[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 .with_context(|| format!("running reloader {name}"))
474 })
475 .await
476 .context("reloader task panicked")?
477 }) as ReloaderFuture
478 })
479}
480
481fn 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
547struct 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
574enum 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
618fn 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
648fn 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#[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#[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#[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#[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
746pub 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
782fn 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}