Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

xtask/src/deploy_check.rs

//! `cargo xtask deploy-check`: the deployment manifests, asserted (roadmap
//! phase 5).
//!
//! `deploy/` is the "cloud and own iron" half of the roadmap. YAML that nobody
//! checks is documentation, and documentation drifts — so every claim
//! `deploy/kubernetes/*.yaml` and `deploy/systemd/*.service` make about how
//! gunnar is deployed is a rule here, and the rules are asserted on every `ci`
//! run.
//!
//! # This is a scanner, not a YAML parser, and that is a deliberate limit
//!
//! `xtask` has **zero dependencies** on purpose (see its manifest), so there is
//! no `serde_yaml` here and there is not going to be one. What this does is
//! read the documents as text and check for the presence and absence of exact
//! strings, in named blocks. That is weaker than parsing, and the weakness is
//! recorded rather than papered over: a check can be evaded by writing the same
//! YAML a different way (flow style, a different quote, an anchor).
//!
//! It is still worth having, because the failures it catches are the ones that
//! actually happen — somebody edits a digest to a tag to test something and
//! forgets, somebody adds `privileged: true` to make a build work, somebody
//! moves a key into the ConfigMap because the Secret was inconvenient. Those
//! are edits, not adversaries.
//!
//! # Every check is proved to bite
//!
//! [`Injury`] is the same idea as `installtest`'s `Fault`: a deliberate defect
//! applied to a copy of the manifest in memory. `xtask/tests/deploy_check.rs`
//! applies each one and fails if the check comes out green. A checker that
//! cannot report red is worth nothing, and this repository has shipped several
//! that could not.

use std::fmt::Write as _;
use std::path::Path;

/// Where the manifests live, relative to the workspace root.
pub const DIR: &str = "deploy";

/// A deliberate defect, for proving a check bites.
///
/// Applied as a text substitution on the manifest before it is checked, which
/// is exactly how the defect would arrive in real life: somebody edited a line.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Injury {
    /// No defect: the manifests must PASS.
    None,
    /// Swap the pinned digest for a floating tag.
    FloatingTag,
    /// Turn the data volume into an `emptyDir` — a transparency log that is
    /// forgotten on every reschedule.
    EphemeralData,
    /// Move the KEK into the ConfigMap.
    KeyInConfigMap,
    /// Let the server run privileged.
    Privileged,
    /// Drop `runAsNonRoot`.
    RunAsRoot,
    /// Take the readiness probe away.
    NoReadiness,
    /// Let the systemd unit put the KEK in an `Environment=` line, where
    /// `systemctl show` prints it to any user on the box.
    KekInUnitFile,
}

impl Injury {
    /// Every injury, for the red sweep.
    pub const ALL: &'static [Injury] = &[
        Injury::FloatingTag,
        Injury::EphemeralData,
        Injury::KeyInConfigMap,
        Injury::Privileged,
        Injury::RunAsRoot,
        Injury::NoReadiness,
        Injury::KekInUnitFile,
    ];

    /// The name that appears in the red sweep's output.
    pub fn label(self) -> &'static str {
        match self {
            Injury::None => "none",
            Injury::FloatingTag => "floating-tag",
            Injury::EphemeralData => "ephemeral-data",
            Injury::KeyInConfigMap => "key-in-configmap",
            Injury::Privileged => "privileged",
            Injury::RunAsRoot => "run-as-root",
            Injury::NoReadiness => "no-readiness",
            Injury::KekInUnitFile => "kek-in-unit-file",
        }
    }
}

/// The manifests, as text, with an injury applied.
///
/// Read once and injured in memory: a checker that wrote a broken manifest to
/// disk to test itself would be one crash away from leaving it there.
#[derive(Debug, Clone)]
pub struct Manifests {
    pub server: String,
    pub unit: String,
}

impl Manifests {
    /// Read them from `root/deploy`.
    pub fn read(root: &Path) -> Result<Manifests, String> {
        let at = |parts: &[&str]| -> Result<String, String> {
            let mut path = root.join(DIR);
            for part in parts {
                path = path.join(part);
            }
            std::fs::read_to_string(&path).map_err(|err| format!("{}: {err}", path.display()))
        };
        Ok(Manifests {
            server: at(&["kubernetes", "gunnar.yaml"])?,
            unit: at(&["systemd", "gunnar.service"])?,
        })
    }

    /// The same manifests with one defect introduced.
    pub fn injured(&self, injury: Injury) -> Manifests {
        let mut out = self.clone();
        match injury {
            Injury::None => {}
            Injury::FloatingTag => {
                out.server = out.server.replace(
                    "image: ghcr.io/example/gunnar@sha256:",
                    "image: ghcr.io/example/gunnar:latest # was sha256:",
                );
            }
            Injury::EphemeralData => {
                out.server = out
                    .server
                    .replace("  volumeClaimTemplates:", "  volumeClaimTemplatesRemoved:");
            }
            Injury::KeyInConfigMap => {
                out.server = out.server.replace(
                    "  GUNNAR_DATA_ROOT: \"/var/lib/gunnar\"",
                    "  GUNNAR_DATA_ROOT: \"/var/lib/gunnar\"\n  GUNNAR_SECRET_KEK: \"deadbeef\"",
                );
            }
            Injury::Privileged => {
                out.server = out.server.replace(
                    "            allowPrivilegeEscalation: false",
                    "            privileged: true",
                );
            }
            Injury::RunAsRoot => {
                out.server = out.server.replace("        runAsNonRoot: true\n", "");
            }
            Injury::NoReadiness => {
                out.server = out
                    .server
                    .replace("          readinessProbe:", "          disabledProbe:");
            }
            Injury::KekInUnitFile => {
                out.unit = out.unit.replace(
                    "EnvironmentFile=/etc/gunnar/gunnar.env",
                    "Environment=GUNNAR_SECRET_KEK=deadbeef",
                );
            }
        }
        out
    }
}

/// The lines that are configuration, with the commentary dropped.
///
/// A `#` line is prose in both YAML and a systemd unit, and prose is exactly
/// where this file explains *why* a setting is what it is — including by naming
/// the setting it is not. Scanning it would make every explanation a violation,
/// which is how a checker teaches people to stop writing them.
///
/// Only whole-line comments are dropped. A trailing `# …` after a value stays,
/// because stripping it would need to know whether the `#` is inside a quoted
/// string, and this is a scanner and not a parser.
fn directives(text: &str) -> String {
    text.lines()
        .filter(|line| !line.trim_start().starts_with('#'))
        .collect::<Vec<_>>()
        .join("\n")
}

/// One rule that did not hold.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
    /// Which document.
    pub where_: &'static str,
    /// What is wrong, and why it matters.
    pub what: String,
}

/// Check the manifests. An empty vector is a pass.
///
/// The order is the order an operator would care about: what ships, then where
/// the state lives, then who can read the keys, then what the process can do.
pub fn check(manifests: &Manifests) -> Vec<Violation> {
    // Commentary dropped once, at the top: every rule below is about what the
    // document *does*, and this file's own prose explains settings by naming
    // them.
    let manifests = &Manifests {
        server: directives(&manifests.server),
        unit: directives(&manifests.unit),
    };
    let mut out = Vec::new();
    let mut fail = |where_: &'static str, what: String| out.push(Violation { where_, what });

    // ── 1. the image is pinned by digest ───────────────────────────────────
    //
    // A product whose thesis is "you can prove what this binary was built from"
    // cannot deploy a tag somebody can move under it.
    {
        let (name, text) = ("kubernetes/gunnar.yaml", &manifests.server);
        for line in text.lines() {
            let trimmed = line.trim();
            let Some(reference) = trimmed.strip_prefix("image: ") else {
                continue;
            };
            let reference = reference.split_whitespace().next().unwrap_or("");
            if !reference.contains("@sha256:") {
                fail(
                    name,
                    format!(
                        "`{reference}` is not pinned by digest. A floating tag can be moved \
                         under a running cluster, which makes every provenance claim about \
                         what is deployed unfalsifiable."
                    ),
                );
            }
        }
    }

    // ── 2. the state is persistent ─────────────────────────────────────────
    if !manifests.server.contains("volumeClaimTemplates:") {
        fail(
            "kubernetes/gunnar.yaml",
            "the StatefulSet has no `volumeClaimTemplates`. The transparency log and the \
             forge log live under the data root; on ephemeral storage they are \
             forgotten on every reschedule, and a log that forgets is a cache with a \
             signature on it."
                .to_owned(),
        );
    }
    if let Some(rest) = manifests.server.split("mountPath: /var/lib/gunnar").nth(1) {
        // The data mount must not resolve to the `emptyDir` that exists for
        // `/tmp`. Checked by name, because the volume is what says which it is.
        if !manifests.server.contains("- name: data") {
            fail(
                "kubernetes/gunnar.yaml",
                "the data mount does not name a `data` volume".to_owned(),
            );
        }
        let _ = rest;
    }

    // ── 3. the keys are in the Secret and nowhere else ─────────────────────
    //
    // Scanned by block, because "the string appears in the file" is true of the
    // Secret too — and the Secret is exactly where it belongs.
    for document in manifests.server.split("\n---\n") {
        if !document.contains("kind: ConfigMap") {
            continue;
        }
        if document.contains("GUNNAR_SECRET_KEK") {
            fail(
                "kubernetes/gunnar.yaml",
                "the KEK is in the ConfigMap. Anything with `get configmaps` in the namespace \
                 can read it, and everything gunnar seals is sealed under it."
                    .to_owned(),
            );
        }
    }
    if !manifests.server.contains("kind: Secret") {
        fail(
            "kubernetes/gunnar.yaml",
            "there is no Secret; the KEK has to come from somewhere and a ConfigMap is not it"
                .to_owned(),
        );
    }

    // ── 4. no workload is privileged, and none runs as root ────────────────
    {
        let (name, text) = ("kubernetes/gunnar.yaml", &manifests.server);
        if text.contains("privileged: true") {
            fail(
                name,
                "a container runs privileged. A git server needs no privilege, so a \
                 privileged container is not a requirement here, it is a hole."
                    .to_owned(),
            );
        }
        if !text.contains("runAsNonRoot: true") {
            fail(
                name,
                "`runAsNonRoot: true` is missing. gunnar needs no privilege to serve.".to_owned(),
            );
        }
        if !text.contains("allowPrivilegeEscalation: false") {
            fail(
                name,
                "`allowPrivilegeEscalation: false` is missing".to_owned(),
            );
        }
    }

    // ── 5. the server says when it is ready, and says it truthfully ────────
    //
    // `/healthz` answers without a credential once the HTTP listener is up, so
    // it proves more than a TCP connect does.
    if !manifests.server.contains("readinessProbe:") {
        fail(
            "kubernetes/gunnar.yaml",
            "there is no readiness probe. A pod that is Ready before it serves sends traffic \
             into a listener that is not answering yet."
                .to_owned(),
        );
    } else if !manifests.server.contains("path: /healthz") {
        fail(
            "kubernetes/gunnar.yaml",
            "the readiness probe does not hit `/healthz`. A TCP connect proves `bind(2)` \
             works; `/healthz` proves the HTTP listener answers."
                .to_owned(),
        );
    }

    // ── 6. the unit files keep the secret out of the unit ──────────────────
    {
        let (name, text) = ("systemd/gunnar.service", &manifests.unit);
        if text.contains("Environment=GUNNAR_SECRET_KEK") {
            fail(
                name,
                "the KEK is in an `Environment=` line. `systemctl show` prints those to any \
                 user on the box; it belongs in the 0600 `EnvironmentFile`."
                    .to_owned(),
            );
        }
        if !text.contains("NoNewPrivileges=yes") {
            fail(name, "`NoNewPrivileges=yes` is missing".to_owned());
        }
    }

    // ── 7. the server has the KEK channel ──────────────────────────────────
    if !manifests
        .unit
        .contains("EnvironmentFile=/etc/gunnar/gunnar.env")
    {
        fail(
            "systemd/gunnar.service",
            "no `EnvironmentFile`; the KEK has to come from one".to_owned(),
        );
    }
    out
}

/// Run the check against the manifests on disk, and print the result.
///
/// Returns `true` on a pass.
pub fn run(root: &Path) -> bool {
    let manifests = match Manifests::read(root) {
        Ok(manifests) => manifests,
        Err(why) => {
            eprintln!("xtask deploy-check: {why}");
            return false;
        }
    };
    report(&check(&manifests))
}

/// Print a verdict. `true` on a pass.
pub fn report(violations: &[Violation]) -> bool {
    if violations.is_empty() {
        println!("deploy-check: the manifests hold every rule they claim");
        return true;
    }
    let mut out = String::new();
    for violation in violations {
        let _ = writeln!(out, "  ✗ {}: {}", violation.where_, violation.what);
    }
    eprintln!("deploy-check: {} rule(s) broken\n{out}", violations.len());
    false
}

/// The red sweep: every [`Injury`] must turn the check red.
///
/// Returns the injuries that did **not**, which must be empty.
pub fn red(manifests: &Manifests) -> Vec<Injury> {
    Injury::ALL
        .iter()
        .copied()
        .filter(|injury| check(&manifests.injured(*injury)).is_empty())
        .collect()
}