From dfbd2a0e70f323ec145f5f778f907e274c7e994e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 4 Aug 2026 20:55:03 -0500 Subject: [PATCH] feat(local): add kiac backend for Apple silicon clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire hops local to saiyam1814/kiac (apple/container VMs): create/resume single-node kubeadm cluster hops, containerd registry certs.d, and a localhost API proxy when Mac→node IP is unreachable. Document container 1.1.0 pin until kiac#14 (sysctl on 1.2.x). --- README.md | 53 ++- src/commands/local/backend/kiac.rs | 698 +++++++++++++++++++++++++++++ src/commands/local/backend/mod.rs | 48 +- src/commands/local/mod.rs | 2 +- 4 files changed, 787 insertions(+), 14 deletions(-) create mode 100644 src/commands/local/backend/kiac.rs diff --git a/README.md b/README.md index 9106163..dc8781c 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ This tool supports three related workflows: -- Local cluster setup on colima or kind +- Local cluster setup on colima, kind, dory, or kiac - Configuration package install/uninstall against the connected cluster - XR observe/manage/adopt/orphan workflows for existing infrastructure For local development, it can also: -- Install and manage a local cluster backend (colima or kind) +- Install and manage a local cluster backend (colima, kind, dory, or kiac) - Start a local k8s cluster with Crossplane installed via Helm - Install the Kubernetes and Helm Crossplane providers - Deploy an in-cluster OCI registry (`crossplane-system/registry`) @@ -56,7 +56,7 @@ See "Releases" for available versions and changenotes. - `up` (Upbound CLI, used by `up project build`) - `aws` CLI v2 (used by `local aws` to export profile credentials) -Note: `hops-cli local install` installs the selected backend (`colima` or `kind`) through Homebrew. +Note: `hops-cli local install` installs the selected backend (`colima`, `kind`, `dory`, or `kiac`) through Homebrew. ## Build @@ -213,7 +213,7 @@ hops config install --repo hops-ops/aws-auto-eks-cluster --version v0.11.0 ### Cluster backends -`hops local` supports three backends behind the same commands: +`hops local` supports four backends behind the same commands: - **colima** — a VM running dockerd + k3s. macOS/Linux; supports `--cpus`, `--memory`, `--disk`, and `hops local resize`. @@ -229,6 +229,14 @@ hops config install --repo hops-ops/aws-auto-eks-cluster --version v0.11.0 k3s **NodePort** on the engine docker bridge (`{dory-k8s-ip}:30500`) because dockerd runs *inside* the engine — Mac `localhost` is the wrong plane. The VM is sized in the Dory app, so hops sizing flags don't apply. +- **kiac** — [kiac](https://github.com/saiyam1814/kiac) on Apple silicon: + each node is a lightweight VM via [apple/container](https://github.com/apple/container) + (think kind, but VM-isolated nodes). hops creates a single-node **kubeadm** + cluster named `hops` (kube context `kiac-hops`). Package registry is the same + in-cluster `registry:2`; host docker push uses the control-plane node IP + NodePort (`{node-ip}:30500`) when that IP is host-reachable. If Mac→node IP + is blocked, hops proxies the apiserver on `127.0.0.1:16443`. `--cpus` / + `--memory` apply at create time only (recreate to resize). Select with the global `--backend` flag: @@ -243,8 +251,41 @@ persisted choice > existing cluster detection (colima wins) > platform default (macOS: colima, otherwise kind). Unless `--context` is given, kubectl commands automatically use the backend's -kubeconfig context (`colima`, `kind-hops`, or `hops-dory`), regardless of your -current-context. +kubeconfig context (`colima`, `kind-hops`, `hops-dory`, or `kiac-hops`), +regardless of your current-context. + +#### Using kiac + +Requires **macOS on Apple silicon**. Install via Homebrew (or +`hops local install --backend kiac`): + +```bash +brew tap saiyam1814/tap +brew install saiyam1814/tap/kiac +# apple/container — prefer **1.1.0** until kiac#14 is fixed +# (1.2.x often fails node boot: sysctl net.ipv4.ip_forward permission denied) +brew install container # or install the 1.1.0 pkg from apple/container releases +container system start # or: kiac doctor --fix + +hops local start --backend kiac +kubectl get nodes # context kiac-hops +``` + +Cluster name is always `hops`. Resume after laptop sleep/reboot: + +```bash +hops local start --backend kiac # calls `kiac resume cluster` when present +``` + +**Host networking:** if your Mac cannot reach the node IP (Local Network +permission, vmnet glitch), hops starts a `hops-kiac-api-proxy` container and +points kubeconfig at `https://127.0.0.1:16443`. Approve Local Network access +for your terminal when macOS prompts, or: +`container system stop && container system start && kiac resume cluster --name hops`. + +Path-install package push uses `{control-plane InternalIP}:30500` when +host-reachable. Crossplane still pulls +`registry.crossplane-system.svc.cluster.local:5000` inside the cluster. #### Using dory diff --git a/src/commands/local/backend/kiac.rs b/src/commands/local/backend/kiac.rs new file mode 100644 index 0000000..f302ca0 --- /dev/null +++ b/src/commands/local/backend/kiac.rs @@ -0,0 +1,698 @@ +//! kiac backend: Kubernetes on Apple's container runtime (apple/container). +//! +//! Each node is a lightweight VM (not a docker container). Cluster is managed +//! by the `kiac` CLI: https://github.com/saiyam1814/kiac +//! +//! Default cluster name is `hops` → kube context `kiac-hops`. +//! Uses the default **kubeadm** distro (kindest/node + kindnet) for a +//! single-node control plane. k3s is available via kiac itself but hops pins +//! kubeadm for registry wiring (containerd certs.d) and create reliability. +//! +//! ## Host reachability +//! +//! When apple/container's vmnet does not make node IPs host-reachable (common +//! when Local Network permission is missing or after some system restarts), +//! hops publishes the apiserver through a small `socat` proxy on +//! `127.0.0.1:16443` and rewrites the `kiac-hops` kubeconfig server. Inter- +//! container networking still works, so the proxy can reach the real node IP. +//! +//! ## apple/container version +//! +//! kiac 0.4.0 + container 1.2.0 fails at node boot with +//! `sysctl: permission denied on key "net.ipv4.ip_forward"` +//! (https://github.com/saiyam1814/kiac/issues/14). Prefer container **1.1.0** +//! until that is fixed. +//! +//! Package registry: +//! - Crossplane pulls via in-cluster Service DNS (same as other backends). +//! - Host docker push uses control-plane node IP:30500 when that IP is +//! host-reachable; otherwise path-installs may need published packages. + +use super::SizeArgs; +use crate::commands::local::package_install::{REGISTRY_PULL, REGISTRY_PUSH}; +use crate::commands::local::{command_exists, run_cmd, run_cmd_output}; +use std::error::Error; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +/// hops-owned cluster name (context becomes `kiac-hops`). +pub const CLUSTER_NAME: &str = "hops"; +const REGISTRY_NODE_PORT: &str = "30500"; +/// Host-side published port for apiserver when node IPs are not reachable. +const API_PROXY_HOST_PORT: u16 = 16443; +const API_PROXY_CONTAINER: &str = "hops-kiac-api-proxy"; +const API_PROXY_IMAGE: &str = "docker.io/alpine/socat:latest"; + +pub fn install() -> Result<(), Box> { + if !cfg!(target_os = "macos") { + return Err("the kiac backend requires macOS on Apple silicon".into()); + } + log::info!("Installing kiac via Homebrew..."); + // Tap is required the first time; ignore if already present. + let _ = run_cmd("brew", &["tap", "saiyam1814/tap"]); + run_cmd("brew", &["install", "saiyam1814/tap/kiac"])?; + if !command_exists("container") { + log::info!("Installing apple/container via Homebrew..."); + run_cmd("brew", &["install", "container"])?; + log::warn!( + "brew currently ships container 1.2.x, which breaks kiac node boot \ + (sysctl ip_forward — saiyam1814/kiac#14). Prefer apple/container 1.1.0 \ + until that is fixed." + ); + } + log::info!("kiac installed; run `kiac doctor --fix` if the container system is stopped"); + Ok(()) +} + +pub fn uninstall() -> Result<(), Box> { + log::info!("Uninstalling kiac..."); + run_cmd("brew", &["uninstall", "saiyam1814/tap/kiac"])?; + log::info!("kiac uninstalled (apple/container left in place)"); + Ok(()) +} + +pub fn start(size: &SizeArgs) -> Result<(), Box> { + preflight()?; + + if cluster_exists() { + log::info!( + "kiac cluster '{}' exists; resuming if needed...", + CLUSTER_NAME + ); + // Idempotent: no-op when VMs are already running. + // Ignore non-zero if kiac warns about host reachability after a healthy resume. + let _ = run_cmd( + "kiac", + &["resume", "cluster", "--name", CLUSTER_NAME, "--wait", "10m"], + ); + } else { + create_cluster(size)?; + } + + ensure_host_api_access()?; + Ok(()) +} + +pub fn stop() -> Result<(), Box> { + // Drop host proxy first so we don't leave a stale forwarder. + let _ = remove_api_proxy(); + if !cluster_exists() { + log::info!("kiac cluster '{}' not found", CLUSTER_NAME); + return Ok(()); + } + // kiac has no cluster-level stop; stop every node VM. + let nodes = node_names()?; + if nodes.is_empty() { + log::info!("no nodes listed for kiac cluster '{}'", CLUSTER_NAME); + return Ok(()); + } + for node in nodes { + log::info!("Stopping kiac node '{}'...", node); + let _ = run_cmd("kiac", &["stop", "node", &node, "--name", CLUSTER_NAME]); + } + log::info!("kiac cluster '{}' nodes stopped", CLUSTER_NAME); + Ok(()) +} + +pub fn destroy() -> Result<(), Box> { + let _ = remove_api_proxy(); + if !cluster_exists() { + log::info!("kiac cluster '{}' not found", CLUSTER_NAME); + return Ok(()); + } + log::info!("Deleting kiac cluster '{}'...", CLUSTER_NAME); + run_cmd("kiac", &["delete", "cluster", "--name", CLUSTER_NAME])?; + log::info!("kiac cluster deleted"); + Ok(()) +} + +pub fn reset() -> Result<(), Box> { + preflight()?; + if cluster_exists() { + destroy()?; + } + create_cluster(&SizeArgs::default())?; + ensure_host_api_access()?; + Ok(()) +} + +pub fn resize(size: &SizeArgs) -> Result<(), Box> { + Err(format!( + "kiac node size is set at create time{}; run `hops local destroy --backend kiac` \ + then `hops local start --backend kiac{}` to recreate", + size.command_suffix(), + size.command_suffix() + ) + .into()) +} + +pub fn cluster_exists() -> bool { + if !command_exists("kiac") { + return false; + } + run_cmd_output("kiac", &["get", "clusters", "-o", "json"]) + .ok() + .and_then(|out| parse_cluster_names_json(&out)) + .map(|names| names.iter().any(|n| n == CLUSTER_NAME)) + .or_else(|| { + run_cmd_output("kiac", &["get", "clusters"]).ok().map(|out| { + out.lines().any(|line| { + let t = line.trim(); + t == CLUSTER_NAME || t.split_whitespace().next() == Some(CLUSTER_NAME) + }) + }) + }) + .unwrap_or(false) +} + +/// containerd certs.d is written in [`wire_registry`]. +pub fn ensure_registry_trust() -> Result<(), Box> { + Ok(()) +} + +/// Point node containerd at the in-cluster registry Service over HTTPS +/// (skip_verify) via certs.d hosts.toml — same model as the kind backend. +pub fn wire_registry(cluster_ip: &str) -> Result<(), Box> { + // Pull name (in-cluster) + push name(s) so pod and host refs both resolve. + let push = registry_push_addr().unwrap_or_else(|_| REGISTRY_PUSH.to_string()); + for name in [REGISTRY_PULL, REGISTRY_PUSH, push.as_str()] { + write_hosts_toml(name, cluster_ip)?; + } + if let Ok(push) = registry_push_addr() { + let _ = ensure_host_docker_hint(&push); + } + Ok(()) +} + +/// Host docker push address: control-plane node IP + NodePort 30500. +pub fn registry_push_addr() -> Result> { + Ok(format!("{}:{}", control_plane_ip()?, REGISTRY_NODE_PORT)) +} + +fn preflight() -> Result<(), Box> { + if !cfg!(target_os = "macos") { + return Err("the kiac backend requires macOS on Apple silicon".into()); + } + if !command_exists("kiac") { + return Err( + "kiac is not installed; run `hops local install --backend kiac` or \ + `brew install saiyam1814/tap/kiac`" + .into(), + ); + } + if !command_exists("container") { + return Err( + "apple/container CLI is not installed; run `brew install container` then \ + `container system start` (or `kiac doctor --fix`)" + .into(), + ); + } + warn_if_broken_container_version(); + // Start container system if needed (kiac create also does this). + let _ = run_cmd("kiac", &["doctor", "--fix"]); + Ok(()) +} + +fn warn_if_broken_container_version() { + // `container system status` prints apiserver.version; fall back to installRoot path. + let status = run_cmd_output("container", &["system", "status"]).unwrap_or_default(); + if status.contains("1.2.") { + log::warn!( + "apple/container 1.2.x is active; kiac node boot often fails with \ + sysctl ip_forward permission denied (saiyam1814/kiac#14). \ + Pin apple/container 1.1.0 until fixed." + ); + } +} + +fn create_cluster(size: &SizeArgs) -> Result<(), Box> { + log::info!( + "Creating kiac cluster '{}' (kubeadm, single-node)...", + CLUSTER_NAME + ); + // Default distro is kubeadm (kindest/node). Do not pass --distro k3s: + // hops wires registry trust via containerd certs.d, and kubeadm create has + // been more reliable with current kiac releases. + let mut args = vec![ + "create".to_string(), + "cluster".to_string(), + "--name".to_string(), + CLUSTER_NAME.to_string(), + "--workers".to_string(), + "0".to_string(), + "--wait".to_string(), + "10m".to_string(), + ]; + if let Some(cpus) = size.cpus { + args.push("--cpus".into()); + args.push(cpus.to_string()); + } + if let Some(memory) = size.memory { + // Single-node: all addons on control plane → cp-memory. + args.push("--cp-memory".into()); + args.push(format!("{memory}G")); + } + if size.disk.is_some() { + log::warn!("kiac has no --disk flag; ignoring --disk"); + } + + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + // kiac exits non-zero when the cluster is Ready but Mac→node IP fails. + // Capture that: if the cluster exists afterward, continue to host API fixup. + match run_cmd("kiac", &arg_refs) { + Ok(()) => {} + Err(e) if cluster_exists() => { + log::warn!( + "kiac create reported an error but cluster '{}' exists ({e}); \ + continuing with host API access fixup", + CLUSTER_NAME + ); + } + Err(e) => return Err(e), + } + log::info!( + "kiac cluster '{}' ready (kube context: kiac-{})", + CLUSTER_NAME, + CLUSTER_NAME + ); + Ok(()) +} + +/// Ensure kubectl on the Mac can reach the apiserver. +/// +/// Prefer direct node InternalIP:6443. If that is not host-routable, run a +/// published-port socat proxy and point the `kiac-hops` kubeconfig at it. +fn ensure_host_api_access() -> Result<(), Box> { + let ip = control_plane_ip()?; + if tcp_reachable(&format!("{ip}:6443"), Duration::from_secs(2)) { + log::info!("kiac apiserver reachable at {ip}:6443"); + // Prefer direct endpoint if a previous run left a proxy in place. + point_kubeconfig_at_server(&format!("https://{ip}:6443"), None)?; + let _ = remove_api_proxy(); + return Ok(()); + } + + log::warn!( + "Mac cannot reach kiac control-plane at {ip}:6443; \ + starting localhost API proxy on 127.0.0.1:{API_PROXY_HOST_PORT} \ + (allow Local Network for your terminal if prompted, or run: \ + container system stop && container system start && kiac resume cluster --name {CLUSTER_NAME})" + ); + ensure_api_proxy(&ip)?; + let proxy = format!("https://127.0.0.1:{API_PROXY_HOST_PORT}"); + point_kubeconfig_at_server(&proxy, Some("kubernetes"))?; + + // Prove kubectl path works. + for _ in 0..30 { + if run_cmd_output("kubectl", &["--context", &context_name(), "get", "--raw", "/readyz"]) + .map(|s| s.contains("ok")) + .unwrap_or(false) + { + log::info!("kiac apiserver reachable via {proxy}"); + return Ok(()); + } + thread::sleep(Duration::from_secs(1)); + } + Err(format!( + "kiac apiserver not reachable on node IP or via localhost:{API_PROXY_HOST_PORT} proxy" + ) + .into()) +} + +fn context_name() -> String { + format!("kiac-{CLUSTER_NAME}") +} + +fn ensure_api_proxy(node_ip: &str) -> Result<(), Box> { + // Recreate when missing or when target IP may have changed after resume. + let _ = remove_api_proxy(); + log::info!( + "Starting {API_PROXY_CONTAINER}: 127.0.0.1:{API_PROXY_HOST_PORT} → {node_ip}:6443" + ); + run_cmd( + "container", + &[ + "run", + "-d", + "--name", + API_PROXY_CONTAINER, + "-p", + &format!("{API_PROXY_HOST_PORT}:6443"), + API_PROXY_IMAGE, + &format!("TCP-LISTEN:6443,fork,reuseaddr"), + &format!("TCP:{node_ip}:6443"), + ], + )?; + // Wait for host published port. + let addr = format!("127.0.0.1:{API_PROXY_HOST_PORT}"); + for _ in 0..30 { + if tcp_reachable(&addr, Duration::from_secs(1)) { + return Ok(()); + } + thread::sleep(Duration::from_millis(500)); + } + Err(format!("API proxy did not open {addr}").into()) +} + +fn remove_api_proxy() -> Result<(), Box> { + let _ = run_cmd("container", &["stop", API_PROXY_CONTAINER]); + let _ = run_cmd("container", &["rm", "-f", API_PROXY_CONTAINER]); + Ok(()) +} + +/// Rewrite the kiac-hops cluster server while preserving CA from the node. +fn point_kubeconfig_at_server( + server: &str, + tls_server_name: Option<&str>, +) -> Result<(), Box> { + let ctx = context_name(); + // CA from node admin.conf (authoritative after create/resume). + let ca_b64 = container_exec_output( + &control_plane_container(), + &[ + "sh", + "-c", + "grep 'certificate-authority-data:' /etc/kubernetes/admin.conf | awk '{print $2}'", + ], + )? + .trim() + .to_string(); + if ca_b64.is_empty() { + return Err("could not read certificate-authority-data from control-plane admin.conf".into()); + } + + let ca_bytes = base64_decode(&ca_b64)?; + let tmp = std::env::temp_dir().join("hops-kiac-ca.crt"); + std::fs::write(&tmp, ca_bytes)?; + + let mut args = vec![ + "config".to_string(), + "set-cluster".to_string(), + ctx.clone(), + format!("--server={server}"), + format!("--certificate-authority={}", tmp.display()), + "--embed-certs=true".to_string(), + ]; + if let Some(sni) = tls_server_name { + args.push(format!("--tls-server-name={sni}")); + } + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run_cmd("kubectl", &arg_refs)?; + // Clear any leftover insecure flag from earlier experiments. + let _ = run_cmd( + "kubectl", + &[ + "config", + "unset", + &format!("clusters.{ctx}.insecure-skip-tls-verify"), + ], + ); + Ok(()) +} + +fn tcp_reachable(addr: &str, timeout: Duration) -> bool { + let Ok(sock_addr) = addr.parse() else { + return false; + }; + TcpStream::connect_timeout(&sock_addr, timeout).is_ok() +} + +fn parse_cluster_names_json(out: &str) -> Option> { + let v: serde_json::Value = serde_json::from_str(out).ok()?; + // Accept either ["hops", ...] or [{ "name": "hops" }, ...] + if let Some(arr) = v.as_array() { + let mut names = Vec::new(); + for item in arr { + if let Some(s) = item.as_str() { + names.push(s.to_string()); + } else if let Some(n) = item.get("name").and_then(|x| x.as_str()) { + names.push(n.to_string()); + } + } + return Some(names); + } + if let Some(arr) = v.get("items").and_then(|x| x.as_array()) { + let mut names = Vec::new(); + for item in arr { + if let Some(n) = item.get("name").and_then(|x| x.as_str()) { + names.push(n.to_string()); + } + } + return Some(names); + } + None +} + +fn node_names() -> Result, Box> { + let out = run_cmd_output("kiac", &["get", "nodes", "--name", CLUSTER_NAME])?; + let mut names = Vec::new(); + for line in out.lines() { + let t = line.trim(); + if t.is_empty() || t.to_lowercase().starts_with("name") { + continue; + } + if let Some(name) = t.split_whitespace().next() { + names.push(name.to_string()); + } + } + Ok(names) +} + +fn control_plane_container() -> String { + format!("kiac-{CLUSTER_NAME}-control-plane") +} + +fn control_plane_ip() -> Result> { + // 1) From running container list (works without kubectl). + if let Ok(ip) = container_ip(&control_plane_container()) { + return Ok(ip); + } + // 2) kubectl node InternalIP (works once API is up on current kubeconfig). + let from_k8s = run_cmd_output( + "kubectl", + &[ + "get", + "nodes", + "-o", + "jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}", + ], + ); + if let Ok(ip) = from_k8s { + let ip = ip.trim(); + if !ip.is_empty() { + return Ok(ip.to_string()); + } + } + Err("could not determine kiac control-plane InternalIP".into()) +} + +fn container_ip(name: &str) -> Result> { + // `container list` table includes IP as CIDR; parse inspect JSON when possible. + let inspect = run_cmd_output("container", &["inspect", name])?; + if let Ok(v) = serde_json::from_str::(&inspect) { + // Shape varies; search for first 192.168.64.x or any IPv4 in networks. + if let Some(ip) = find_ipv4_in_json(&v) { + return Ok(ip); + } + } + // Fallback: list table + let list = run_cmd_output("container", &["list"])?; + for line in list.lines() { + if line.contains(name) { + for tok in line.split_whitespace() { + if let Some(ip) = tok.split('/').next() { + if ip.parse::().is_ok() { + return Ok(ip.to_string()); + } + } + } + } + } + Err(format!("no IP for container {name}").into()) +} + +fn find_ipv4_in_json(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => { + let s = s.split('/').next().unwrap_or(s); + if s.parse::().is_ok() && s != "0.0.0.0" && s != "127.0.0.1" { + return Some(s.to_string()); + } + None + } + serde_json::Value::Array(a) => a.iter().find_map(find_ipv4_in_json), + serde_json::Value::Object(m) => { + // Prefer explicit address fields. + for key in ["address", "ip", "IPAddress", "ipv4"] { + if let Some(ip) = m.get(key).and_then(find_ipv4_in_json) { + return Some(ip); + } + } + m.values().find_map(find_ipv4_in_json) + } + _ => None, + } +} + +fn write_hosts_toml(registry_name: &str, cluster_ip: &str) -> Result<(), Box> { + let dir = format!("/etc/containerd/certs.d/{registry_name}"); + let path = format!("{dir}/hosts.toml"); + let desired = format!( + "[host.\"https://{cluster_ip}:5000\"]\n capabilities = [\"pull\", \"resolve\"]\n skip_verify = true\n" + ); + + let container = control_plane_container(); + let current = container_exec_output(&container, &["sh", "-c", &format!("cat {path} 2>/dev/null || true")]) + .unwrap_or_default(); + if current == desired { + return Ok(()); + } + + log::info!("Wiring containerd registry alias: {registry_name} -> {cluster_ip}:5000"); + let b64 = base64_encode(desired.as_bytes()); + container_exec( + &container, + &[ + "sh", + "-c", + &format!("mkdir -p '{dir}' && echo {b64} | base64 -d > '{path}'"), + ], + )?; + Ok(()) +} + +fn ensure_host_docker_hint(push_hostport: &str) -> Result<(), Box> { + if !command_exists("docker") { + log::warn!( + "docker CLI not found; package path installs need a host docker daemon that can \ + reach {push_hostport} (kiac NodePort). Install Docker Desktop or use published packages." + ); + return Ok(()); + } + if run_cmd_output("docker", &["info", "--format", "{{.ServerVersion}}"]).is_err() { + log::warn!( + "no reachable host docker daemon; config install --path push to {push_hostport} may fail" + ); + return Ok(()); + } + log::info!( + "package push endpoint for kiac is {push_hostport} (NodePort on control-plane). \ + Ensure docker trusts this host for HTTPS (self-signed) or mark it insecure." + ); + Ok(()) +} + +fn container_exec(container: &str, cmd: &[&str]) -> Result<(), Box> { + // apple/container: `container exec ` (no `--` separator). + let mut args = vec!["exec", container]; + args.extend_from_slice(cmd); + run_cmd("container", &args) +} + +fn container_exec_output(container: &str, cmd: &[&str]) -> Result> { + let mut args = vec!["exec", container]; + args.extend_from_slice(cmd); + run_cmd_output("container", &args) +} + +/// Minimal base64 encoder (no extra crate) for writing files via shell. +fn base64_encode(data: &[u8]) -> String { + const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + let mut i = 0; + while i < data.len() { + let b0 = data[i] as u32; + let b1 = if i + 1 < data.len() { + data[i + 1] as u32 + } else { + 0 + }; + let b2 = if i + 2 < data.len() { + data[i + 2] as u32 + } else { + 0 + }; + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(TABLE[((triple >> 18) & 63) as usize] as char); + out.push(TABLE[((triple >> 12) & 63) as usize] as char); + if i + 1 < data.len() { + out.push(TABLE[((triple >> 6) & 63) as usize] as char); + } else { + out.push('='); + } + if i + 2 < data.len() { + out.push(TABLE[(triple & 63) as usize] as char); + } else { + out.push('='); + } + i += 3; + } + out +} + +fn base64_decode(s: &str) -> Result, Box> { + // Use host base64 for decode reliability (stdin). + let mut child = Command::new("base64") + .args(["-d"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(s.as_bytes())?; + } + let mut stdout = Vec::new(); + if let Some(mut out) = child.stdout.take() { + out.read_to_end(&mut stdout)?; + } + let status = child.wait()?; + if !status.success() || stdout.is_empty() { + return Err("base64 -d failed for kube CA".into()); + } + Ok(stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_plane_container_name() { + assert_eq!(control_plane_container(), "kiac-hops-control-plane"); + } + + #[test] + fn parse_cluster_json_string_array() { + let names = parse_cluster_names_json(r#"["hops","dev"]"#).unwrap(); + assert!(names.contains(&"hops".to_string())); + } + + #[test] + fn parse_cluster_json_object_array() { + let names = parse_cluster_names_json(r#"[{"name":"hops"},{"name":"dev"}]"#).unwrap(); + assert_eq!(names[0], "hops"); + } + + #[test] + fn base64_roundtrip_shape() { + let s = base64_encode(b"hello"); + assert_eq!(s, "aGVsbG8="); + } + + #[test] + fn start_rejects_nothing_when_no_size() { + assert!(!SizeArgs::default().any_set()); + } + + #[test] + fn find_ipv4_in_nested_json() { + let v: serde_json::Value = serde_json::from_str( + r#"[{"configuration":{"networks":[{"address":"192.168.64.3/24"}]}}]"#, + ) + .unwrap(); + assert_eq!(find_ipv4_in_json(&v).as_deref(), Some("192.168.64.3")); + } +} diff --git a/src/commands/local/backend/mod.rs b/src/commands/local/backend/mod.rs index e0b2098..55ea70d 100644 --- a/src/commands/local/backend/mod.rs +++ b/src/commands/local/backend/mod.rs @@ -6,6 +6,7 @@ mod colima; mod dory; +mod kiac; mod kind; use super::{local_state_dir, run_cmd_output, HOPS_KUBE_CONTEXT_ENV}; @@ -65,6 +66,8 @@ pub enum Backend { Kind, /// k3s on dory's shared-VM engine, via the `dory` CLI Dory, + /// Kubernetes on Apple's container runtime (each node a lightweight VM) + Kiac, } impl Backend { @@ -74,6 +77,7 @@ impl Backend { Backend::Colima => "colima", Backend::Kind => "kind", Backend::Dory => "dory", + Backend::Kiac => "kiac", } } @@ -84,6 +88,8 @@ impl Backend { Backend::Kind => "kind-hops".to_string(), // Merged into ~/.kube/config (default name hops-dory; see dory::context_name). Backend::Dory => dory::context_name(), + // kiac merges as kiac-; hops cluster name is always `hops`. + Backend::Kiac => format!("kiac-{}", kiac::CLUSTER_NAME), } } @@ -92,6 +98,7 @@ impl Backend { Backend::Colima => colima::install(), Backend::Kind => kind::install(), Backend::Dory => dory::install(), + Backend::Kiac => kiac::install(), } } @@ -100,6 +107,7 @@ impl Backend { Backend::Colima => colima::uninstall(), Backend::Kind => kind::uninstall(), Backend::Dory => dory::uninstall(), + Backend::Kiac => kiac::uninstall(), } } @@ -109,6 +117,7 @@ impl Backend { Backend::Colima => colima::instance_exists(), Backend::Kind => kind::cluster_exists(), Backend::Dory => dory::cluster_exists(), + Backend::Kiac => kiac::cluster_exists(), } } @@ -119,6 +128,7 @@ impl Backend { Backend::Colima => colima::start(size, assume_yes), Backend::Kind => kind::start(size), Backend::Dory => dory::start(size), + Backend::Kiac => kiac::start(size), } } @@ -127,6 +137,7 @@ impl Backend { Backend::Colima => colima::stop(), Backend::Kind => kind::stop(), Backend::Dory => dory::stop(), + Backend::Kiac => kiac::stop(), } } @@ -135,6 +146,7 @@ impl Backend { Backend::Colima => colima::destroy(), Backend::Kind => kind::destroy(), Backend::Dory => dory::destroy(), + Backend::Kiac => kiac::destroy(), } } @@ -143,6 +155,7 @@ impl Backend { Backend::Colima => colima::reset(), Backend::Kind => kind::reset(), Backend::Dory => dory::reset(), + Backend::Kiac => kiac::reset(), } } @@ -151,6 +164,7 @@ impl Backend { Backend::Colima => colima::resize(size), Backend::Kind => kind::resize(size), Backend::Dory => dory::resize(size), + Backend::Kiac => kiac::resize(size), } } @@ -164,6 +178,7 @@ impl Backend { Backend::Kind => Ok(()), // k3s registries.yaml is written in wire_registry once ClusterIP is known. Backend::Dory => dory::ensure_registry_trust(), + Backend::Kiac => kiac::ensure_registry_trust(), } } @@ -177,6 +192,7 @@ impl Backend { Backend::Colima => colima::sync_hosts_entry(cluster_ip), Backend::Kind => kind::wire_registry(cluster_ip), Backend::Dory => dory::wire_registry(cluster_ip), + Backend::Kiac => kiac::wire_registry(cluster_ip), } } @@ -199,6 +215,9 @@ impl Backend { Backend::Dory => dory::registry_push_addr().unwrap_or_else(|_| { crate::commands::local::package_install::REGISTRY_PUSH.to_string() }), + Backend::Kiac => kiac::registry_push_addr().unwrap_or_else(|_| { + crate::commands::local::package_install::REGISTRY_PUSH.to_string() + }), Backend::Colima | Backend::Kind => { crate::commands::local::package_install::REGISTRY_PUSH.to_string() } @@ -220,8 +239,9 @@ impl FromStr for Backend { "colima" => Ok(Backend::Colima), "kind" => Ok(Backend::Kind), "dory" => Ok(Backend::Dory), + "kiac" => Ok(Backend::Kiac), other => Err(format!( - "unknown backend '{}' (expected colima, kind, or dory)", + "unknown backend '{}' (expected colima, kind, dory, or kiac)", other )), } @@ -253,6 +273,7 @@ pub fn resolve(flag: Option) -> Backend { colima::instance_exists, kind::cluster_exists, dory::cluster_exists, + kiac::cluster_exists, platform_default() == Backend::Colima, ) } @@ -299,6 +320,7 @@ fn resolve_from( colima_detected: impl FnOnce() -> bool, kind_detected: impl FnOnce() -> bool, dory_detected: impl FnOnce() -> bool, + kiac_detected: impl FnOnce() -> bool, macos: bool, ) -> Backend { if let Some(backend) = flag { @@ -316,6 +338,9 @@ fn resolve_from( if dory_detected() { return Backend::Dory; } + if kiac_detected() { + return Backend::Kiac; + } if macos { Backend::Colima } else { @@ -460,6 +485,7 @@ mod tests { || true, no_detect, no_detect, + no_detect, true, ); @@ -474,6 +500,7 @@ mod tests { || true, no_detect, no_detect, + no_detect, true, ); @@ -482,21 +509,28 @@ mod tests { #[test] fn colima_detection_beats_kind_detection() { - let resolved = resolve_from(None, None, || true, || true, || true, false); + let resolved = resolve_from(None, None, || true, || true, || true, || true, false); assert_eq!(resolved, Backend::Colima); } #[test] fn dory_detection_used_when_no_colima_or_kind() { - let resolved = resolve_from(None, None, no_detect, no_detect, || true, true); + let resolved = resolve_from(None, None, no_detect, no_detect, || true, no_detect, true); assert_eq!(resolved, Backend::Dory); } + #[test] + fn kiac_detection_used_when_no_other_cluster() { + let resolved = resolve_from(None, None, no_detect, no_detect, no_detect, || true, true); + + assert_eq!(resolved, Backend::Kiac); + } + #[test] fn kind_detection_used_when_no_colima() { - let resolved = resolve_from(None, None, no_detect, || true, no_detect, true); + let resolved = resolve_from(None, None, no_detect, || true, no_detect, no_detect, true); assert_eq!(resolved, Backend::Kind); } @@ -504,18 +538,18 @@ mod tests { #[test] fn platform_default_when_nothing_detected() { assert_eq!( - resolve_from(None, None, no_detect, no_detect, no_detect, true), + resolve_from(None, None, no_detect, no_detect, no_detect, no_detect, true), Backend::Colima ); assert_eq!( - resolve_from(None, None, no_detect, no_detect, no_detect, false), + resolve_from(None, None, no_detect, no_detect, no_detect, no_detect, false), Backend::Kind ); } #[test] fn backend_name_round_trips_through_from_str() { - for backend in [Backend::Colima, Backend::Kind, Backend::Dory] { + for backend in [Backend::Colima, Backend::Kind, Backend::Dory, Backend::Kiac] { assert_eq!(backend.name().parse::().unwrap(), backend); } assert!("podman".parse::().is_err()); diff --git a/src/commands/local/mod.rs b/src/commands/local/mod.rs index 8cc2751..d92895f 100644 --- a/src/commands/local/mod.rs +++ b/src/commands/local/mod.rs @@ -117,7 +117,7 @@ pub struct LocalArgs { #[derive(Subcommand, Debug)] pub enum LocalCommands { - /// Install the local cluster backend (colima or kind) via Homebrew + /// Install the local cluster backend (colima, kind, dory, or kiac) via Homebrew Install, /// Reset local Kubernetes state (colima: k8s reset; kind: recreate cluster) Reset,