znippy-plugin-skidbladnir 0.1.0

Skidbladnir airgap-bundle metadata plugin for znippy (native builtin — no WASM)
//! Bundle-member classification.
//!
//! A Skidbladnir airgap bundle is a fixed directory layout (see
//! `.nornir/znippy-plugin-skidbladnir.md`). Every entry belongs to exactly one
//! [`MemberKind`], decided **purely from its relative path** — no file read, no
//! parsing. This is the whole ownership rule the plugin's `matches_path` and
//! `extract_metadata` lean on, so it lives in one pure, exhaustively-tested fn.

/// One payload member's role inside the bundle. The discriminant strings are the
/// values written into the `member_kind` Arrow column (queryable via DuckDB).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberKind {
    /// `lakespec.json` — products + dependency order + pki source (the bring-up spec).
    Lakespec,
    /// `manifest/*` — bundle.json (version · scope · watermark) + consistency.json.
    Manifest,
    /// `dbdump/*` — atomic pg_dump stream (postgres).
    DbDump,
    /// `s3/*` — document object blobs (minio); `dokument.fil_nyckel`.
    S3Blob,
    /// `app/*` (oci tar) — the container **image itself**, embedded (airgap; `podman load`).
    Image,
    /// `app/*.imageref` / `*.ref` — an image **pointer** (registry ref + digest);
    /// the far side pulls it (the `plain` scope tier — needs the registry).
    ImageRef,
    /// `infra/terraform/*` — provisioning config for the Terraform unfold backend.
    InfraTerraform,
    /// `infra/redfish/*` — BMC bring-up spec for the Redfish unfold backend.
    InfraRedfish,
    /// `infra/kvm/*` — libvirt/KVM domain definition.
    InfraKvm,
    /// `infra/talos/*.iso` — Talos boot media.
    TalosIso,
    /// `infra/talos/*` (non-iso) — talosctl machineconfig (cluster bootstrap).
    TalosMachineConfig,
    /// `njordconf/*` — declarative app config (serverspec/services).
    Config,
    /// `njordconf/**/*.tgz` — a packaged Helm chart (the `platform`-tier installer:
    /// a fresh Tillsynia self-migrates on boot, so chart+values is all it needs).
    HelmChart,
    /// `njordconf/values*.yaml` — Helm values overlay for the chart.
    HelmValues,
    /// `secrets/*` or any `*.age` — encrypted secret material.
    Secret,
}

impl MemberKind {
    /// The stable string written to the `member_kind` column.
    pub fn as_str(self) -> &'static str {
        match self {
            MemberKind::Lakespec => "lakespec",
            MemberKind::Manifest => "manifest",
            MemberKind::DbDump => "dbdump",
            MemberKind::S3Blob => "s3-blob",
            MemberKind::Image => "image",
            MemberKind::ImageRef => "image-ref",
            MemberKind::InfraTerraform => "infra-terraform",
            MemberKind::InfraRedfish => "infra-redfish",
            MemberKind::InfraKvm => "infra-kvm",
            MemberKind::TalosIso => "talos-iso",
            MemberKind::TalosMachineConfig => "talos-machineconfig",
            MemberKind::Config => "config",
            MemberKind::HelmChart => "helm-chart",
            MemberKind::HelmValues => "helm-values",
            MemberKind::Secret => "secret",
        }
    }
}

/// Normalise a relative path to `/`-separated components, dropping a leading
/// `./` and any empty segments. Windows `\` is tolerated.
fn segments(path: &str) -> Vec<&str> {
    path.split(['/', '\\']).filter(|s| !s.is_empty() && *s != ".").collect()
}

/// Classify a bundle entry by its relative path. `None` = not a recognised
/// bundle member (the plugin then does not claim it). Pure and total.
pub fn classify(path: &str) -> Option<MemberKind> {
    // A `.age` anywhere is encrypted secret material regardless of directory.
    if path.ends_with(".age") {
        return Some(MemberKind::Secret);
    }
    let segs = segments(path);
    let top = *segs.first()?;

    match top {
        "lakespec.json" => Some(MemberKind::Lakespec),
        "manifest" => Some(MemberKind::Manifest),
        "dbdump" => Some(MemberKind::DbDump),
        "s3" => Some(MemberKind::S3Blob),
        "app" => {
            if path.ends_with(".imageref") || path.ends_with(".ref") {
                Some(MemberKind::ImageRef)
            } else {
                Some(MemberKind::Image)
            }
        }
        "njordconf" => {
            let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
            if path.ends_with(".tgz") {
                Some(MemberKind::HelmChart)
            } else if name.starts_with("values")
                && (name.ends_with(".yaml") || name.ends_with(".yml"))
            {
                Some(MemberKind::HelmValues)
            } else {
                Some(MemberKind::Config)
            }
        }
        "secrets" => Some(MemberKind::Secret),
        "infra" => match segs.get(1).copied() {
            Some("terraform") => Some(MemberKind::InfraTerraform),
            Some("redfish") => Some(MemberKind::InfraRedfish),
            Some("kvm") => Some(MemberKind::InfraKvm),
            Some("talos") => {
                if path.ends_with(".iso") {
                    Some(MemberKind::TalosIso)
                } else {
                    Some(MemberKind::TalosMachineConfig)
                }
            }
            _ => None,
        },
        _ => None,
    }
}

/// The logical Njord component a member belongs to (the `component` column).
/// `None` for bundle-level members (lakespec/manifest) that span all components.
pub fn component_of(kind: MemberKind, path: &str) -> Option<&'static str> {
    match kind {
        MemberKind::DbDump => Some("postgres"),
        MemberKind::S3Blob => Some("minio"),
        MemberKind::Image | MemberKind::ImageRef => {
            // Derive the component from the image filename (embedded or ref).
            let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
            if name.contains("mail-server") {
                Some("mail-server")
            } else if name.contains("web") {
                Some("web")
            } else {
                Some("app")
            }
        }
        MemberKind::InfraTerraform
        | MemberKind::InfraRedfish
        | MemberKind::InfraKvm
        | MemberKind::TalosIso => Some("infra"),
        MemberKind::TalosMachineConfig => Some("cluster"),
        MemberKind::Config => Some("config"),
        MemberKind::HelmChart | MemberKind::HelmValues => Some("platform"),
        MemberKind::Secret => Some("secrets"),
        MemberKind::Lakespec | MemberKind::Manifest => None,
    }
}

/// Is this member encrypted-at-rest inside the bundle?
pub fn is_encrypted(kind: MemberKind, path: &str) -> bool {
    kind == MemberKind::Secret || path.ends_with(".age")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_every_top_level_dir() {
        let cases = [
            ("lakespec.json", MemberKind::Lakespec),
            ("manifest/bundle.json", MemberKind::Manifest),
            ("manifest/consistency.json", MemberKind::Manifest),
            ("dbdump/njord.dump", MemberKind::DbDump),
            ("s3/2026/07/protokoll-123.pdf", MemberKind::S3Blob),
            ("app/njord-web.oci.tar", MemberKind::Image),
            ("app/njord-mail-server.oci.tar", MemberKind::Image),
            ("app/njord-web.imageref", MemberKind::ImageRef),
            ("app/njord-mail-server.ref", MemberKind::ImageRef),
            ("infra/terraform/main.tf", MemberKind::InfraTerraform),
            ("infra/redfish/bmc.json", MemberKind::InfraRedfish),
            ("infra/kvm/domain.xml", MemberKind::InfraKvm),
            ("infra/talos/talos-1.13.iso", MemberKind::TalosIso),
            ("infra/talos/machineconfig.yaml", MemberKind::TalosMachineConfig),
            ("njordconf/serverspec.json", MemberKind::Config),
            ("njordconf/chart/njord-0.1.0.tgz", MemberKind::HelmChart),
            ("njordconf/values-lab.yaml", MemberKind::HelmValues),
            ("secrets/secrets.age", MemberKind::Secret),
        ];
        for (p, want) in cases {
            assert_eq!(classify(p), Some(want), "classify({p})");
        }
    }

    #[test]
    fn age_anywhere_is_secret() {
        assert_eq!(classify("njordconf/values.age"), Some(MemberKind::Secret));
        assert!(is_encrypted(MemberKind::Secret, "secrets/x"));
        assert!(is_encrypted(MemberKind::Config, "njordconf/v.age"));
        assert!(!is_encrypted(MemberKind::Config, "njordconf/serverspec.json"));
    }

    #[test]
    fn unknown_paths_are_not_claimed() {
        assert_eq!(classify("README.md"), None);
        assert_eq!(classify("infra/unknown/x"), None);
        assert_eq!(classify(""), None);
        assert_eq!(classify("./s3/./doc.pdf"), Some(MemberKind::S3Blob));
    }

    #[test]
    fn component_mapping() {
        assert_eq!(component_of(MemberKind::DbDump, "dbdump/x.dump"), Some("postgres"));
        assert_eq!(component_of(MemberKind::S3Blob, "s3/x.pdf"), Some("minio"));
        assert_eq!(component_of(MemberKind::Image, "app/njord-web.oci.tar"), Some("web"));
        assert_eq!(component_of(MemberKind::ImageRef, "app/njord-web.imageref"), Some("web"));
        assert_eq!(
            component_of(MemberKind::Image, "app/njord-mail-server.oci.tar"),
            Some("mail-server")
        );
        assert_eq!(component_of(MemberKind::TalosMachineConfig, "infra/talos/mc.yaml"), Some("cluster"));
        assert_eq!(component_of(MemberKind::Lakespec, "lakespec.json"), None);
    }
}