amont-runtime 1.47.3

The amont hook logic: registry, dispatchers, checks and the trust model
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! The three Kubernetes hooks: argo-lint, kube-linter, kubeconform.
//!
//! All are SOFT gates — a missing toolchain warns and skips rather than
//! blocking a commit, because CI is the hard gate and not every developer has
//! kustomize/kubeconform/argo installed.

use super::common::{fail, hl, ok, program, repo_root, staged_files, warn, which};
use crate::check::Outcome;
use std::path::Path;
use std::process::{Command, Stdio};

/// The extensions all three Kubernetes checks consume. Exported so
/// `registry.rs` declares their scopes from the same constant — see
/// `lint_json_yaml::EXTS` for the drift this prevents.
pub const EXTS: &[&str] = &[".yaml", ".yml"];

/// Staged YAML under a kubernetes-ish prefix. Deliberately conservative and
/// shared by all three hooks, so they trigger on exactly the same change sets.
fn k8s_staged() -> Vec<String> {
    const PREFIXES: [&str; 7] = [
        "kubernetes/",
        "manifests/",
        "chart/",
        "charts/",
        "k8s/",
        "helm/",
        "deploy/",
    ];
    staged_files(EXTS)
        .into_iter()
        .filter(|f| PREFIXES.iter().any(|p| f.starts_with(p)))
        .collect()
}

const ARGO_KINDS: [&str; 4] = [
    "Workflow",
    "CronWorkflow",
    "WorkflowTemplate",
    "ClusterWorkflowTemplate",
];

/// `^kind: <one of the Argo kinds>$` — anchored per line, so a `kind:` nested
/// in a template body or a longer word does not qualify.
pub fn declares_argo_kind(content: &str) -> bool {
    content.lines().any(|line| {
        line.strip_prefix("kind: ")
            .map(|k| ARGO_KINDS.contains(&k.trim_end()))
            .unwrap_or(false)
    })
}

pub fn argo_lint(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
    let staged = k8s_staged();
    if staged.is_empty() {
        return Outcome::Passed;
    }
    let root = repo_root();
    let workflows: Vec<String> = staged
        .into_iter()
        .filter(|file| {
            std::fs::read_to_string(Path::new(&root).join(file))
                .map(|c| declares_argo_kind(&c))
                .unwrap_or(false)
        })
        .collect();
    if workflows.is_empty() {
        return Outcome::Passed;
    }
    if which("argo").is_none() {
        warn(&format!(
            "Argo workflow manifests staged. Skipping argo lint; install: {}",
            hl("argo")
        ));
        return Outcome::Unavailable;
    }
    // --offline: no cluster needed, inline templates only. Flux ${VAR}
    // postBuild placeholders are inert strings to the linter; Argo {{…}}
    // templating is what it actually checks.
    // `--` before the paths: a workflow file named e.g. `-canary.yaml` would
    // otherwise be read as a flag by argo's own parser.
    let mut argv = vec![
        "lint".to_string(),
        "--offline".to_string(),
        "--".to_string(),
    ];
    argv.extend(workflows.iter().cloned());
    let mut cmd = Command::new(program("argo"));
    cmd.args(&argv).current_dir(&root).stdin(Stdio::null());
    let okd = super::common::bounded_success(settings, &mut cmd, "argo");
    if !okd {
        fail("argo lint failed (output above)");
        return Outcome::Failed;
    }
    let n = workflows.len();
    ok(
        settings,
        &format!(
            "argo lint passed ({n} workflow manifest{})",
            if n > 1 { "s" } else { "" }
        ),
    );
    Outcome::Passed
}

/// Repo-root `.kube-linter*.yaml` / `.yml`, sorted for a stable run order.
pub fn kube_linter_configs(root: &str) -> Vec<String> {
    let Ok(rd) = std::fs::read_dir(root) else {
        return Vec::new();
    };
    let mut out: Vec<String> = rd
        .flatten()
        .filter_map(|e| e.file_name().into_string().ok())
        .filter(|n| n.starts_with(".kube-linter") && (n.ends_with(".yaml") || n.ends_with(".yml")))
        .collect();
    out.sort();
    out
}

pub fn kube_linter(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
    if k8s_staged().is_empty() {
        return Outcome::Passed;
    }
    let root = repo_root();
    // Stock kube-linter rules are too noisy to enforce generically, so a
    // repo-local config is the opt-in signal — and it is tested before the
    // binary, so a repo that never opted in is not told to install a linter it
    // does not use, and does not report a gap it does not have.
    let configs = kube_linter_configs(&root);
    if configs.is_empty() {
        // SILENT, like `yamllint::run` in exactly this situation. It used to
        // print a skip notice, which fires on EVERY commit that touches
        // `kubernetes/**.yaml` in a repository that has no `.kube-linter*.yaml`
        // and never will — the same "a repo that never wanted yamllint was told
        // to install it" noise `docs/hook-architecture.md` records these three
        // checks being fixed for.
        return Outcome::Passed;
    }
    if which("kube-linter").is_none() {
        warn(&format!(
            "This repo configures kube-linter but it is not installed. Install {}",
            hl("kube-linter")
        ));
        return Outcome::Unavailable;
    }
    // One run per config: each config's own `excludes:` and scope (set inside
    // the YAML, not on the CLI) decide which manifests it applies to, so
    // apps-vs-infra splits work without per-hook wiring.
    let mut overall = 0;
    for cfg in &configs {
        let mut cmd = Command::new(program("kube-linter"));
        cmd.args(["lint", ".", "--config", cfg])
            .current_dir(&root)
            .stdin(Stdio::null());
        let okd = super::common::bounded_success(settings, &mut cmd, "kube-linter");
        if !okd {
            fail(&format!("kube-linter ({cfg}) found issues"));
            overall = 1;
        }
    }
    if overall != 0 {
        return Outcome::Failed;
    }
    let n = configs.len();
    ok(
        settings,
        &format!(
            "kube-linter passed ({n} config{})",
            if n > 1 { "s" } else { "" }
        ),
    );
    Outcome::Passed
}

/// Walk up from each staged file until a directory holding one of `markers` is
/// found, or the repo root is reached. Deduped, stable order.
///
/// Generalised from `kustomization_roots` when `helm` needed the identical walk
/// for `Chart.yaml`. One walk with two callers rather than two walks: the
/// subtle parts — stopping at the repo root, deduping so ten files under one
/// directory yield one root, keeping the order stable so a golden render can
/// pin it — are the same for every marker, and a second copy is a second place
/// for them to drift.
pub fn marker_roots(root: &str, staged: &[String], markers: &[&str]) -> Vec<String> {
    let mut roots: Vec<String> = Vec::new();
    for f in staged {
        let mut dir = Path::new(f).parent();
        while let Some(d) = dir {
            let s = d.to_string_lossy().to_string();
            if s.is_empty() || s == "." {
                break;
            }
            let base = Path::new(root).join(&s);
            if markers.iter().any(|m| base.join(m).is_file()) {
                if !roots.contains(&s) {
                    roots.push(s);
                }
                break;
            }
            dir = d.parent();
        }
    }
    roots
}

/// The kustomize marker set. Kept as its own function so `k8s`'s callers and
/// their tests are untouched by the generalisation above.
pub fn kustomization_roots(root: &str, staged: &[String]) -> Vec<String> {
    marker_roots(root, staged, &["kustomization.yaml", "kustomization.yml"])
}

/// Kinds from a repo-local `.kubeconform-skip` (one per line, `#` comments) —
/// the escape hatch for the day someone vendors local CRD schemas.
pub fn skip_kinds(content: &str) -> Vec<String> {
    content
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .map(|l| l.replace(' ', ""))
        .collect()
}

pub fn kubeconform(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
    let staged = k8s_staged();
    if staged.is_empty() {
        return Outcome::Passed;
    }
    let root = repo_root();
    let roots = kustomization_roots(&root, &staged);
    // Raw-YAML validation is out of scope: a project either uses kustomize or
    // it does not — and that, not the toolbox, is what decides whether this
    // check had anything to do.
    if roots.is_empty() {
        return Outcome::Passed;
    }
    let missing: Vec<&str> = ["kustomize", "kubeconform"]
        .into_iter()
        .filter(|t| which(t).is_none())
        .collect();
    if !missing.is_empty() {
        warn(&format!(
            "Kustomizations staged. Skipping kubeconform; install: {}",
            hl(&missing.join(", "))
        ));
        return Outcome::Unavailable;
    }

    let skip = std::fs::read_to_string(Path::new(&root).join(".kubeconform-skip"))
        .ok()
        .map(|c| skip_kinds(&c))
        .filter(|k| !k.is_empty())
        .map(|k| k.join(","));

    let mut overall = 0;
    for r in &roots {
        if !validate_root(settings, &root, r, skip.as_deref()) {
            fail(&format!("kubeconform failed for {r}"));
            overall = 1;
        }
    }
    if overall != 0 {
        return Outcome::Failed;
    }
    let n = roots.len();
    ok(
        settings,
        &format!(
            "kubeconform passed ({n} kustomization root{})",
            if n > 1 { "s" } else { "" }
        ),
    );
    Outcome::Passed
}

/// `kustomize build <root> | kubeconform …`, with the shell's `pipefail`
/// semantics: a kustomize failure fails the check even when kubeconform would
/// happily consume the empty input.
fn validate_root(
    settings: &crate::config::Settings,
    root: &str,
    sub: &str,
    skip: Option<&str>,
) -> bool {
    // `--` before `sub`: it is a directory name walked up from staged paths,
    // so a kustomization root named e.g. `-overlay` would otherwise be read
    // as a flag by kustomize's own (cobra) parser.
    // `--load-restrictor LoadRestrictionsNone`, because the question this check
    // answers is "will the cluster accept what Flux applies", and
    // kustomize-controller loads relative to the SOURCE root rather than the
    // kustomization directory. kustomize's CLI default refuses a `../` reference
    // that leaves the root, so a root the controller renders happily fails here
    // with `security; file ... is not in or below ...` — and since a build
    // failure fails the check, the effect is that such a directory cannot be
    // committed to at all.
    //
    // Real example: a repository where `apps/stalwart-secrets/` is built almost
    // entirely from `../stalwart/*.yaml`. Flux applies it every ten minutes; the
    // CLI cannot build it; and the repository's CI had grown a whitelist for
    // that exact error string, so the directory was silently validated by
    // nothing while every commit touching it was blocked locally.
    //
    // The relaxation is what the controller already does with the same files, so
    // it widens nothing the cluster does not already permit.
    let Ok(mut build) = Command::new(program("kustomize"))
        .args([
            "build",
            "--load-restrictor",
            "LoadRestrictionsNone",
            "--",
            sub,
        ])
        .current_dir(root)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .spawn()
    else {
        return false;
    };
    let Some(out) = build.stdout.take() else {
        return false;
    };

    // CRDs are deliberately NOT validated against an external catalog: the
    // datree CRDs-catalog is unmaintained (Datree is EOL) and its cilium schema
    // typed CIDR fields IPv4-only, false-failing IPv6 policies. Kyverno CLI,
    // Trivy and each operator's admission webhook cover them instead.
    let mut argv = vec![
        "--strict",
        "--ignore-missing-schemas",
        "--schema-location",
        "default",
    ];
    if let Some(s) = skip {
        argv.push("--skip");
        argv.push(s);
    }
    argv.push("--summary");
    argv.push("-");

    let mut cmd = Command::new(program("kubeconform"));
    cmd.args(&argv).current_dir(root).stdin(Stdio::from(out));
    let conform = super::common::bounded_success(settings, &mut cmd, "kubeconform");
    // kubeconform's deadline closes the pipe, which normally ends kustomize
    // too — but a kustomize hung BEFORE writing (a remote base fetching over
    // the network, say) never feels the pipe close, and an unbounded wait
    // here would inherit its hang. Same clock, and a kill on expiry.
    let built = matches!(
        super::common::wait_within(&mut build, super::common::check_timeout(settings), 0, None),
        Ok(super::common::Ran::Status(s)) if s.success()
    );
    built && conform
}

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

    fn test_settings() -> crate::config::Settings {
        crate::config::Settings::default()
    }

    #[test]
    fn recognises_every_argo_kind_and_nothing_else() {
        for k in ARGO_KINDS {
            assert!(
                declares_argo_kind(&format!("apiVersion: x\nkind: {k}\n")),
                "{k}"
            );
        }
        assert!(!declares_argo_kind("kind: Deployment\n"));
        assert!(!declares_argo_kind("kind: WorkflowSomethingElse\n"));
        assert!(!declares_argo_kind("  kind: Workflow\n")); // must be anchored
    }

    #[test]
    fn parses_the_skip_list() {
        let c = "# comment\nCiliumNetworkPolicy\n\n  Foo Bar \n";
        assert_eq!(skip_kinds(c), vec!["CiliumNetworkPolicy", "FooBar"]);
        assert!(skip_kinds("# only a comment\n").is_empty());
    }

    /// A root built from files ABOVE it must validate.
    ///
    /// kustomize's CLI default refuses `../` out of the root; kustomize-controller
    /// does not, so a directory Flux applies every ten minutes failed here — and
    /// because a build failure fails the check, no commit touching that directory
    /// could be made at all. Needs the real binaries; skipped without them, the
    /// same soft-tool rule the check itself follows.
    #[test]
    fn a_root_that_reaches_above_itself_still_validates() {
        if which("kustomize").is_none() || which("kubeconform").is_none() {
            return;
        }
        let tmp = std::env::temp_dir().join(format!("amont-loadrestrictor-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&tmp);
        let shared = tmp.join("shared");
        let overlay = tmp.join("overlay");
        std::fs::create_dir_all(&shared).unwrap();
        std::fs::create_dir_all(&overlay).unwrap();
        std::fs::write(
            shared.join("namespace.yaml"),
            "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: demo\n",
        )
        .unwrap();
        // The shape that fails without the relaxation: every resource lives in a
        // sibling directory.
        std::fs::write(
            overlay.join("kustomization.yaml"),
            "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n  - ../shared/namespace.yaml\n",
        )
        .unwrap();

        let root = tmp.to_string_lossy().to_string();
        assert!(
            validate_root(&test_settings(), &root, "overlay", None),
            "a kustomization built from ../shared must validate — kustomize-controller renders it"
        );
        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn walks_up_to_the_nearest_kustomization_root() {
        let tmp = std::env::temp_dir().join("amont-kustomize-test");
        let _ = std::fs::remove_dir_all(&tmp);
        let base = tmp.join("kubernetes/app/base");
        std::fs::create_dir_all(&base).unwrap();
        std::fs::write(base.join("kustomization.yaml"), "resources: []").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let found = kustomization_roots(&root, &["kubernetes/app/base/deploy.yaml".into()]);
        assert_eq!(found, vec!["kubernetes/app/base".to_string()]);

        // nothing above it → no root, and therefore nothing to validate
        let none = kustomization_roots(&root, &["kubernetes/loose/deploy.yaml".into()]);
        assert!(none.is_empty());
        let _ = std::fs::remove_dir_all(&tmp);
    }
}