cindy 0.1.0

Managing infrastructure at breakneck speed.
Documentation
use crate as cindy;

/// The entire fleet as the user's `#[cindy::inventory]` function declares
/// it.
///
/// `Inventory<V>` is what the user constructs and returns; the CLI then
/// pulls a JSON dump of it out of the orchestrator binary at startup
/// (via the `CINDY_DUMP_INVENTORY=1` env-var mode) and consults it to
/// figure out which hosts to deploy to and — after applying `--limit`
/// — what `CINDY_HOST_CONTEXT` to inject into each per-host orchestrator
/// invocation.
///
/// The CLI is *type-erased*: it parses the dump into
/// `Inventory<serde_json::Value>` so it can introspect `name` / `tags`
/// without knowing your `V`. Your `V` round-trips intact and is
/// re-deserialised inside the orchestrator process, where the
/// `#[cindy::main]`-generated entry knows the type. Target architecture
/// for cross-compilation is SSH-discovered (`uname -m`) at preflight,
/// not declared in the inventory.
///
/// Construct it however you want — read TOML, hit Consul, hardcode it,
/// shell out. The function is just async Rust:
///
/// ```ignore
/// #[derive(serde::Serialize, serde::Deserialize)]
/// struct VyosVars { eth0_mac: String, vrrp_priority: u8 }
///
/// #[cindy::inventory]
/// async fn inventory() -> cindy::Result<cindy::Inventory<VyosVars>> {
///     Ok(cindy::Inventory {
///         hosts: vec![
///             cindy::Host {
///                 name: "vyos-01".into(),
///                 tags: vec!["router".into(), "eu".into()],
///                 vars: VyosVars { eth0_mac: "10:66:6a:1a:30:ec".into(), vrrp_priority: 200 },
///             },
///         ],
///     })
/// }
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(crate = "cindy::__reexports::serde")]
pub struct Inventory<V = ()> {
    pub hosts: Vec<Host<V>>,
}

/// Conversion glue so `#[cindy::inventory]` can accept *either*
/// `Inventory<V>` or `Result<Inventory<V>, E>` and always emit the same
/// JSON shape on the wire.
///
/// The infallible impl just serialises straight through. The fallible
/// impl unwraps to stdout-as-JSON on `Ok`, prints the debug rendering
/// of the error to stderr and exits 1 on `Err`. Either way the CLI
/// reads a clean `{"hosts": [...]}` from FD 1 — no `{"Ok": {...}}`
/// wrapper for it to peel.
#[doc(hidden)]
pub trait IntoInventoryDump {
    fn into_inventory_dump(self) -> serde_json::Value;
}

impl<V: serde::Serialize> IntoInventoryDump for Inventory<V> {
    fn into_inventory_dump(self) -> serde_json::Value {
        serde_json::to_value(&self).expect("Failed to serialise `Inventory<V>` to JSON")
    }
}

impl<V: serde::Serialize, E: std::fmt::Debug> IntoInventoryDump
    for ::std::result::Result<Inventory<V>, E>
{
    fn into_inventory_dump(self) -> serde_json::Value {
        match self {
            Ok(inv) => {
                serde_json::to_value(&inv).expect("Failed to serialise `Inventory<V>` to JSON")
            }
            Err(e) => {
                ::std::eprintln!("`#[cindy::inventory]` failed: {e:?}");
                ::std::process::exit(1);
            }
        }
    }
}

/// Inventory-crate slot that `#[cindy::inventory]` registers into.
///
/// The macro stores a thunk that builds the user's inventory and
/// serialises the result to a `serde_json::Value`. The
/// `#[cindy::main]`-generated entry walks `inventory::iter::<RegisteredInventory>`
/// when `CINDY_DUMP_INVENTORY=1` is set, finds the (unique) registered
/// inventory, executes it on the current tokio runtime, and writes the
/// JSON to the original FD 1 for the CLI to consume.
///
/// Multiple registrations are a runtime error — the macro doesn't try
/// to merge inventories.
#[doc(hidden)]
pub struct RegisteredInventory {
    pub dump:
        fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = serde_json::Value> + Send>>,
}
cindy::__reexports::inventory::collect!(RegisteredInventory);

/// A single deployment target as the orchestrator sees it at run-time.
///
/// `Host<V>` is the runtime view that the `#[cindy::main]`-generated entry
/// hands to your `main`. The generic `V` is *your* per-host vars struct
/// — anything `Serialize + DeserializeOwned`. The CLI itself never
/// names `V`; it only deals with the structural fields (`name`,
/// `tags`) and treats `vars` as an opaque JSON blob that round-trips
/// through `serde_json::Value`. That keeps the CLI ignorant of your
/// project's types while still letting your code reach typed vars.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(crate = "cindy::__reexports::serde")]
pub struct Host<V = ()> {
    /// Operator-facing host name. Whatever the CLI calls this target —
    /// typically the SSH host alias.
    pub name: String,

    /// Free-form labels for filtering via `--limit`. Pure targeting
    /// metadata — no implicit vars resolution / no inheritance.
    /// Composition of per-host vars is done explicitly in the
    /// (eventual) `#[cindy::inventory]` function with normal Rust.
    #[serde(default)]
    pub tags: Vec<String>,

    /// Whatever per-host typed data your inventory wants to surface.
    /// Round-trips through JSON between the CLI and the orchestrator
    /// process; the CLI never deserialises it past
    /// `serde_json::Value`.
    pub vars: V,
}

impl<V> Host<V> {
    /// Convenience: `host.has_tag("eu")` instead of
    /// `host.tags.iter().any(|t| t == "eu")`.
    pub fn has_tag(&self, tag: &str) -> bool {
        self.tags.iter().any(|t| t == tag)
    }
}