use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use caixa_core::{Caixa, CaixaKind, DEFAULT_GIT_REMOTE, WitTarget};
use clap::{Args, Subcommand};
use super::load::{caixa_root, load_caixa, validate_cluster_arg};
#[derive(Args)]
pub struct App {
#[command(subcommand)]
pub command: AppCommand,
}
#[derive(Subcommand)]
pub enum AppCommand {
Graph(GraphArgs),
Deploy(DeployArgs),
}
impl App {
pub fn run(self) -> Result<()> {
match self.command {
AppCommand::Graph(c) => c.run(),
AppCommand::Deploy(c) => c.run(),
}
}
}
#[derive(Args)]
pub struct GraphArgs {
#[arg(long)]
pub path: Option<PathBuf>,
#[arg(long)]
pub json: bool,
}
impl GraphArgs {
pub fn run(self) -> Result<()> {
let caixa = load_aplicacao(self.path.as_deref())?;
let spec = caixa_mesh::typed_view(&caixa)?;
if self.json {
println!("{}", serde_json::to_string_pretty(&spec)?);
} else {
println!("{}", graph_header_line(&caixa));
let placement = spec.placement();
println!(
" placement: {:?} on clusters {:?}",
placement.estrategia(),
placement.clusters()
);
println!(" membros ({}):", spec.membros().len(),);
for m in spec.membros() {
println!(" - {} {}", m.nome(), m.versao_requirement());
}
println!(" contratos ({}):", spec.contratos().len());
for c in spec.contratos() {
let label = match c.target().expect("validated by typed_view") {
WitTarget::Http { endpoint } => {
format!("{}={endpoint}", WitTarget::HTTP_FIELD_NAME)
}
WitTarget::PubSub { subject } => {
format!("{}={subject}", WitTarget::PUBSUB_FIELD_NAME)
}
WitTarget::Store { slot } => {
format!("{}={slot}", WitTarget::STORE_FIELD_NAME)
}
WitTarget::Capability => "(capability-only)".to_string(),
};
println!(
" - {} → {} via {} [{}]",
c.source(),
c.destination(),
c.world_ref(),
label,
);
}
if let Some(e) = spec.entrada() {
println!(
" entrada: {} → {} (paths={:?}, port={})",
e.hostname(),
e.destination(),
e.paths(),
e.port(),
);
} else {
println!(" entrada: (internal-only mesh)");
}
}
Ok(())
}
}
#[derive(Args)]
pub struct DeployArgs {
#[arg(long)]
pub cluster: String,
#[arg(long, env = "PLEME_K8S_REPO")]
pub k8s_repo: Option<PathBuf>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, conflicts_with = "apply")]
pub commit: bool,
#[arg(long)]
pub apply: bool,
#[arg(long)]
pub path: Option<PathBuf>,
}
impl DeployArgs {
pub fn run(self) -> Result<()> {
validate_cluster_arg(&self.cluster)?;
let caixa = load_aplicacao(self.path.as_deref())?;
let docs = caixa_mesh::render_all(&caixa)?;
let serialized = render_multidoc(caixa.nome(), &docs)?;
if self.dry_run {
print!("{serialized}");
return Ok(());
}
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 rel = PathBuf::from("clusters")
.join(&self.cluster)
.join("aplicacaos")
.join(caixa.nome())
.join("manifests.yaml");
let abs = k8s_repo.join(&rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&abs, &serialized).with_context(|| format!("writing {}", abs.display()))?;
eprintln!("rendered {} → {}", caixa.nome(), abs.display());
if self.commit || self.apply {
commit_change(&k8s_repo, &rel, &caixa)?;
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(),
rel.display()
);
}
Ok(())
}
}
pub(crate) fn graph_header_line(caixa: &Caixa) -> String {
format!("Aplicacao {} v{}", caixa.nome(), caixa.versao())
}
pub(crate) fn deploy_commit_message(caixa: &Caixa) -> String {
format!(
"deploy: aplicacao {} v{}\n\nUpdated by `feira app deploy --cluster <name>`.\n",
caixa.nome(),
caixa.versao(),
)
}
fn load_aplicacao(path: Option<&std::path::Path>) -> Result<Caixa> {
let root = caixa_root(path);
let caixa = load_caixa(&root)?;
caixa_core::require_kind(&caixa, CaixaKind::Aplicacao)
.with_context(|| "feira app verbs require :kind Aplicacao")?;
Ok(caixa)
}
fn render_multidoc(nome: &str, docs: &[serde_yaml::Value]) -> Result<String> {
let header = format!(
"# Auto-generated by `feira app deploy` from caixa.lisp ({nome}).\n\
# One file per Aplicacao; multi-doc YAML separated by `---`.\n\
# Edits are overwritten on next `feira app deploy`.\n",
);
let mut out = String::from(&header);
for d in docs {
out.push_str("---\n");
let s = serde_yaml::to_string(d)?;
out.push_str(&s);
}
Ok(out)
}
fn commit_change(repo: &std::path::Path, rel: &std::path::Path, caixa: &Caixa) -> Result<()> {
let msg = deploy_commit_message(caixa);
git(repo, ["add", &rel.display().to_string()])?;
git(repo, ["commit", "-m", &msg])?;
Ok(())
}
fn push_origin(repo: &std::path::Path) -> Result<()> {
git(repo, ["push", DEFAULT_GIT_REMOTE, "HEAD"])
}
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::*;
use tempfile::tempdir;
fn write_caixa(dir: &std::path::Path, src: &str) {
std::fs::write(dir.join("caixa.lisp"), src).expect("write caixa.lisp");
}
#[test]
fn load_aplicacao_accepts_aplicacao_kind() {
let dir = tempdir().expect("tempdir");
write_caixa(
dir.path(),
r#"(defcaixa
:nome "checkout"
:kind Aplicacao
:versao "0.1.0"
:membros ())"#,
);
let caixa = load_aplicacao(Some(dir.path())).expect("Aplicacao must load");
assert_eq!(caixa.nome(), "checkout");
assert_eq!(caixa.kind(), CaixaKind::Aplicacao);
}
#[test]
fn load_aplicacao_rejects_non_aplicacao_kind_with_named_caixa() {
let dir = tempdir().expect("tempdir");
write_caixa(
dir.path(),
r#"(defcaixa
:nome "mis-kinded"
:kind Biblioteca
:versao "0.1.0"
:bibliotecas ())"#,
);
let err = load_aplicacao(Some(dir.path())).expect_err("Biblioteca must reject");
let rendered = format!("{err:?}");
assert!(
rendered.contains("mis-kinded"),
"diagnostic must name the offending caixa nome (got: {rendered:?})"
);
assert!(
rendered.contains("Aplicacao"),
"diagnostic must name the expected kind (got: {rendered:?})"
);
assert!(
rendered.contains("Biblioteca"),
"diagnostic must name the actual kind (got: {rendered:?})"
);
assert!(
rendered.contains("feira app"),
"diagnostic must keep the feira-app context the prior bail \
prefix carried (got: {rendered:?})"
);
}
#[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"
);
}
#[test]
fn graph_header_line_routes_through_caixa_nome_and_versao_accessors() {
let caixa = Caixa::from_lisp(
r#"(defcaixa
:nome "checkout"
:kind Aplicacao
:versao "0.2.3"
:membros ())"#,
)
.expect("parse");
let line = graph_header_line(&caixa);
assert_eq!(
line,
format!("Aplicacao {} v{}", caixa.nome(), caixa.versao()),
"graph header line must route the `{{nome}}` / `{{versao}}` \
scalars through the typed Caixa::nome / Caixa::versao \
accessors — any regression to the raw `caixa.nome` / \
`caixa.versao` field-accesses would silently pass today \
(accessors are `&self.<field>`) but drift on the first \
accessor extension"
);
assert_eq!(
line, "Aplicacao checkout v0.2.3",
"graph header line must carry the Aplicacao's `:nome` / \
`:versao` verbatim (got: {line:?})"
);
}
#[test]
fn deploy_commit_message_routes_through_caixa_nome_and_versao_accessors() {
let caixa = Caixa::from_lisp(
r#"(defcaixa
:nome "checkout"
:kind Aplicacao
:versao "1.4.7"
:membros ())"#,
)
.expect("parse");
let msg = deploy_commit_message(&caixa);
assert_eq!(
msg,
format!(
"deploy: aplicacao {} v{}\n\nUpdated by `feira app deploy --cluster <name>`.\n",
caixa.nome(),
caixa.versao(),
),
"commit message must route the `{{nome}}` / `{{versao}}` \
scalars through the typed Caixa::nome / Caixa::versao \
accessors — any regression to the raw `caixa.nome` / \
`caixa.versao` field-accesses would silently pass today \
(accessors are `&self.<field>`) but drift on the first \
accessor extension"
);
assert!(
msg.starts_with("deploy: aplicacao checkout v1.4.7\n"),
"commit message subject line must carry the Aplicacao's \
`:nome` / `:versao` verbatim (got: {msg:?})"
);
assert!(
msg.ends_with("Updated by `feira app deploy --cluster <name>`.\n"),
"commit message body must carry the canonical trailing \
sentence naming the verb that produced it (got: {msg:?})"
);
}
#[test]
fn load_aplicacao_rejection_carries_typed_kind_mismatch_view() {
let dir = tempdir().expect("tempdir");
write_caixa(
dir.path(),
r#"(defcaixa
:nome "wrong-shape"
:kind Servico
:versao "0.1.0"
:servicos ("servicos/wrong-shape.computeunit.yaml"))"#,
);
let err = load_aplicacao(Some(dir.path())).expect_err("Servico must reject");
let km = err
.chain()
.find_map(|e| e.downcast_ref::<caixa_core::KindMismatch>())
.expect("KindMismatch must be reachable through the anyhow chain");
assert_eq!(km.nome, "wrong-shape");
assert_eq!(km.expected, CaixaKind::Aplicacao);
assert_eq!(km.actual, CaixaKind::Servico);
}
}