use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use caixa_core::{Caixa, DEFAULT_GIT_REMOTE};
use clap::Args;
use super::load::{caixa_root, load_caixa, validate_cluster_arg};
#[derive(Args)]
pub struct Deploy {
#[arg(long)]
pub cluster: String,
#[arg(long, env = "PLEME_K8S_REPO")]
pub k8s_repo: Option<PathBuf>,
#[arg(long)]
pub programs_yaml: Option<PathBuf>,
#[arg(long, conflicts_with = "apply")]
pub commit: bool,
#[arg(long)]
pub apply: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub path: Option<PathBuf>,
}
impl Deploy {
pub fn run(self) -> Result<()> {
validate_cluster_arg(&self.cluster)?;
let root = caixa_root(self.path.as_deref());
let caixa = load_caixa(&root)?;
let cu_yaml = super::chart::load_first_servico_yaml(&caixa, &root)?;
let entry = caixa_flux::programs_yaml_entry(&caixa, &cu_yaml)?;
let k8s_repo = self
.k8s_repo
.clone()
.or_else(|| dirs::home_dir().map(|h| h.join("code/github/pleme-io/k8s")))
.ok_or_else(|| anyhow::anyhow!("could not resolve k8s repo path"))?;
let programs_rel = self.programs_yaml.clone().unwrap_or_else(|| {
PathBuf::from("clusters")
.join(&self.cluster)
.join("programs")
.join("release.yaml")
});
let programs_abs = k8s_repo.join(&programs_rel);
if !programs_abs.exists() {
bail!(
"fleet-programs HelmRelease not found at {}\n\
(expected the k8s GitOps repo to have clusters/{}/programs/release.yaml; \
set --k8s-repo / PLEME_K8S_REPO or --programs-yaml if the path is non-default)",
programs_abs.display(),
self.cluster,
);
}
let existing = super::load::load_yaml(&programs_abs)?;
let (new_doc, inserted) = caixa_flux::upsert_into_helmrelease_programs(existing, entry)?;
let new_src = render_yaml_with_header(&new_doc, &programs_rel.display().to_string())?;
if self.dry_run {
print!("{new_src}");
return Ok(());
}
std::fs::write(&programs_abs, &new_src)
.with_context(|| format!("writing {}", programs_abs.display()))?;
let action = if inserted { "added" } else { "updated" };
eprintln!("{}", deploy_summary_line(action, &caixa, &programs_abs));
if self.commit || self.apply {
commit_change(&k8s_repo, &programs_rel, &caixa, action)?;
eprintln!("committed change in {}", k8s_repo.display());
}
if self.apply {
push_origin(&k8s_repo)?;
eprintln!("pushed origin/main");
} else if !self.commit {
eprintln!(
"review with: git -C {} diff -- {}",
k8s_repo.display(),
programs_rel.display(),
);
eprintln!("commit + push when ready (or rerun with --commit / --apply).");
}
Ok(())
}
}
fn render_yaml_with_header(doc: &serde_yaml::Value, rel_path: &str) -> Result<String> {
let body = serde_yaml::to_string(doc)?;
let header = format!(
"# {rel_path}\n\
# Cluster fleet manifest — HelmRelease whose spec.values.programs[]\n\
# is consumed by lareira-fleet-programs to render one ComputeUnit\n\
# CR per entry.\n\
#\n\
# Edit via `feira deploy --cluster <name>`; manual edits are\n\
# preserved on the next upsert as long as `name`-keyed entries\n\
# are kept (lookup is by `name`, order is preserved).\n\n",
);
Ok(format!("{header}{body}"))
}
fn commit_change(
repo: &std::path::Path,
rel: &std::path::Path,
caixa: &Caixa,
action: &str,
) -> Result<()> {
let msg = deploy_commit_message(action, caixa);
git(repo, ["add", &rel.display().to_string()])?;
git(repo, ["commit", "-m", &msg])?;
Ok(())
}
pub(crate) fn deploy_summary_line(
action: &str,
caixa: &Caixa,
programs_abs: &std::path::Path,
) -> String {
format!(
"{action} entry for {} v{} in {}",
caixa.nome(),
caixa.versao(),
programs_abs.display()
)
}
pub(crate) fn deploy_commit_message(action: &str, caixa: &Caixa) -> String {
format!(
"deploy: {action} {} v{}\n\
\n\
Updated by `feira deploy --cluster <name>`.\n",
caixa.nome(),
caixa.versao(),
)
}
fn push_origin(repo: &std::path::Path) -> Result<()> {
git(repo, ["push", DEFAULT_GIT_REMOTE, "HEAD"])?;
Ok(())
}
fn git<'a, I: IntoIterator<Item = &'a str>>(cwd: &std::path::Path, args: I) -> Result<()> {
use std::process::Command;
let argv: Vec<&str> = args.into_iter().collect();
let out = Command::new("git").current_dir(cwd).args(&argv).output()?;
if !out.status.success() {
bail!(
"git {} failed: {}",
argv.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_origin_remote_arg_reads_from_lifted_caixa_core_constant() {
assert_eq!(DEFAULT_GIT_REMOTE, "origin");
assert_eq!(DEFAULT_GIT_REMOTE, caixa_core::DEFAULT_GIT_REMOTE);
assert!(
std::ptr::eq(
DEFAULT_GIT_REMOTE.as_ptr(),
caixa_core::DEFAULT_GIT_REMOTE.as_ptr(),
),
"DEFAULT_GIT_REMOTE must resolve through caixa_core, not \
a sibling local `pub const` that happens to carry the same \
string — drift between the two is the canonical footgun \
this lift closes"
);
}
fn servico_caixa(nome: &str, versao: &str) -> Caixa {
let src = format!(
"(defcaixa :nome \"{nome}\" :versao \"{versao}\" :kind Servico \
:servicos (\"servicos/{nome}.computeunit.yaml\"))"
);
Caixa::from_lisp(&src).expect("Servico caixa src parses")
}
#[test]
fn deploy_summary_line_routes_through_caixa_nome_and_versao_accessors() {
let caixa = servico_caixa("checkout", "0.4.2");
let programs_abs = std::path::Path::new("/k8s/clusters/rio/programs/release.yaml");
let rendered = deploy_summary_line("added", &caixa, programs_abs);
assert_eq!(
rendered,
format!(
"added entry for {} v{} in {}",
caixa.nome(),
caixa.versao(),
programs_abs.display()
),
"deploy_summary_line must derive its {{nome}} / {{versao}} \
slots through the typed Caixa::nome / Caixa::versao \
accessors — a regression that re-inlines caixa.nome / \
caixa.versao at the emit site silently splits the operator-\
facing stderr notice from the peer accessor-derived \
substrate-side emit"
);
assert_eq!(
rendered,
"added entry for checkout v0.4.2 in /k8s/clusters/rio/programs/release.yaml"
);
}
#[test]
fn deploy_summary_line_carries_updated_action_verbatim() {
let caixa = servico_caixa("cart", "1.0.0");
let programs_abs = std::path::Path::new("/k8s/clusters/mar/programs/release.yaml");
assert_eq!(
deploy_summary_line("updated", &caixa, programs_abs),
"updated entry for cart v1.0.0 in /k8s/clusters/mar/programs/release.yaml"
);
}
#[test]
fn deploy_commit_message_routes_through_caixa_nome_and_versao_accessors() {
let caixa = servico_caixa("checkout", "0.4.2");
let msg = deploy_commit_message("added", &caixa);
assert_eq!(
msg,
format!(
"deploy: added {} v{}\n\
\n\
Updated by `feira deploy --cluster <name>`.\n",
caixa.nome(),
caixa.versao()
),
"deploy_commit_message must derive its {{nome}} / {{versao}} \
slots through the typed Caixa::nome / Caixa::versao \
accessors — a regression that re-inlines caixa.nome / \
caixa.versao at the emit site silently splits the k8s-repo \
git-history commit subject from the peer accessor-derived \
substrate-side emit"
);
assert_eq!(
msg,
"deploy: added checkout v0.4.2\n\
\n\
Updated by `feira deploy --cluster <name>`.\n"
);
assert_eq!(
deploy_commit_message("updated", &servico_caixa("cart", "1.0.0")),
"deploy: updated cart v1.0.0\n\
\n\
Updated by `feira deploy --cluster <name>`.\n"
);
}
}