1use 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
27const CLUSTER_API_READY_TIMEOUT: Duration = Duration::from_secs(180);
30const CLUSTER_API_READY_INTERVAL: Duration = Duration::from_secs(2);
32
33pub type KubectlFactory = dyn Fn(&dyn Driver) -> anyhow::Result<Box<dyn kubectl::Backend>>;
37
38fn 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
50pub(crate) struct KubectlDnsReader {
60 backend: Box<dyn kubectl::Backend>,
61}
62
63pub(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
104pub 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 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 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 let _ = &mut container;
195
196 tracing::info!("caproni cluster is ready");
197
198 Ok(())
199}
200
201#[allow(clippy::unnecessary_wraps)] pub 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#[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
260pub 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
298pub(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
309pub(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
317fn 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
333fn 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
340fn 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#[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
405fn 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}