use std::path::{Path, PathBuf};
use std::process::Command;
use crate::codegen::{plan::Plan, render};
use anyhow::{Context, Result, anyhow, bail};
use clap::Subcommand;
#[derive(Subcommand)]
pub enum K8sCmd {
#[command(after_long_help = "PREREQUISITES:
- tonin.toml in the current dir (or `--path`).
SIDE EFFECTS:
Writes files under ./<out>/ (default ./k8s/). Overwrites existing
files in that directory.
EXAMPLES:
# Render the current service into ./k8s/
tonin k8s generate
# Render every service in a workspace recursively
tonin k8s generate --workspace --path ./services
# Apply the prod overlay for [database.prod] / [cache.prod]
tonin k8s generate --env prod
# Preview to stdout without writing files
tonin k8s generate --dry-run
SEE ALSO:
docs/12-kubernetes-deploy.md")]
Generate(GenerateArgs),
#[command(after_long_help = "PREREQUISITES:
- kubectl on PATH
- A reachable Kubernetes cluster
- Current kubectl context points where you want to validate
- tonin.toml in the current dir
EXAMPLES:
tonin k8s validate
tonin k8s validate --env staging
EXIT CODES:
0 — every manifest accepted by the apiserver
non-zero — at least one manifest rejected; stderr carries the reason")]
Validate(GenerateArgs),
#[command(after_long_help = "PREREQUISITES:
- kubectl on PATH (the `diff` subcommand specifically)
- A reachable Kubernetes cluster
- tonin.toml in the current dir
EXAMPLES:
tonin k8s diff
tonin k8s diff --workspace --path ./services
EXIT CODES:
0 — no differences (cluster matches the would-be-applied state)
1 — differences exist (per kubectl diff semantics)
>1 — error")]
Diff(GenerateArgs),
#[command(after_long_help = "PREREQUISITES:
- kubectl on PATH
- A reachable Kubernetes cluster
- Current kubectl context points to your target cluster (unless
overridden with `--context`)
- tonin.toml in the current dir
- For workspace mode, every tonin.toml under `--path` must be valid
SIDE EFFECTS:
- Writes files under ./<out>/ (default ./k8s/)
- Calls `kubectl apply` — creates / mutates real resources in the
target cluster. There is no rollback; review the manifests first
or pair with `k8s diff`.
EXAMPLES:
# Apply to whatever the current kubectl context points at
tonin k8s apply
# Apply to a named context (e.g. a staging cluster)
tonin k8s apply --context staging
# Apply every service in a workspace
tonin k8s apply --workspace --path ./services")]
Apply(ApplyArgs),
#[command(after_long_help = "MODES:
--mode local For kind / k3d / minikube. Installs in-cluster defaults
(OTel collector). Assumes the cluster is local-dev disposable.
--mode remote Uses the current kubectl context as-is. Prints commands
rather than running them when destructive.
EXAMPLES:
tonin k8s setup --mode local
tonin k8s setup --mode remote")]
Setup(SetupArgs),
}
#[derive(clap::Args, Clone)]
pub struct GenerateArgs {
#[arg(long, default_value = ".")]
pub path: PathBuf,
#[arg(long)]
pub workspace: bool,
#[arg(long, default_value = "k8s")]
pub out: PathBuf,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub env: Option<String>,
}
#[derive(clap::Args)]
pub struct ApplyArgs {
#[command(flatten)]
pub generate: GenerateArgs,
#[arg(long)]
pub context: Option<String>,
}
#[derive(clap::Args)]
pub struct SetupArgs {
#[arg(long, default_value = "local")]
pub mode: String,
}
pub fn run(cmd: K8sCmd) -> Result<()> {
match cmd {
K8sCmd::Generate(args) => generate(&args),
K8sCmd::Validate(args) => validate(&args),
K8sCmd::Diff(args) => diff(&args),
K8sCmd::Apply(args) => apply(&args),
K8sCmd::Setup(args) => setup(&args),
}
}
fn generate(args: &GenerateArgs) -> Result<()> {
let plans = load_plans(&args.path, args.workspace, args.env.as_deref())?;
for plan in &plans {
let files = render::render(plan).context("rendering plan")?;
let target_dir = plan.dir.join(&args.out);
if args.dry_run {
println!("# === {} ({} files) ===", plan.name, files.len());
for f in &files {
println!("# --- {}/{} ---", target_dir.display(), f.path);
println!("{}", f.contents);
}
} else {
std::fs::create_dir_all(&target_dir)
.with_context(|| format!("creating {}", target_dir.display()))?;
for f in &files {
let p = target_dir.join(&f.path);
std::fs::write(&p, &f.contents)
.with_context(|| format!("writing {}", p.display()))?;
}
eprintln!(
"wrote {} files for service '{}' → {}",
files.len(),
plan.name,
target_dir.display()
);
}
}
Ok(())
}
fn validate(args: &GenerateArgs) -> Result<()> {
require_kubectl()?;
let plans = load_plans(&args.path, args.workspace, args.env.as_deref())?;
let tmp = tempfile::tempdir().context("creating temp dir")?;
let mut total = 0;
let mut fail = 0;
for plan in &plans {
let svc_dir = tmp.path().join(&plan.name);
std::fs::create_dir_all(&svc_dir)?;
for f in render::render(plan)? {
std::fs::write(svc_dir.join(&f.path), f.contents)?;
total += 1;
}
let status = Command::new("kubectl")
.args(["apply", "--dry-run=server", "-f"])
.arg(&svc_dir)
.status()
.context("running kubectl apply --dry-run=server")?;
if status.success() {
eprintln!("✓ {}: valid", plan.name);
} else {
eprintln!("✗ {}: kubectl reported errors above", plan.name);
fail += 1;
}
}
eprintln!(
"validated {} files across {} services; {} failed",
total,
plans.len(),
fail
);
if fail > 0 {
bail!("{} service(s) failed validation", fail);
}
Ok(())
}
fn diff(args: &GenerateArgs) -> Result<()> {
require_kubectl()?;
let plans = load_plans(&args.path, args.workspace, args.env.as_deref())?;
let tmp = tempfile::tempdir().context("creating temp dir")?;
for plan in &plans {
let svc_dir = tmp.path().join(&plan.name);
std::fs::create_dir_all(&svc_dir)?;
for f in render::render(plan)? {
std::fs::write(svc_dir.join(&f.path), f.contents)?;
}
eprintln!("# diff: {}", plan.name);
Command::new("kubectl")
.args(["diff", "-f"])
.arg(&svc_dir)
.status()
.context("running kubectl diff")?;
}
Ok(())
}
fn apply(args: &ApplyArgs) -> Result<()> {
require_kubectl()?;
generate(&args.generate)?;
let plans = load_plans(
&args.generate.path,
args.generate.workspace,
args.generate.env.as_deref(),
)?;
for plan in &plans {
let target_dir = plan.dir.join(&args.generate.out);
let mut cmd = Command::new("kubectl");
if let Some(ctx) = &args.context {
cmd.args(["--context", ctx]);
}
cmd.args(["apply", "-f"]).arg(&target_dir);
let status = cmd.status().context("running kubectl apply")?;
if !status.success() {
bail!("kubectl apply failed for service '{}'", plan.name);
}
eprintln!("✓ {}: applied", plan.name);
}
Ok(())
}
fn setup(args: &SetupArgs) -> Result<()> {
eprintln!("tonin k8s setup --mode={} : not yet implemented", args.mode);
eprintln!("manual setup for now:");
eprintln!(" 1. Create cluster (kind/k3d/EKS/GKE/AKS)");
eprintln!(" 2. Install Cilium (or chosen mesh) per docs/mesh-setup.md");
eprintln!(" 3. kubectl apply -f templates/k8s/otel-collector.yaml.tmpl");
eprintln!(" 4. tonin k8s validate --workspace --path examples");
Ok(())
}
fn load_plans(path: &Path, workspace: bool, env: Option<&str>) -> Result<Vec<Plan>> {
let env = crate::codegen::stateful::select_env(env);
let plans = if workspace {
Plan::load_workspace_with_env(path, &env).context("loading workspace plans")?
} else {
let toml = path.join("tonin.toml");
if !toml.exists() {
return Err(anyhow!(
"no tonin.toml at {}; pass --workspace to scan recursively",
toml.display()
));
}
vec![Plan::load_with_env(&toml, &env).context("loading plan")?]
};
if plans.is_empty() {
bail!("no services found under {}", path.display());
}
Ok(plans)
}
fn require_kubectl() -> Result<()> {
Command::new("kubectl")
.arg("version")
.arg("--client")
.output()
.map_err(|e| anyhow!("kubectl not found on PATH: {e}"))?;
Ok(())
}