use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use kanade_shared::manifest::Manifest;
use crate::cmd::provenance::{append_origin_yaml, detect_repo_origin, has_top_level_origin};
use tracing::{info, warn};
#[derive(Args, Debug)]
pub struct JobArgs {
#[command(subcommand)]
pub sub: JobSub,
}
#[derive(Subcommand, Debug)]
pub enum JobSub {
Create {
#[arg(required = true, num_args = 1..)]
paths: Vec<PathBuf>,
},
Validate {
#[arg(required = true, num_args = 1..)]
paths: Vec<PathBuf>,
},
Export {
#[arg(required_unless_present = "all")]
id: Option<String>,
#[arg(long, conflicts_with = "id")]
all: bool,
#[arg(long)]
out_dir: Option<PathBuf>,
},
List,
Delete { id: String },
}
pub async fn execute(backend_url: &str, args: JobArgs) -> Result<()> {
let base = backend_url.trim_end_matches('/');
match args.sub {
JobSub::Create { paths } => create_all(base, paths).await,
JobSub::Validate { paths } => validate_all(paths),
JobSub::Export { id, all, out_dir } => {
crate::cmd::bulk::export(base, "jobs", id, all, out_dir).await
}
JobSub::List => list(base).await,
JobSub::Delete { id } => delete(base, &id).await,
}
}
async fn create_all(base: &str, paths: Vec<PathBuf>) -> Result<()> {
let files = crate::cmd::bulk::expand_manifest_paths(&paths)?;
let mut failures = 0usize;
for f in &files {
if let Err(e) = create_one(base, f).await {
eprintln!("✗ {}: {e:#}", f.display());
failures += 1;
}
}
if failures > 0 {
anyhow::bail!("{failures}/{} job manifest(s) failed", files.len());
}
Ok(())
}
async fn create_one(base: &str, yaml: &std::path::Path) -> Result<()> {
let raw = std::fs::read_to_string(yaml).with_context(|| format!("read {yaml:?}"))?;
let docs = crate::cmd::bulk::split_yaml_documents(&raw);
match docs.as_slice() {
[] => anyhow::bail!("{yaml:?}: no YAML documents found"),
[only] => return create_one_doc(base, yaml, only).await,
_ => {}
}
let mut failures = 0usize;
for (i, doc) in docs.iter().enumerate() {
if let Err(e) = create_one_doc(base, yaml, doc).await {
eprintln!("✗ {} [doc {}]: {e:#}", yaml.display(), i + 1);
failures += 1;
}
}
if failures > 0 {
anyhow::bail!("{failures}/{} document(s) in {yaml:?} failed", docs.len());
}
Ok(())
}
async fn create_one_doc(base: &str, yaml: &std::path::Path, raw: &str) -> Result<()> {
let mut job: Manifest = kanade_shared::strict::from_yaml_str(raw)
.map_err(|e| anyhow::anyhow!("parse {yaml:?}: {e}"))?;
if let Err(e) = job.validate() {
anyhow::bail!("{yaml:?}: {e}");
}
let script_file_abs = job
.execute
.script_file
.as_deref()
.map(|p| resolve_script_file_path(yaml, p));
let origin = detect_repo_origin(yaml, script_file_abs.as_deref());
let (body, sent_raw) = if let Some(path) = job.execute.script_file.as_deref() {
let file_path = resolve_script_file_path(yaml, path);
let script_body = std::fs::read_to_string(&file_path).with_context(|| {
format!(
"read script_file {} (referenced from {yaml:?})",
file_path.display(),
)
})?;
info!(
script_file = %file_path.display(),
size = script_body.len(),
"inlined script_file into execute.script",
);
job.execute.script = Some(script_body);
job.execute.script_file = None;
job.origin = origin;
let serialized = manifest_to_block_scalar_yaml(&job)
.context("re-serialize manifest after script_file inlining")?;
(serialized, false)
} else {
let mut out = raw.to_string();
if let Some(o) = &origin {
if has_top_level_origin(&out) {
warn!(
job_id = %job.id,
"origin: already present in source YAML; preserving it. \
If the repo / remote changed, delete + recreate the job \
to refresh provenance",
);
} else {
append_origin_yaml(&mut out, o).context("append origin provenance")?;
}
}
(out, origin.is_none())
};
info!(
job_id = %job.id,
version = %job.version,
sent_raw_yaml = sent_raw,
"upserting job",
);
let url = format!("{base}/api/jobs");
let resp = crate::http_client::authed_client()?
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/yaml")
.body(body)
.send()
.await
.with_context(|| format!("POST {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("create rejected: {status} — {body}");
}
let payload: serde_json::Value = resp
.json()
.await
.context("parse JSON response from server")?;
let id = payload.get("id").and_then(|v| v.as_str()).unwrap_or("?");
let version = payload
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("?");
println!("✓ {} → job '{id}' v{version}", yaml.display());
Ok(())
}
fn validate_all(paths: Vec<PathBuf>) -> Result<()> {
let files = crate::cmd::bulk::expand_manifest_paths(&paths)?;
let mut failures = 0usize;
for f in &files {
if let Err(e) = validate_one(f) {
eprintln!("✗ {}: {e:#}", f.display());
failures += 1;
}
}
if failures > 0 {
anyhow::bail!(
"{failures}/{} job manifest(s) failed validation",
files.len()
);
}
Ok(())
}
fn validate_one(yaml: &std::path::Path) -> Result<()> {
let raw = std::fs::read_to_string(yaml).with_context(|| format!("read {yaml:?}"))?;
let docs = crate::cmd::bulk::split_yaml_documents(&raw);
match docs.as_slice() {
[] => anyhow::bail!("{yaml:?}: no YAML documents found"),
[only] => return validate_one_doc(yaml, only),
_ => {}
}
let mut failures = 0usize;
for (i, doc) in docs.iter().enumerate() {
if let Err(e) = validate_one_doc(yaml, doc) {
eprintln!("✗ {} [doc {}]: {e:#}", yaml.display(), i + 1);
failures += 1;
}
}
if failures > 0 {
anyhow::bail!(
"{failures}/{} document(s) in {yaml:?} failed validation",
docs.len()
);
}
Ok(())
}
fn validate_one_doc(yaml: &std::path::Path, raw: &str) -> Result<()> {
let job: Manifest = kanade_shared::strict::from_yaml_str(raw)
.map_err(|e| anyhow::anyhow!("parse {yaml:?}: {e}"))?;
if let Err(e) = job.validate() {
anyhow::bail!("{yaml:?}: {e}");
}
if let Some(path) = job.execute.script_file.as_deref() {
let file_path = resolve_script_file_path(yaml, path);
if !file_path.is_file() {
anyhow::bail!(
"{yaml:?}: script_file {} not found or is not a file",
file_path.display(),
);
}
}
println!(
"✓ {} → job '{}' v{} (valid)",
yaml.display(),
job.id,
job.version,
);
Ok(())
}
fn resolve_script_file_path(yaml: &std::path::Path, script_file: &str) -> PathBuf {
let p = PathBuf::from(script_file);
if p.is_absolute() {
return p;
}
match yaml.parent() {
Some(parent) => parent.join(p),
None => p,
}
}
fn manifest_to_block_scalar_yaml(job: &Manifest) -> Result<String> {
const SENTINEL: &str = "__KANADE_SCRIPT_BLOCK_SENTINEL__";
let body = job
.execute
.script
.clone()
.unwrap_or_default()
.replace("\r\n", "\n");
let mut stub = job.clone();
stub.execute.script = Some(SENTINEL.to_string());
let serialized = serde_yaml::to_string(&stub).context("serialize manifest")?;
let mut out = String::with_capacity(serialized.len() + body.len());
let mut spliced = false;
for line in serialized.lines() {
if !spliced && line.contains(SENTINEL) {
let indent: String = line.chars().take_while(|c| *c == ' ').collect();
let (chomp, content) = match body.strip_suffix('\n') {
Some(stripped) => ("|", stripped),
None => ("|-", body.as_str()),
};
out.push_str(&indent);
out.push_str("script: ");
out.push_str(chomp);
out.push('\n');
let body_indent = format!("{indent} ");
for bl in content.split('\n') {
if bl.is_empty() {
out.push('\n');
} else {
out.push_str(&body_indent);
out.push_str(bl);
out.push('\n');
}
}
spliced = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !spliced {
anyhow::bail!("script sentinel vanished during YAML serialization");
}
Ok(out)
}
async fn list(base: &str) -> Result<()> {
let url = format!("{base}/api/jobs");
let resp = crate::http_client::authed_client()?
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("list failed: {}", resp.status());
}
let payload: serde_json::Value = resp.json().await?;
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
async fn delete(base: &str, id: &str) -> Result<()> {
let url = format!("{base}/api/jobs/{id}");
let resp = crate::http_client::authed_client()?
.delete(&url)
.send()
.await
.with_context(|| format!("DELETE {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("delete failed: {status} — {body}");
}
println!("deleted: {id}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_script_file_resolves_under_yaml_parent() {
let yaml = std::path::Path::new("/repo/jobs/cleanup.yaml");
assert_eq!(
resolve_script_file_path(yaml, "scripts/cleanup.ps1"),
std::path::PathBuf::from("/repo/jobs/scripts/cleanup.ps1"),
);
}
#[test]
fn absolute_script_file_passes_through_unchanged() {
let yaml = std::path::Path::new("/repo/jobs/cleanup.yaml");
let abs = if cfg!(windows) {
"C:/shared/templates/cleanup.ps1"
} else {
"/shared/templates/cleanup.ps1"
};
assert_eq!(
resolve_script_file_path(yaml, abs),
std::path::PathBuf::from(abs),
);
}
#[test]
fn manifest_with_both_script_and_script_file_fails_validation() {
let yaml = r#"
id: ambiguous
version: 1.0.0
execute:
shell: powershell
script: "echo inline"
script_file: scripts/cleanup.ps1
timeout: 30s
"#;
let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
let err = m.validate().expect_err("validate should reject");
assert!(
err.contains("only one of"),
"expected exclusivity error, got: {err}",
);
}
#[test]
fn bare_yaml_filename_keeps_script_file_relative_to_cwd() {
let yaml = std::path::Path::new("manifest.yaml");
assert_eq!(
resolve_script_file_path(yaml, "script.ps1"),
std::path::PathBuf::from("script.ps1"),
);
}
fn inline_manifest() -> Manifest {
serde_yaml::from_str(
"id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script: \"x\"\n timeout: 30s\n",
)
.expect("parse base manifest")
}
#[test]
fn block_scalar_yaml_roundtrips_multiline_script() {
let mut m = inline_manifest();
let script = "#requires -Version 5.1\nWrite-Output 'hi'\n\nGet-Date\n";
m.execute.script = Some(script.to_string());
m.origin = Some(kanade_shared::manifest::RepoOrigin {
path: "configs/jobs/j.yaml".into(),
repo: Some("git@github.com:o/r.git".into()),
script_file: Some("configs/jobs/scripts/j.ps1".into()),
});
let yaml = manifest_to_block_scalar_yaml(&m).expect("serialize");
assert!(
yaml.contains("script: |"),
"expected a block scalar, got:\n{yaml}"
);
assert!(
!yaml.contains("script: \""),
"script must be a block scalar, not a quoted blob:\n{yaml}"
);
let back: Manifest = serde_yaml::from_str(&yaml).expect("re-parse");
assert_eq!(back.execute.script.as_deref(), Some(script));
assert_eq!(
back.origin.as_ref().map(|o| o.path.as_str()),
Some("configs/jobs/j.yaml"),
);
back.validate().expect("spliced manifest still validates");
}
#[test]
fn block_scalar_yaml_preserves_no_trailing_newline() {
let mut m = inline_manifest();
let script = "line1\nline2";
m.execute.script = Some(script.to_string());
let yaml = manifest_to_block_scalar_yaml(&m).expect("serialize");
assert!(
yaml.contains("script: |-"),
"expected strip-chomp block, got:\n{yaml}"
);
let back: Manifest = serde_yaml::from_str(&yaml).expect("re-parse");
assert_eq!(back.execute.script.as_deref(), Some(script));
}
fn repo_root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("crates/kanade has a repo root two levels up")
.to_path_buf()
}
#[test]
fn validate_one_accepts_a_real_script_file_job() {
let yaml = repo_root().join("configs/jobs/installers/install-kanade-client.yaml");
if !yaml.exists() {
return;
}
validate_one(&yaml).expect("real script_file job validates offline");
}
#[test]
fn validate_one_rejects_missing_script_file() {
let dir = tempfile::tempdir().expect("mk temp dir");
let yaml_path = dir.path().join("job.yaml");
std::fs::write(
&yaml_path,
"id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script_file: does-not-exist.ps1\n timeout: 30s\n",
)
.expect("write temp manifest");
let err = validate_one(&yaml_path).expect_err("missing script_file must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("script_file") && msg.contains("not found"),
"expected a missing-script_file error, got: {msg}",
);
}
#[test]
fn validate_one_rejects_unknown_key() {
let dir = tempfile::tempdir().expect("mk temp dir");
let yaml_path = dir.path().join("job.yaml");
std::fs::write(
&yaml_path,
"id: j\nversion: 1.0.0\nexecute:\n shell: powershell\n script: x\n timeout: 30s\nbogus_key: 1\n",
)
.expect("write temp manifest");
let err = validate_one(&yaml_path).expect_err("unknown key must fail");
assert!(
format!("{err:#}").contains("bogus_key"),
"expected the unknown key in the error, got: {err:#}",
);
}
#[test]
fn install_kanade_client_yaml_renders_clean() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("crates/kanade has a repo root two levels up");
let yaml_path = root.join("configs/jobs/installers/install-kanade-client.yaml");
let ps1_path = root.join("configs/jobs/installers/scripts/install-kanade-client.ps1");
if !yaml_path.exists() || !ps1_path.exists() {
return;
}
let raw = std::fs::read_to_string(&yaml_path).expect("read manifest");
let mut job: Manifest = kanade_shared::strict::from_yaml_str(&raw).expect("parse manifest");
let body = std::fs::read_to_string(&ps1_path).expect("read script");
job.execute.script = Some(body.clone());
job.execute.script_file = None;
let out = manifest_to_block_scalar_yaml(&job).expect("serialize");
assert!(out.contains("script: |"), "expected block scalar:\n{out}");
assert!(
!out.contains("script: \""),
"script must be a block scalar, not a quoted blob:\n{out}"
);
let back: Manifest = serde_yaml::from_str(&out).expect("re-parse");
assert_eq!(
back.execute.script.as_deref(),
Some(body.replace("\r\n", "\n").as_str()),
);
back.validate()
.expect("round-tripped manifest still validates");
}
}