Skip to main content

xianyun_cli/commands/
up.rs

1//! `caproni up` (alias `start`): start the cluster and deploy all deployers.
2//!
3//! Port of Go's `cmd/up` package.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::Context as _;
10use clap::Args;
11use xianyun_backends::kubectl;
12use xianyun_cluster::driver::{DnsReader, Driver, DriverError};
13use xianyun_core::config::caproni::load_from_context;
14use xianyun_core::config::container::Container;
15use xianyun_core::config::global::Context;
16use xianyun_core::config::latest::CaproniConfig;
17use xianyun_core::config::types::ClusterConfig;
18use xianyun_executor::exec::CommandRunner;
19
20#[derive(Args, Debug)]
21pub struct UpArgs {}
22
23pub fn run(_args: &UpArgs, ctx: &Context) -> anyhow::Result<()> {
24    run_start(ctx, &default_kubectl_factory)
25}
26
27/// Port of `up.clusterAPIReadyTimeout`: how long `run_start` waits for the
28/// cluster API to answer after the driver reports the cluster started.
29const CLUSTER_API_READY_TIMEOUT: Duration = Duration::from_secs(180);
30/// Port of `up.clusterAPIReadyInterval`.
31const CLUSTER_API_READY_INTERVAL: Duration = Duration::from_secs(2);
32
33/// Port of Go's `kubectlFactory`: builds a kubectl backend once the
34/// cluster has been started and its kubeconfig is available. Tests inject
35/// a factory that returns a fake backend without touching the cluster.
36pub type KubectlFactory = dyn Fn(&dyn Driver) -> anyhow::Result<Box<dyn kubectl::Backend>>;
37
38/// Port of `up.defaultKubectlFactory`: fetches the kubeconfig from the
39/// cluster and builds a real kubectl client bound to it.
40fn default_kubectl_factory(mgr: &dyn Driver) -> anyhow::Result<Box<dyn kubectl::Backend>> {
41    let kubeconfig = mgr.get_kubeconfig().context("getting kubeconfig")?;
42    let executor: Arc<dyn CommandRunner + Send + Sync> =
43        Arc::new(xianyun_executor::exec::Client::new());
44    Ok(Box::new(kubectl::Client::with_config(
45        executor,
46        &kubeconfig,
47    )))
48}
49
50/// Adapts a [`kubectl::Backend`] to [`DnsReader`] so `clusterdomain::resolve`
51/// (which only needs `get`/`get_raw`/`run_job`) can read cluster DNS state
52/// through it, mapping `KubectlError` into `DriverError::Other` the same
53/// way every other driver in this workspace surfaces backend failures.
54///
55/// `pub(crate)` (not private): `commands::ca` reuses this and
56/// [`connect_kubectl`] as the `Connector` its `clusterdomain::
57/// resolve_running` call needs, mirroring Go's `clusterdomain.
58/// ConnectKubectl` being shared across `cmd/up` and `cmd/ca`.
59pub(crate) struct KubectlDnsReader {
60    backend: Box<dyn kubectl::Backend>,
61}
62
63/// Port of `clusterdomain.ConnectKubectl`: opens a kubectl-backed
64/// [`DnsReader`] on a cluster that is known to be running, matching
65/// [`Connector`](xianyun_cluster::clusterdomain::resolve::Connector)'s
66/// signature.
67pub(crate) fn connect_kubectl(
68    mgr: &dyn Driver,
69) -> Result<Box<dyn DnsReader>, xianyun_cluster::clusterdomain::resolve::ResolveError> {
70    let backend = default_kubectl_factory(mgr).map_err(|e| {
71        xianyun_cluster::clusterdomain::resolve::ResolveError::Driver(DriverError::Other(
72            e.to_string(),
73        ))
74    })?;
75    Ok(Box::new(KubectlDnsReader { backend }))
76}
77
78impl DnsReader for KubectlDnsReader {
79    fn get(&self, resource_type: &str, name: &str, namespace: &str) -> Result<String, DriverError> {
80        self.backend
81            .get(resource_type, name, namespace)
82            .map_err(|e| DriverError::Other(e.to_string()))
83    }
84
85    fn get_raw(&self, path: &str) -> Result<String, DriverError> {
86        self.backend
87            .get_raw(path)
88            .map_err(|e| DriverError::Other(e.to_string()))
89    }
90
91    fn run_job(
92        &self,
93        image: &str,
94        namespace: &str,
95        timeout: Duration,
96        command: &[&str],
97    ) -> Result<String, DriverError> {
98        self.backend
99            .run_job(image, namespace, timeout, command)
100            .map_err(|e| DriverError::Other(e.to_string()))
101    }
102}
103
104/// Port of `up.runStart`: loads the configuration, ensures the managed
105/// registry mirror (if configured), starts the cluster, resolves its DNS
106/// domain, clones repositories needed by edit-mode deployers/reloaders,
107/// prepares the dev CA, runs `edit_mode_prepare` hooks, then deploys every
108/// declared deployer.
109///
110/// # Errors
111///
112/// Returns an error if configuration loading, the managed registry,
113/// starting the cluster, getting its status/kubeconfig, resolving its DNS
114/// domain, cloning repositories, preparing the dev CA, running prepare
115/// hooks, or deploying fails.
116///
117/// /etc/hosts validation (Go's non-blocking `validateAndWarnHosts`) is not
118/// ported: `xianyun_backends::hosts` is still an unimplemented stub in
119/// this worktree. See `PORT_NOTES.md`.
120pub fn run_start(ctx: &Context, new_kubectl: &KubectlFactory) -> anyhow::Result<()> {
121    crate::drivers::ensure_registered();
122
123    let mut container = load_from_context(ctx).context("loading configuration")?;
124
125    tracing::info!(
126        driver = %container.caproni.cluster.driver,
127        "configuration loaded successfully"
128    );
129
130    ensure_managed_registry(&mut container)?;
131
132    let manager = xianyun_cluster::registry::new_manager(
133        container.caproni.cluster.clone(),
134        container.global.profile_name.clone(),
135    )
136    .context("creating cluster manager")?;
137
138    tracing::info!(driver = %container.caproni.cluster.driver, "starting cluster");
139
140    manager.start().context("starting cluster")?;
141
142    tracing::info!("cluster started successfully");
143
144    let status = manager.status().context("getting cluster status")?;
145
146    tracing::info!(state = ?status.state, driver = manager.name(), "cluster status");
147
148    let kubectl_backend = new_kubectl(manager.as_ref()).context("creating kubectl backend")?;
149
150    let kversion = kubectl_backend
151        .get_version(CLUSTER_API_READY_TIMEOUT, CLUSTER_API_READY_INTERVAL)
152        .context("getting kubectl version")?;
153
154    tracing::info!(
155        tag = %kversion.server_version.git_version,
156        "cluster version"
157    );
158
159    let reader = KubectlDnsReader {
160        backend: kubectl_backend,
161    };
162
163    xianyun_cluster::clusterdomain::resolve::resolve(
164        &mut container,
165        Some(manager.as_ref()),
166        &reader,
167    )
168    .context("resolving cluster DNS domain")?;
169
170    let kubeconfig = manager.get_kubeconfig().context("getting kubeconfig")?;
171
172    clone_repos(&container)?;
173
174    // Reload config now that repos are cloned so caproni-project.yaml files
175    // inside the freshly cloned repos are picked up.
176    let ctx =
177        xianyun_core::config::global::with_context(Context::default(), container.global.clone());
178    let mut container = load_from_context(&ctx).context("reloading configuration after clone")?;
179
180    // After the reload, so the resolved cluster domain is in the CA constraints.
181    ensure_dev_ca(&container.global.profile_name, &container.caproni)
182        .context("preparing dev CA")?;
183
184    run_prepare_hooks(&container)?;
185
186    let orch = xianyun_deploy::deploy::orchestrator::Orchestrator::new();
187    orch.deploy_all(&container, None, &kubeconfig)
188        .context("deploying all deployers")?;
189
190    // Config is loaded through `load_from_context` again on the next
191    // command invocation; nothing further needs `container` mutably, but
192    // keep the binding so a future caller extending this function has it
193    // in scope without re-loading.
194    let _ = &mut container;
195
196    tracing::info!("caproni cluster is ready");
197
198    Ok(())
199}
200
201/// Port of `up.ensureManagedRegistry`: resolves the registry mirror
202/// endpoint for the managed case. See the Go doc comment for the full
203/// semantics (bring-your-own mirrors are left untouched; a managed mirror
204/// with no upstreams, or a failure to start zot, disables the mirror).
205#[allow(clippy::unnecessary_wraps)] // kept `Result` for symmetry with Go's ensureManagedRegistry
206pub fn ensure_managed_registry(container: &mut Container) -> anyhow::Result<()> {
207    let Some(mirror) = container.caproni.cluster.registry_mirror.clone() else {
208        return Ok(());
209    };
210
211    if !mirror.managed {
212        return Ok(());
213    }
214
215    if mirror.upstreams.is_empty() {
216        container.caproni.cluster.registry_mirror = None;
217        return Ok(());
218    }
219
220    match xianyun_deploy::registrycache::ensure(&container.global.profile_name, &mirror.upstreams) {
221        Ok(endpoint) => {
222            let host = node_mirror_host(&container.caproni.cluster.driver);
223            let mut mirror = mirror;
224            mirror.endpoint = format!("http://{}", endpoint.host_port(host));
225            container.caproni.cluster.registry_mirror = Some(mirror);
226        }
227        Err(e) => {
228            tracing::warn!(
229                error = %e,
230                hint = "image pulls will go directly to upstream; check `caproni registry status` and ~/.local/share/caproni/registry/zot.log",
231                "registry cache could not be started; continuing without it"
232            );
233            container.caproni.cluster.registry_mirror = None;
234        }
235    }
236
237    Ok(())
238}
239
240/// Port of `up.nodeMirrorHost`: the hostname cluster nodes use to reach
241/// the host machine where the managed zot cache listens.
242#[must_use]
243pub fn node_mirror_host(driver: &str) -> &'static str {
244    const COLIMA_HOST: &str = "host.docker.internal";
245    const K3D_HOST: &str = "host.k3d.internal";
246
247    match driver {
248        "k3d" => K3D_HOST,
249        "auto" => {
250            if std::env::consts::OS == "linux" {
251                K3D_HOST
252            } else {
253                COLIMA_HOST
254            }
255        }
256        _ => COLIMA_HOST,
257    }
258}
259
260/// Port of `up.ensureDevCA`: generates (or loads) caproni's per-profile
261/// dev CA so it exists on disk before the deployer orchestrator runs.
262pub fn ensure_dev_ca(profile_name: &str, caproni_cfg: &CaproniConfig) -> anyhow::Result<()> {
263    let pki_dir = pki_dir_for_profile(profile_name).context("resolving PKI directory")?;
264
265    let permitted_dns = permitted_dns_for_cluster(&caproni_cfg.cluster, profile_name);
266
267    let (ca, previous) = xianyun_pki::ca::ensure_current(&pki_dir, &permitted_dns)
268        .context("loading or generating CA")?;
269
270    match previous {
271        Some(previous) => {
272            tracing::warn!(
273                path = %pki_dir.display(),
274                previous_constraints = ?previous,
275                current_constraints = ?permitted_dns,
276                "caproni dev CA re-signed with its existing key: constraints changed"
277            );
278            tracing::info!(
279                "certificates it already issued stay valid; to trust the new names from your host, run: caproni ca trust"
280            );
281        }
282        None if ca.generated => {
283            tracing::info!(
284                path = %pki_dir.display(),
285                constraints = ?permitted_dns,
286                "caproni dev CA generated"
287            );
288            tracing::info!("to trust this CA in your browser, run: caproni ca trust");
289        }
290        None => {
291            tracing::debug!(path = %pki_dir.display(), "using existing CA");
292        }
293    }
294
295    Ok(())
296}
297
298/// Port of `pki.PermittedDNSFor`'s call site shape: computes the CA Name
299/// Constraint permitted DNS domains for a cluster config, bridging into
300/// `xianyun_pki::constraints`' narrow stand-in type. `pub(crate)` so
301/// `commands::ca`'s `permittedDNSFromConfig` port can reuse it.
302pub(crate) fn permitted_dns_for_cluster(
303    cluster: &ClusterConfig,
304    profile_name: &str,
305) -> Vec<String> {
306    xianyun_pki::constraints::permitted_dns_for(&to_pki_cluster_config(cluster), profile_name)
307}
308
309/// Port of `datadir.PKIDir`'s call site shape: resolves the profile's PKI
310/// directory under the real `$HOME`. `pub(crate)` so `commands::ca` can
311/// reuse it.
312pub(crate) fn pki_dir_for_profile(profile_name: &str) -> anyhow::Result<PathBuf> {
313    let home = home_dir().context("resolving home directory")?;
314    xianyun_executor::datadir::pki_dir_in(&home, profile_name).map_err(anyhow::Error::from)
315}
316
317/// Converts the shared `xianyun_core` cluster config into the narrow
318/// stand-in type `xianyun_pki::constraints::permitted_dns_for` expects
319/// (see that crate's `PORT_NOTES.md`: it was ported before the real
320/// config crate existed).
321fn to_pki_cluster_config(cluster: &ClusterConfig) -> xianyun_pki::constraints::ClusterConfig {
322    xianyun_pki::constraints::ClusterConfig {
323        network: cluster
324            .network
325            .as_ref()
326            .map(|network| xianyun_pki::constraints::NetworkConfig {
327                hostname: network.hostname.clone(),
328                cluster_domain: network.cluster_domain.clone(),
329            }),
330    }
331}
332
333/// Returns the current user's home directory, mirroring `os.UserHomeDir()`.
334fn home_dir() -> std::io::Result<PathBuf> {
335    std::env::var_os("HOME")
336        .map(PathBuf::from)
337        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is not set"))
338}
339
340/// Port of `up.runPrepareHooks`: runs `edit_mode_prepare` lifecycle hooks
341/// for every repository that has an active edit-mode reloader.
342fn run_prepare_hooks(container: &Container) -> anyhow::Result<()> {
343    let Some(repositories) = &container.caproni.repositories else {
344        return Ok(());
345    };
346
347    let executor: Arc<dyn CommandRunner + Send + Sync> =
348        Arc::new(xianyun_executor::exec::Client::new());
349
350    for (name, repo_config) in repositories.iter() {
351        if !should_run_prepare_hook(container, name) {
352            continue;
353        }
354
355        let hooks = xianyun_update::lifecyclehooks::resolve_repository_hooks(
356            &container.caproni,
357            name,
358            xianyun_update::lifecyclehooks::HookType::EditModePrepare,
359        );
360
361        if hooks.is_empty() {
362            continue;
363        }
364
365        xianyun_update::lifecyclehooks::run_hooks(
366            executor.as_ref(),
367            &container.caproni,
368            container.resolver.as_ref(),
369            repo_config,
370            name,
371            &hooks,
372            None,
373        )
374        .with_context(|| format!("running prepare hooks for {name}"))?;
375    }
376
377    Ok(())
378}
379
380/// Port of `up.shouldRunPrepareHook`.
381#[must_use]
382pub fn should_run_prepare_hook(container: &Container, repo_name: &str) -> bool {
383    let Some(repositories) = &container.caproni.repositories else {
384        return false;
385    };
386
387    let Some(repo) = repositories.get(&repo_name.to_string()) else {
388        return false;
389    };
390
391    if repo.edit_mode {
392        return true;
393    }
394
395    let Some(reloaders) = &container.caproni.reloaders else {
396        return false;
397    };
398
399    reloaders
400        .values()
401        .iter()
402        .any(|reloader| reloader.repository == repo_name && reloader.edit_mode)
403}
404
405/// Port of `up.cloneRepos`: ensures git repositories are cloned for
406/// edit-mode deployers/reloaders. Prepare hooks are skipped here (matching
407/// Go's `SkipPrepareHooks: true`); they run after the config reload in
408/// [`run_start`].
409fn clone_repos(container: &Container) -> anyhow::Result<()> {
410    let executor: Arc<dyn CommandRunner + Send + Sync> =
411        Arc::new(xianyun_executor::exec::Client::new());
412    let git_backend = Arc::new(xianyun_backends::git::Client::new(executor.clone()));
413
414    let updater = xianyun_update::updater::Updater::new(
415        git_backend,
416        xianyun_update::updater::UpdateOptions {
417            strategy: xianyun_update::updater::UpdateStrategy::MinimalRequired,
418            skip_prepare_hooks: true,
419            ..Default::default()
420        },
421    );
422
423    let (results, success) = updater.update_repositories(executor.as_ref(), container);
424
425    for result in &results {
426        if result.status == xianyun_update::updater::UpdateStatus::Updated
427            && result.notes == "Cloned"
428            && let Some(repo_config) = container
429                .caproni
430                .repositories
431                .as_ref()
432                .and_then(|repos| repos.get(&result.repo_name))
433        {
434            let repo_dir = container.caproni.resolve_path(&repo_config.directory);
435            tracing::info!(name = %result.repo_name, directory = %repo_dir, "cloned repository");
436        }
437    }
438
439    if !success
440        && let Some(failed) = results
441            .iter()
442            .find(|r| r.status == xianyun_update::updater::UpdateStatus::Failed)
443    {
444        anyhow::bail!(
445            "cloning repository {}: {}",
446            failed.repo_name,
447            failed
448                .error
449                .as_ref()
450                .map_or_else(|| failed.notes.clone(), std::string::ToString::to_string)
451        );
452    }
453
454    Ok(())
455}