pub mod addon;
pub mod deploy;
pub mod log_collector;
pub mod registry;
pub mod watcher;
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::debug;
use crate::config::model::{ClusterConfig, ClusterRegistryAuth};
pub struct K3dManager {
cluster_name: String,
slug: String,
kubeconfig_path: PathBuf,
network_name: String,
config_dir: PathBuf,
config: ClusterConfig,
}
impl K3dManager {
pub fn new(
slug: &str,
config: &ClusterConfig,
state_dir: &Path,
network_name: &str,
config_dir: &Path,
) -> Self {
let cluster_name = format!("devrig-{}", slug);
let kubeconfig_path = state_dir.join("kubeconfig");
Self {
cluster_name,
slug: slug.to_string(),
kubeconfig_path,
network_name: network_name.to_string(),
config_dir: config_dir.to_path_buf(),
config: config.clone(),
}
}
pub async fn create_cluster(&self) -> Result<()> {
if self.cluster_exists().await? {
debug!(cluster = %self.cluster_name, "cluster already exists, skipping create");
return Ok(());
}
let mut args = vec![
"cluster".to_string(),
"create".to_string(),
self.cluster_name.clone(),
"--network".to_string(),
self.network_name.clone(),
"--agents".to_string(),
self.config.agents.to_string(),
"--kubeconfig-update-default=false".to_string(),
"--kubeconfig-switch-context=false".to_string(),
"--api-port".to_string(),
"127.0.0.1:0".to_string(),
];
for entry in &self.config.ports {
args.push("-p".to_string());
args.push(entry.clone());
}
for entry in &self.config.volumes {
args.push("--volume".to_string());
args.push(self.resolve_volume_path(entry));
}
for entry in &self.config.k3s_args {
args.push("--k3s-arg".to_string());
args.push(format!("{}@server:*", entry));
}
if self.config.registry {
args.push("--registry-create".to_string());
args.push(format!("k3d-{}-reg:0.0.0.0:0", self.cluster_name));
}
if !self.config.registries.is_empty() {
let registries_yaml = generate_registries_yaml(&self.config.registries);
let registries_path = self.kubeconfig_path.parent()
.unwrap_or_else(|| Path::new("."))
.join("registries.yaml");
std::fs::write(®istries_path, registries_yaml.as_bytes())
.context("writing registries.yaml")?;
args.push("--registry-config".to_string());
args.push(registries_path.to_string_lossy().to_string());
debug!(path = %registries_path.display(), "generated registries.yaml for external registries");
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
self.run_k3d(&arg_refs).await?;
debug!(cluster = %self.cluster_name, "cluster created");
Ok(())
}
pub async fn delete_cluster(&self) -> Result<()> {
self.run_k3d(&["cluster", "delete", &self.cluster_name])
.await?;
debug!(cluster = %self.cluster_name, "cluster deleted");
if self.kubeconfig_path.exists() {
tokio::fs::remove_file(&self.kubeconfig_path)
.await
.context("removing kubeconfig file")?;
}
Ok(())
}
pub async fn cluster_exists(&self) -> Result<bool> {
let output = self.run_k3d(&["cluster", "list", "-o", "json"]).await?;
let clusters: Vec<serde_json::Value> =
serde_json::from_str(&output).context("parsing k3d cluster list JSON")?;
let exists = clusters
.iter()
.any(|c| c.get("name").and_then(|n| n.as_str()) == Some(&self.cluster_name));
Ok(exists)
}
pub async fn write_kubeconfig(&self) -> Result<()> {
let kubeconfig = self
.run_k3d(&["kubeconfig", "get", &self.cluster_name])
.await?;
tokio::fs::write(&self.kubeconfig_path, kubeconfig.as_bytes())
.await
.context("writing kubeconfig file")?;
self.fix_kubeconfig_port().await?;
debug!(path = %self.kubeconfig_path.display(), "kubeconfig written");
Ok(())
}
async fn fix_kubeconfig_port(&self) -> Result<()> {
let content = tokio::fs::read_to_string(&self.kubeconfig_path)
.await
.context("reading kubeconfig for port fix")?;
let needs_fix = content.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with("server:") && trimmed.ends_with(":0")
});
if !needs_fix {
return Ok(());
}
debug!("kubeconfig contains unresolved port 0, discovering actual API server port");
let container = format!("k3d-{}-serverlb", self.cluster_name);
let output = Command::new("docker")
.args([
"inspect",
&container,
"--format",
"{{(index .NetworkSettings.Ports \"6443/tcp\" 0).HostPort}}",
])
.output()
.await
.context("inspecting serverlb container for API port")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"failed to discover API server port from '{}': {}",
container,
stderr.trim()
);
}
let actual_port = String::from_utf8_lossy(&output.stdout).trim().to_string();
if actual_port.is_empty() || actual_port == "0" {
bail!(
"API server port could not be resolved (got '{}')",
actual_port
);
}
let fixed = content
.replace(
"https://127.0.0.1:0",
&format!("https://127.0.0.1:{}", actual_port),
)
.replace(
"https://0.0.0.0:0",
&format!("https://127.0.0.1:{}", actual_port),
);
tokio::fs::write(&self.kubeconfig_path, fixed.as_bytes())
.await
.context("writing fixed kubeconfig")?;
debug!(port = %actual_port, "fixed kubeconfig API server port");
Ok(())
}
pub async fn kubectl(&self, args: &[&str]) -> Result<String> {
let output = Command::new("kubectl")
.args(args)
.env("KUBECONFIG", &self.kubeconfig_path)
.output()
.await
.context("running kubectl")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"kubectl {} failed: {}",
args.first().unwrap_or(&""),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
async fn run_k3d(&self, args: &[&str]) -> Result<String> {
let output = Command::new("k3d")
.args(args)
.output()
.await
.context("running k3d")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"k3d {} failed: {}",
args.first().unwrap_or(&""),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn cluster_name(&self) -> &str {
&self.cluster_name
}
pub fn kubeconfig_path(&self) -> &Path {
&self.kubeconfig_path
}
fn resolve_volume_path(&self, spec: &str) -> String {
if let Some((host_path, rest)) = spec.split_once(':') {
let path = Path::new(host_path);
if path.is_relative() {
let resolved = self.config_dir.join(path);
let absolute = resolved
.canonicalize()
.unwrap_or(resolved);
return format!("{}:{}", absolute.display(), rest);
}
}
spec.to_string()
}
pub fn network_name(&self) -> &str {
&self.network_name
}
pub fn slug(&self) -> &str {
&self.slug
}
}
fn generate_registries_yaml(registries: &[ClusterRegistryAuth]) -> String {
let mut yaml = String::new();
yaml.push_str("mirrors:\n");
for reg in registries {
yaml.push_str(&format!(" \"{}\":\n", reg.url));
yaml.push_str(" endpoint:\n");
yaml.push_str(&format!(" - \"https://{}\"\n", reg.url));
}
yaml.push_str("configs:\n");
for reg in registries {
yaml.push_str(&format!(" \"{}\":\n", reg.url));
yaml.push_str(" auth:\n");
yaml.push_str(&format!(" username: \"{}\"\n", reg.username));
yaml.push_str(&format!(" password: \"{}\"\n", reg.password));
}
yaml
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn registries_yaml_single_registry() {
let registries = vec![ClusterRegistryAuth {
url: "ghcr.io".to_string(),
username: "user".to_string(),
password: "token".to_string(),
}];
let yaml = generate_registries_yaml(®istries);
assert!(yaml.contains("ghcr.io"));
assert!(yaml.contains("username: \"user\""));
assert!(yaml.contains("password: \"token\""));
assert!(yaml.contains("https://ghcr.io"));
}
#[test]
fn registries_yaml_multiple_registries() {
let registries = vec![
ClusterRegistryAuth {
url: "ghcr.io".to_string(),
username: "user1".to_string(),
password: "pass1".to_string(),
},
ClusterRegistryAuth {
url: "docker.io".to_string(),
username: "user2".to_string(),
password: "pass2".to_string(),
},
];
let yaml = generate_registries_yaml(®istries);
assert!(yaml.contains("ghcr.io"));
assert!(yaml.contains("docker.io"));
assert!(yaml.contains("username: \"user1\""));
assert!(yaml.contains("username: \"user2\""));
}
#[test]
fn registries_yaml_empty() {
let yaml = generate_registries_yaml(&[]);
assert_eq!(yaml, "mirrors:\nconfigs:\n");
}
fn make_k3d_mgr(config_dir: &Path) -> K3dManager {
K3dManager::new(
"test-abc123",
&ClusterConfig {
name: None,
agents: 1,
ports: vec![],
volumes: vec![],
registry: false,
images: BTreeMap::new(),
deploy: BTreeMap::new(),
addons: BTreeMap::new(),
logs: None,
registries: vec![],
k3s_args: vec![],
},
&config_dir.join(".devrig"),
"test-net",
config_dir,
)
}
#[test]
fn resolve_volume_absolute_path_unchanged() {
let mgr = make_k3d_mgr(Path::new("/home/user/project"));
assert_eq!(
mgr.resolve_volume_path("/data:/workspace@server:*"),
"/data:/workspace@server:*"
);
}
#[test]
fn resolve_volume_relative_path_joined_with_config_dir() {
let mgr = make_k3d_mgr(Path::new("/home/user/project"));
let resolved = mgr.resolve_volume_path("../:/workspace@server:*");
assert!(!resolved.starts_with("../"), "expected absolute path, got: {resolved}");
assert!(resolved.ends_with(":/workspace@server:*"));
}
#[test]
fn resolve_volume_no_colon_unchanged() {
let mgr = make_k3d_mgr(Path::new("/home/user/project"));
assert_eq!(mgr.resolve_volume_path("just-a-name"), "just-a-name");
}
}