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()
}