1use std::io::Write as _;
26use std::net::TcpStream;
27use std::sync::Arc;
28
29use super::{Driver as DriverTrait, DriverError, State, Status};
30use xianyun_core::config::types::ClusterConfig;
31use xianyun_executor::exec::CommandRunner;
32
33pub const DRIVER_NAME: &str = "k3d";
34
35pub struct Driver {
37 pub cfg: ClusterConfig,
38 pub profile_name: String,
39 pub port_in_use: Box<dyn Fn(&str) -> bool>,
44 runner: Arc<dyn CommandRunner + Send + Sync>,
45}
46
47impl Driver {
48 #[must_use]
51 pub fn new(cfg: ClusterConfig, profile_name: String) -> Self {
52 Self::with_runner(
53 cfg,
54 profile_name,
55 Arc::new(xianyun_executor::exec::Client::new()),
56 )
57 }
58
59 #[must_use]
63 pub fn with_runner(
64 cfg: ClusterConfig,
65 profile_name: String,
66 runner: Arc<dyn CommandRunner + Send + Sync>,
67 ) -> Self {
68 Self {
69 cfg,
70 profile_name,
71 port_in_use: Box::new(probe_address),
72 runner,
73 }
74 }
75}
76
77impl DriverTrait for Driver {
78 fn name(&self) -> &str {
79 DRIVER_NAME
80 }
81
82 fn start(&self) -> Result<(), DriverError> {
83 let cfg = self.cfg.clone();
84
85 if !is_k3d_available(self) {
86 return Err(DriverError::DriverNotFound("k3d".to_string()));
87 }
88
89 let Some(k3d_cfg) = cfg.k3d.clone() else {
90 return Err(DriverError::MissingDriverConfig);
91 };
92
93 let status = status_impl(self).ok();
94
95 if let Some(status) = &status
96 && status.state == Some(State::Ready)
97 {
98 return verify_config(status, &cfg);
99 }
100
101 if let Some(status) = &status
102 && status.exists
103 && matches!(status.state, Some(State::Stopped) | Some(State::NotReady))
104 {
105 tracing::info!(
106 profile = %self.profile_name,
107 state = ?status.state,
108 "starting existing k3d cluster"
109 );
110
111 let args = vec![
112 "cluster".to_string(),
113 "start".to_string(),
114 self.profile_name.clone(),
115 ];
116
117 return self
118 .runner
119 .exec_streamed_command(None, "k3d", &args)
120 .map_err(|e| DriverError::Other(format!("starting k3d: {e}")));
121 }
122
123 let port_mappings = merge_port_mappings(&k3d_cfg.port_mappings);
124
125 check_port_conflicts(self, &port_mappings)?;
126
127 let args = build_cluster_create_args(self, &cfg, &port_mappings)
128 .map_err(|e| DriverError::Other(format!("building cluster create args: {e}")))?;
129
130 warn_deprecated_disable_args(&k3d_cfg.extra_args);
131
132 tracing::info!(
133 profile = %self.profile_name,
134 agents = k3d_cfg.agents,
135 servers = k3d_cfg.servers,
136 port_mappings = k3d_cfg.port_mappings.len(),
137 extra_args = k3d_cfg.extra_args.len(),
138 "starting k3d cluster"
139 );
140
141 self.runner
142 .exec_streamed_command(None, "k3d", &args)
143 .map_err(|e| DriverError::Other(format!("starting k3d: {e}")))
144 }
145
146 fn stop(&self) -> Result<(), DriverError> {
147 let status = status_impl(self)?;
148
149 if status.state == Some(State::Stopped) {
150 return Ok(());
151 }
152
153 tracing::info!(profile = %self.profile_name, "stopping k3d cluster");
154
155 let args = vec![
156 "cluster".to_string(),
157 "stop".to_string(),
158 self.profile_name.clone(),
159 ];
160
161 self.runner
162 .exec_streamed_command(None, "k3d", &args)
163 .map_err(|e| DriverError::Other(format!("stopping k3d: {e}")))
164 }
165
166 fn destroy(&self) -> Result<(), DriverError> {
167 if !is_k3d_available(self) {
168 return Err(DriverError::DriverNotFound("k3d".to_string()));
169 }
170
171 let status = status_impl(self)?;
172
173 if status.state == Some(State::Stopped) && !status.exists {
174 return Ok(());
175 }
176
177 tracing::info!(profile = %self.profile_name, "destroying k3d cluster");
178
179 let (opts, _guard) = disposable_kubeconfig_opts()
180 .map_err(|e| DriverError::Other(format!("creating disposable kubeconfig: {e}")))?;
181
182 let args = vec![
183 "cluster".to_string(),
184 "delete".to_string(),
185 self.profile_name.clone(),
186 ];
187
188 self.runner
189 .exec_streamed_command(Some(opts), "k3d", &args)
190 .map_err(|e| DriverError::Other(format!("destroying k3d: {e}")))
191 }
192
193 fn status(&self) -> Result<Status, DriverError> {
194 status_impl(self)
195 }
196
197 fn get_kubeconfig(&self) -> Result<String, DriverError> {
198 if !is_k3d_available(self) {
199 return Err(DriverError::DriverNotFound("k3d".to_string()));
200 }
201
202 let status = status_impl(self)?;
203
204 if status.state != Some(State::Ready) {
205 return Err(DriverError::ClusterNotRunning);
206 }
207
208 let args = vec![
209 "kubeconfig".to_string(),
210 "get".to_string(),
211 self.profile_name.clone(),
212 ];
213
214 self.runner
215 .read_stdout("k3d", &args)
216 .map_err(|e| DriverError::Other(format!("reading kubeconfig: {e}")))
217 }
218}
219
220struct TempKubeconfigGuard(std::path::PathBuf);
223
224impl Drop for TempKubeconfigGuard {
225 fn drop(&mut self) {
226 let _ = std::fs::remove_file(&self.0);
227 }
228}
229
230fn disposable_kubeconfig_opts() -> std::io::Result<(
236 xianyun_executor::exec::ExecStreamedOpts,
237 TempKubeconfigGuard,
238)> {
239 let pid = std::process::id();
240 let nanos = std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .map(|d| d.as_nanos())
243 .unwrap_or_default();
244 let path = std::env::temp_dir().join(format!("caproni-kubeconfig-{pid}-{nanos}"));
245
246 let mut file = std::fs::File::create(&path)?;
247 file.write_all(b"")?;
248
249 let mut env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
250 env.push(format!("KUBECONFIG={}", path.display()));
251
252 Ok((
253 xianyun_executor::exec::ExecStreamedOpts {
254 use_scrolling_area: true,
255 env: Some(env),
256 ..Default::default()
257 },
258 TempKubeconfigGuard(path),
259 ))
260}
261
262fn status_impl(driver: &Driver) -> Result<Status, DriverError> {
263 if !is_k3d_available(driver) {
264 return Err(DriverError::DriverNotFound("k3d".to_string()));
265 }
266
267 let mut status = Status {
268 driver: DRIVER_NAME.to_string(),
269 state: Some(State::Stopped),
270 kube_context: format!("k3d-{}", driver.profile_name),
271 ..Default::default()
272 };
273
274 let args = vec![
275 "cluster".to_string(),
276 "list".to_string(),
277 driver.profile_name.clone(),
278 "-o".to_string(),
279 "json".to_string(),
280 ];
281
282 let stdout = match driver.runner.read_stdout("k3d", &args) {
283 Ok(out) => out,
284 Err(_) => return Ok(status),
285 };
286
287 let clusters: Vec<K3dCluster> = serde_json::from_str(&stdout)
288 .map_err(|e| DriverError::Other(format!("parsing k3d cluster list JSON: {e}")))?;
289
290 let Some(target) = clusters.iter().find(|c| c.name == driver.profile_name) else {
291 return Ok(status);
292 };
293
294 status.exists = true;
295 status.kubernetes_version = node_version(&target.nodes);
296 status.ip_address = node_ip(&target.nodes);
297 status.host_ip = host_ip(std::env::consts::OS, &status.ip_address);
298
299 status.state = if target.servers_running == 0 {
300 Some(State::Stopped)
301 } else if target.servers_running == target.servers_count {
302 Some(State::Ready)
303 } else {
304 Some(State::NotReady)
305 };
306
307 Ok(status)
308}
309
310#[must_use]
312pub fn host_ip(goos: &str, ip: &str) -> String {
313 if goos == "darwin" {
314 "127.0.0.1".to_string()
315 } else {
316 ip.to_string()
317 }
318}
319
320pub fn is_k3d_available(driver: &Driver) -> bool {
322 driver.runner.look_path("k3d").is_ok()
323}
324
325pub fn verify_config(status: &Status, _cfg: &ClusterConfig) -> Result<(), DriverError> {
327 if status.driver != DRIVER_NAME {
328 return Err(DriverError::ConfigMismatch);
329 }
330
331 Ok(())
332}
333
334#[must_use]
336pub fn node_version(nodes: &[K3dNode]) -> String {
337 for node in nodes {
338 if node.role == "server" {
339 return node
340 .runtime_labels
341 .get("k3d.version")
342 .cloned()
343 .unwrap_or_default();
344 }
345 }
346
347 String::new()
348}
349
350#[must_use]
352pub fn node_ip(nodes: &[K3dNode]) -> String {
353 let mut server_ip = String::new();
354
355 for node in nodes {
356 if node.role == "loadbalancer" && !node.ip.ip.is_empty() {
357 return node.ip.ip.clone();
358 }
359 if node.role == "server" && !node.ip.ip.is_empty() && server_ip.is_empty() {
360 server_ip = node.ip.ip.clone();
361 }
362 }
363
364 server_ip
365}
366
367fn is_deprecated_disable_arg(arg: &str) -> bool {
371 if let Some(rest) = arg.strip_prefix("--disable") {
372 return rest.starts_with('=') || rest.starts_with(' ');
373 }
374 if let Some(idx) = arg.find("=--disable") {
375 let rest = &arg[idx + "=--disable".len()..];
376 return rest.starts_with('=') || rest.starts_with(' ');
377 }
378 false
379}
380
381pub fn warn_deprecated_disable_args(extra_args: &[String]) {
383 for arg in extra_args {
384 if !is_deprecated_disable_arg(arg) {
385 continue;
386 }
387
388 tracing::warn!(
389 extra_arg = %arg,
390 "disabling k3s components through extra_args is deprecated and is invisible to \
391 caproni; declare it under cluster.k3d.k3s.disable instead"
392 );
393 }
394}
395
396pub fn build_cluster_create_args(
398 driver: &Driver,
399 cfg: &ClusterConfig,
400 port_mappings: &[PortMapping],
401) -> Result<Vec<String>, DriverError> {
402 let k3d_cfg = cfg.k3d.as_ref().ok_or(DriverError::MissingDriverConfig)?;
403
404 let mut args = vec![
405 "cluster".to_string(),
406 "create".to_string(),
407 driver.profile_name.clone(),
408 "--k3s-arg".to_string(),
409 "--kubelet-arg=feature-gates=KubeletInUserNamespace=true@server:*".to_string(),
410 "--kubeconfig-update-default=false".to_string(),
411 ];
412
413 if k3d_cfg.agents > 0 {
414 args.push("--agents".to_string());
415 args.push(k3d_cfg.agents.to_string());
416 }
417
418 if k3d_cfg.servers > 0 && k3d_cfg.servers != 1 {
419 args.push("--servers".to_string());
420 args.push(k3d_cfg.servers.to_string());
421 }
422
423 if !k3d_cfg.image.is_empty() {
424 args.push("--image".to_string());
425 args.push(k3d_cfg.image.clone());
426 }
427
428 for mapping in port_mappings {
429 args.push("-p".to_string());
430 args.push(mapping.as_spec().to_string());
431 }
432
433 if let Some(mirror) = &cfg.registry_mirror {
434 let path = write_registries_yaml(&driver.profile_name, mirror)
435 .map_err(|e| DriverError::Other(format!("writing k3d registries.yaml: {e}")))?;
436 args.push("--registry-config".to_string());
437 args.push(path);
438 }
439
440 if let Some(network) = &cfg.network
441 && !network.cluster_domain.is_empty()
442 {
443 args.push("--k3s-arg".to_string());
444 args.push(format!(
445 "--cluster-domain={}@server:*",
446 network.cluster_domain
447 ));
448 }
449
450 if let Some(k3s) = &k3d_cfg.k3s {
451 for component in &k3s.disable {
452 args.push("--k3s-arg".to_string());
453 args.push(format!("--disable={component}@server:*"));
454 }
455 }
456
457 args.extend(k3d_cfg.extra_args.iter().cloned());
458
459 Ok(args)
460}
461
462fn write_registries_yaml(
468 profile: &str,
469 mirror: &xianyun_core::config::types::RegistryMirrorConfig,
470) -> std::io::Result<String> {
471 let home = std::env::var("HOME")
472 .map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e.to_string()))?;
473
474 let dir = std::path::PathBuf::from(home)
475 .join(".local/share/caproni")
476 .join(profile);
477 std::fs::create_dir_all(&dir)?;
478
479 let path = dir.join("k3d-registries.yaml");
480
481 let endpoint = mirror.host_port();
482 let endpoint_url = format!("http://{endpoint}");
483
484 let mut mirrors = serde_yaml::Mapping::new();
485 for upstream in &mirror.upstreams {
486 let mut entry = serde_yaml::Mapping::new();
487 entry.insert(
488 serde_yaml::Value::String("endpoint".to_string()),
489 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(endpoint_url.clone())]),
490 );
491 mirrors.insert(
492 serde_yaml::Value::String(upstream.clone()),
493 serde_yaml::Value::Mapping(entry),
494 );
495 }
496
497 let mut tls = serde_yaml::Mapping::new();
498 tls.insert(
499 serde_yaml::Value::String("insecureSkipVerify".to_string()),
500 serde_yaml::Value::Bool(true),
501 );
502 let mut config_entry = serde_yaml::Mapping::new();
503 config_entry.insert(
504 serde_yaml::Value::String("tls".to_string()),
505 serde_yaml::Value::Mapping(tls),
506 );
507 let mut configs = serde_yaml::Mapping::new();
508 configs.insert(
509 serde_yaml::Value::String(endpoint),
510 serde_yaml::Value::Mapping(config_entry),
511 );
512
513 let mut root = serde_yaml::Mapping::new();
514 root.insert(
515 serde_yaml::Value::String("mirrors".to_string()),
516 serde_yaml::Value::Mapping(mirrors),
517 );
518 root.insert(
519 serde_yaml::Value::String("configs".to_string()),
520 serde_yaml::Value::Mapping(configs),
521 );
522
523 let body = serde_yaml::to_string(&serde_yaml::Value::Mapping(root))
524 .map_err(|e| std::io::Error::other(e.to_string()))?;
525
526 std::fs::write(&path, body)?;
527
528 Ok(path.to_string_lossy().into_owned())
529}
530
531#[derive(Debug, Clone, Default, serde::Deserialize)]
533#[serde(rename_all = "camelCase", default)]
534pub struct K3dCluster {
535 pub name: String,
536 pub servers_count: i32,
537 pub servers_running: i32,
538 pub agents_count: i32,
539 pub agents_running: i32,
540 pub nodes: Vec<K3dNode>,
541}
542
543#[derive(Debug, Clone, Default, serde::Deserialize)]
545#[serde(default)]
546pub struct K3dNode {
547 pub name: String,
548 pub role: String,
549 #[serde(rename = "IP")]
550 pub ip: K3dNodeIp,
551 #[serde(rename = "State")]
552 pub state: K3dNodeState,
553 #[serde(rename = "runtimeLabels")]
554 pub runtime_labels: std::collections::HashMap<String, String>,
555}
556
557#[derive(Debug, Clone, Default, serde::Deserialize)]
559pub struct K3dNodeIp {
560 #[serde(rename = "IP", default)]
561 pub ip: String,
562 #[serde(rename = "Static", default)]
563 pub static_: bool,
564}
565
566#[derive(Debug, Clone, Default, serde::Deserialize)]
568pub struct K3dNodeState {
569 #[serde(rename = "Running", default)]
570 pub running: bool,
571 #[serde(rename = "Status", default)]
572 pub status: String,
573}
574
575#[derive(Debug, Clone, Default, PartialEq, Eq)]
578pub struct PortMapping {
579 pub raw: String,
580 pub host_ip: String,
581 pub host_port: String,
582 pub container_port: String,
583 pub tcp: bool,
584}
585
586impl PortMapping {
587 #[must_use]
589 pub fn as_spec(&self) -> &str {
590 &self.raw
591 }
592
593 #[must_use]
595 pub fn host_address(&self) -> Option<String> {
596 if self.host_port.is_empty() || !self.tcp {
597 return None;
598 }
599
600 let host = if self.host_ip.is_empty() || self.host_ip == "0.0.0.0" {
601 "127.0.0.1"
602 } else {
603 &self.host_ip
604 };
605
606 Some(join_host_port(host, &self.host_port))
607 }
608
609 #[must_use]
611 pub fn tcp_container_port(&self) -> Option<&str> {
612 if !self.tcp {
613 return None;
614 }
615
616 Some(&self.container_port)
617 }
618}
619
620fn join_host_port(host: &str, port: &str) -> String {
625 format!("{host}:{port}")
626}
627
628#[must_use]
630pub fn parse_port_mappings(mappings: &[String]) -> Vec<PortMapping> {
631 mappings.iter().map(|m| parse_port_mapping(m)).collect()
632}
633
634#[must_use]
636pub fn parse_port_mapping(mapping: &str) -> PortMapping {
637 let parsed = PortMapping {
638 raw: mapping.to_string(),
639 ..Default::default()
640 };
641
642 let spec = mapping.split('@').next().unwrap_or(mapping);
643 let (spec, protocol, has_protocol) = match spec.split_once('/') {
644 Some((spec, protocol)) => (spec, protocol, true),
645 None => (spec, "", false),
646 };
647
648 let fields: Vec<&str> = spec.split(':').collect();
649
650 let (host_ip, host_port, container_port) = match fields.as_slice() {
651 [container_port] => (String::new(), String::new(), (*container_port).to_string()),
652 [host_port, container_port] => (
653 String::new(),
654 (*host_port).to_string(),
655 (*container_port).to_string(),
656 ),
657 [host_ip, host_port, container_port] => (
658 (*host_ip).to_string(),
659 (*host_port).to_string(),
660 (*container_port).to_string(),
661 ),
662 _ => return parsed,
663 };
664
665 if !is_port_number(&container_port) {
666 return parsed;
667 }
668
669 if !host_port.is_empty() && !is_port_number(&host_port) {
670 return parsed;
671 }
672
673 PortMapping {
674 raw: mapping.to_string(),
675 host_ip,
676 host_port,
677 container_port,
678 tcp: !has_protocol || protocol == "tcp",
679 }
680}
681
682fn is_port_number(s: &str) -> bool {
683 match s.parse::<u32>() {
684 Ok(port) => port > 0 && port <= u32::from(u16::MAX),
685 Err(_) => false,
686 }
687}
688
689#[must_use]
691pub fn merge_port_mappings(user_mappings: &[String]) -> Vec<PortMapping> {
692 let defaults = parse_port_mappings(&[
693 "80:80@loadbalancer".to_string(),
694 "443:443@loadbalancer".to_string(),
695 ]);
696 let user = parse_port_mappings(user_mappings);
697
698 let overridden: std::collections::HashSet<String> = user
699 .iter()
700 .filter_map(|m| m.tcp_container_port().map(str::to_string))
701 .collect();
702
703 let mut result = Vec::with_capacity(defaults.len() + user.len());
704
705 for def in defaults {
706 let is_overridden = def
707 .tcp_container_port()
708 .is_some_and(|p| overridden.contains(p));
709 if !is_overridden {
710 result.push(def);
711 }
712 }
713
714 result.extend(user);
715 result
716}
717
718pub fn check_port_conflicts(driver: &Driver, mappings: &[PortMapping]) -> Result<(), DriverError> {
720 let mut conflicts = Vec::new();
721
722 for mapping in mappings {
723 let Some(address) = mapping.host_address() else {
724 continue;
725 };
726
727 if !(driver.port_in_use)(&address) {
728 continue;
729 }
730
731 tracing::debug!(mapping = mapping.as_spec(), address = %address, "host port already has a listener");
732
733 conflicts.push(address);
734 }
735
736 if conflicts.is_empty() {
737 return Ok(());
738 }
739
740 Err(DriverError::PortInUse(format!(
741 "{}; stop whatever is listening there, or publish the cluster on other host ports with \
742 cluster.k3d.port_mappings",
743 conflicts.join(", ")
744 )))
745}
746
747#[must_use]
751pub fn probe_address(address: &str) -> bool {
752 TcpStream::connect(address).is_ok()
753}