Skip to main content

cellos_ctl/cmd/
apply.rs

1//! `cellctl apply -f formation.yaml` → POST /v1/formations.
2//!
3//! YAML is parsed locally only to validate shape and convert to JSON. The
4//! authoritative validation happens on the server; we do a minimal local check
5//! to surface obvious schema errors with exit-code 3 before any network call.
6
7use std::path::Path;
8
9use serde_json::Value;
10
11use crate::client::CellosClient;
12use crate::exit::{CtlError, CtlResult};
13use crate::model::Formation;
14
15pub async fn run(client: &CellosClient, path: &Path) -> CtlResult<()> {
16    let yaml = std::fs::read_to_string(path)
17        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
18
19    let body: Value = serde_yaml::from_str(&yaml)?;
20    validate_formation_spec(&body, path)?;
21
22    let created: Formation = client.post_json("/v1/formations", &body).await?;
23    let name = if !created.name.is_empty() {
24        &created.name
25    } else {
26        &created.id
27    };
28    println!("formation/{name} created (state={})", created.state);
29    Ok(())
30}
31
32/// Local shape check — the server is authoritative, but obvious mistakes
33/// should fail fast with exit code 3 (validation) rather than 2 (api).
34fn validate_formation_spec(v: &Value, path: &Path) -> CtlResult<()> {
35    let Some(obj) = v.as_object() else {
36        return Err(CtlError::validation(format!(
37            "{}: top-level must be a YAML mapping",
38            path.display()
39        )));
40    };
41
42    // Accept either `kind: Formation` (kubectl-style) OR a bare formation spec
43    // (which is what Session 15/16 sketched). Don't be pedantic — server decides.
44    if let Some(kind) = obj.get("kind").and_then(|k| k.as_str()) {
45        if kind != "Formation" {
46            return Err(CtlError::validation(format!(
47                "{}: kind must be \"Formation\" (got \"{kind}\")",
48                path.display()
49            )));
50        }
51    }
52
53    // Require either `name` or `metadata.name`.
54    let has_name = obj.get("name").and_then(|n| n.as_str()).is_some()
55        || obj
56            .get("metadata")
57            .and_then(|m| m.get("name"))
58            .and_then(|n| n.as_str())
59            .is_some();
60    if !has_name {
61        return Err(CtlError::validation(format!(
62            "{}: missing required field `name` (or `metadata.name`)",
63            path.display()
64        )));
65    }
66    Ok(())
67}