Skip to main content

xianyun_cli/commands/
deploy.rs

1//! `caproni deploy`: deploy or update Helm charts to the cluster.
2//!
3//! Port of Go's `cmd/deploy` package.
4
5use std::sync::Arc;
6
7use anyhow::Context as _;
8use clap::Args;
9use xianyun_cluster::clusterdomain;
10use xianyun_cluster::driver::{Driver, State};
11use xianyun_core::config::caproni::load_from_context;
12use xianyun_core::config::global::Context;
13use xianyun_core::config::latest::CaproniConfig;
14use xianyun_deploy::deploy::orchestrator::{DeployerFilterFunc, Orchestrator};
15use xianyun_executor::exec::CommandRunner;
16
17#[derive(Args, Debug)]
18pub struct DeployArgs {
19    /// Deploy only the specified deployer (must match a key under
20    /// deployers: in caproni.yaml).
21    #[arg(long = "repo")]
22    pub repo: Option<String>,
23
24    /// Update repositories before deploying (pull latest changes for
25    /// deployer-referenced repos).
26    #[arg(long = "update")]
27    pub update: bool,
28}
29
30pub fn run(args: &DeployArgs, ctx: &Context) -> anyhow::Result<()> {
31    run_deploy(
32        ctx,
33        args.repo.as_deref(),
34        args.update,
35        crate::commands::up::connect_kubectl,
36    )
37}
38
39/// Port of `deploy.ErrClusterNotReady`'s call site: `deploy::
40/// verify_cluster_ready` has no port in `xianyun-deploy` yet (it isn't
41/// among the four crates this task's dispatch scoped for
42/// sibling-crate checks), so it's ported here directly — it's a
43/// three-line status check, not a meaningful chunk of missing
44/// functionality to leave stubbed.
45#[derive(Debug, thiserror::Error)]
46pub enum DeployError {
47    #[error("cluster is not ready (state: {0:?}). Run 'caproni up' first")]
48    ClusterNotReady(Option<State>),
49    #[error("one or more repository updates failed")]
50    RepositoryUpdatesFailed,
51}
52
53/// Port of `deploy.VerifyClusterReady`.
54///
55/// Returns `anyhow::Error` (not `DeployError`) so a failure of
56/// `mgr.status()` itself keeps the underlying `DriverError`'s identity
57/// for `downcast_ref`-style checks; only the "wrong state" case is a
58/// `DeployError::ClusterNotReady`.
59fn verify_cluster_ready(mgr: &dyn Driver) -> anyhow::Result<()> {
60    let status = mgr.status().context("checking cluster status")?;
61
62    if status.state != Some(State::Ready) {
63        anyhow::bail!(DeployError::ClusterNotReady(status.state));
64    }
65
66    tracing::info!(state = ?status.state, driver = mgr.name(), "cluster verified");
67
68    Ok(())
69}
70
71/// Port of `deploy.runDeploy`: loads the configuration, verifies the
72/// cluster is ready, resolves its DNS domain, optionally updates
73/// repositories, then deploys the (optionally filtered) declared
74/// deployers.
75///
76/// # Errors
77///
78/// Returns an error if configuration loading, verifying the cluster is
79/// ready, getting its kubeconfig, resolving its DNS domain, or deploying
80/// fails. Repository-update failures are logged and reported via
81/// [`DeployError::RepositoryUpdatesFailed`] but do not abort deployment
82/// (matching Go's `slog.Warn`-then-continue).
83pub fn run_deploy(
84    ctx: &Context,
85    repo_filter: Option<&str>,
86    update: bool,
87    connect: clusterdomain::resolve::Connector,
88) -> anyhow::Result<()> {
89    crate::drivers::ensure_registered();
90
91    let mut container = load_from_context(ctx).context("loading configuration")?;
92
93    let deployer_filter: Option<Box<DeployerFilterFunc<'_>>> = repo_filter.map(|repo| {
94        tracing::info!(deployer = repo, "deploying single deployer");
95        Box::new(move |name: &str| name == repo) as Box<DeployerFilterFunc<'_>>
96    });
97
98    let manager = xianyun_cluster::registry::new_manager(
99        container.caproni.cluster.clone(),
100        container.global.profile_name.clone(),
101    )
102    .context("creating cluster manager")?;
103
104    verify_cluster_ready(manager.as_ref()).context("verifying cluster ready")?;
105
106    let kubeconfig = manager.get_kubeconfig().context("getting kubeconfig")?;
107
108    let reader = connect(manager.as_ref()).context("connecting to cluster DNS")?;
109
110    let mut container = clusterdomain::resolve::resolve_and_reload(
111        &mut container,
112        Some(manager.as_ref()),
113        reader.as_ref(),
114    )
115    .context("resolving cluster DNS domain")?;
116
117    if update {
118        tracing::info!("updating repositories");
119
120        if let Err(e) = update_repositories_for_deploy(&container, deployer_filter.as_deref()) {
121            tracing::warn!(error = %e, "repository update encountered errors");
122        }
123    }
124
125    tracing::info!("deploying charts");
126
127    let orch = Orchestrator::new();
128    orch.deploy_all(&container, deployer_filter.as_deref(), &kubeconfig)
129        .context("deploying charts")?;
130
131    tracing::info!("all deployments complete");
132
133    let _ = &mut container;
134
135    Ok(())
136}
137
138/// Port of `deploy.updateRepositoriesForDeploy`: updates repositories
139/// referenced by deployers being deployed. Returns an error if any
140/// repository update fails, but this should not abort deployment (the
141/// caller logs and continues).
142fn update_repositories_for_deploy(
143    container: &xianyun_core::config::container::Container,
144    deployer_filter: Option<&DeployerFilterFunc<'_>>,
145) -> Result<(), DeployError> {
146    let executor: Arc<dyn CommandRunner + Send + Sync> =
147        Arc::new(xianyun_executor::exec::Client::new());
148    let git_backend = Arc::new(xianyun_backends::git::Client::new(executor.clone()));
149
150    let repo_filter = build_repo_filter_for_deployers(&container.caproni, deployer_filter);
151
152    let updater = xianyun_update::updater::Updater::new(
153        git_backend,
154        xianyun_update::updater::UpdateOptions {
155            strategy: xianyun_update::updater::UpdateStrategy::FullUpdate,
156            filter: Some(repo_filter),
157            ..Default::default()
158        },
159    );
160
161    let (results, success) = updater.update_repositories(executor.as_ref(), container);
162
163    for result in &results {
164        match result.status {
165            xianyun_update::updater::UpdateStatus::Failed => {
166                tracing::warn!(
167                    repo = %result.repo_name,
168                    error = ?result.error,
169                    "repository update failed"
170                );
171            }
172            xianyun_update::updater::UpdateStatus::Updated => {
173                tracing::info!(repo = %result.repo_name, notes = %result.notes, "repository updated");
174            }
175            _ => {}
176        }
177    }
178
179    if !success {
180        return Err(DeployError::RepositoryUpdatesFailed);
181    }
182
183    Ok(())
184}
185
186/// Port of `deploy.buildRepoFilterForDeployers`: creates a repo filter
187/// that includes only repos referenced by deployers matching the
188/// deployer filter.
189fn build_repo_filter_for_deployers(
190    caproni_cfg: &CaproniConfig,
191    deployer_filter: Option<&DeployerFilterFunc<'_>>,
192) -> xianyun_update::updater::RepoFilterFn {
193    // Materialized eagerly (rather than captured by the returned
194    // closure) so the closure only owns a plain `HashSet<String>`,
195    // trivially `Send + Sync` — unlike `deployer_filter`'s `&dyn Fn`,
196    // which `RepoFilterFn`'s `Send + Sync` bound rejects.
197    let mut required_repos = std::collections::HashSet::new();
198
199    if let Some(deployers) = &caproni_cfg.deployers {
200        for (name, deployer_config) in deployers.iter() {
201            if let Some(filter) = deployer_filter
202                && !filter(name)
203            {
204                continue;
205            }
206
207            if let Some(repository) = &deployer_config.repository {
208                required_repos.insert(repository.clone());
209            }
210        }
211    }
212
213    Box::new(move |repo_name: &str| required_repos.contains(repo_name))
214}