mirror of
https://github.com/enix/x509-certificate-exporter.git
synced 2026-08-19 12:06:30 +00:00
Two new value maps under `prometheusRules`: - `alertExprOverrides` — alertName -> custom Prometheus expression that replaces the default `expr:`. Closes #253: multi-cluster setups can inject `max by (cluster, …)` aggregations or label filters without having to disableBuiltinAlertGroup and reconstruct everything. - `alertForOverrides` — alertName -> `for:` duration that replaces the default. Schema-validated against Prometheus's duration syntax. Both maps key on bare alert names (no `rulePrefix`). Overrides are taken whole — no merge with the default. Single-line and multi-line strings both render correctly: the template now emits each `expr:` via `| quote` which wraps in a YAML double-quoted scalar (newlines serialise to \n; Prometheus parses the resulting string identically to the multi-line form). Test ratchets added alongside: - test/schema/valid/prometheusrules-alert-overrides.yaml — valid case covering both maps, with a multi-line expr to exercise the quoting path. - test/schema/invalid/prometheusrules-alertforoverrides-bad-duration — paired with .expect.txt, ensures the duration regex rejects natural-language inputs ("30 minutes"). - test/render/prometheusrules-default-alerts.{yaml,expect-pass.txt} — new ratchet under the existing TestHelmRender machinery (extended to support "positive substring" checks via .expect-pass.txt). Locks in the exact 13 alert names the chart ships by default: any rename or accidental removal breaks the test at PR time. Docs: - chart/README.md.gotmpl alert table refreshed — was still on the v3 layout (X509ExporterReadErrors + 3 cert alerts). Now lists all 13. - values.yaml gets the two new map keys with descriptions and commented examples.
316 lines
10 KiB
Go
316 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
|
|
"dagger/x-509-ce/internal/dagger"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// TestHelmRender drives two render-time checks against the chart:
|
|
//
|
|
// 1. POSITIVE — renders the chart with `test/render/all-watch-modes.yaml`
|
|
// (covers every host-path watch mode the chart supports) and verifies
|
|
// cross-template alignment between the rendered ConfigMap's scan
|
|
// paths and the rendered DaemonSet's volume mounts. Catches the
|
|
// class of bugs where the chart's volume mount path and the in-pod
|
|
// scan path disagree on the directory boundary — resolved files
|
|
// end up at the wrong in-pod location, the scan finds nothing, and
|
|
// no `x509_cert_*` series are emitted. Silent for users (no error,
|
|
// just no metrics); e2e catches it eventually in minutes — this
|
|
// check renders the chart in seconds.
|
|
//
|
|
// 2. NEGATIVE — every `test/render/*.yaml` paired with a
|
|
// `<name>.expect.txt` is rendered and MUST fail at template time,
|
|
// with each non-blank, non-comment line of the .expect.txt
|
|
// appearing as a substring in the helm stderr/stdout. Companion to
|
|
// TestHelmFixtures, which covers schema-validation failures (`helm
|
|
// lint`); this one covers template-fail rejections that helm lint
|
|
// surfaces only as `[INFO] Fail:` (exit 0).
|
|
//
|
|
// Alignment rule: for every `paths:` entry in the rendered exporter
|
|
// config, the corresponding required mount is derived as
|
|
// - the parent directory, when the entry is a literal file path; or
|
|
// - the static prefix (segments before the first glob meta), when the
|
|
// entry contains a shell-glob (`*`, `?`, `[`, or `**`).
|
|
// One of the DaemonSet's `volumeMounts[].mountPath` values must equal
|
|
// that required mount, or the check fails.
|
|
func (m *X509Ce) TestHelmRender(ctx context.Context) (string, error) {
|
|
base := dag.Container().
|
|
From(helmImage).
|
|
WithWorkdir("/src").
|
|
WithDirectory("/src/chart", m.Source.Directory("chart")).
|
|
WithDirectory("/src/test/render", m.Source.Directory("test/render"))
|
|
|
|
var report strings.Builder
|
|
|
|
// Positive: render the comprehensive fixture and validate alignment.
|
|
rendered, err := base.
|
|
WithExec([]string{"helm", "template", "release-name", "chart", "--values", "test/render/all-watch-modes.yaml"}).
|
|
Stdout(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("helm template all-watch-modes: %w", err)
|
|
}
|
|
posReport, err := validateMountAlignment(rendered)
|
|
if err != nil {
|
|
return posReport, err
|
|
}
|
|
fmt.Fprintf(&report, "all-watch-modes: %s", posReport)
|
|
|
|
// Negative: every <name>.yaml under test/render/ with a paired
|
|
// <name>.expect.txt MUST cause `helm template` to fail, and each
|
|
// non-blank, non-comment line of the .expect.txt MUST appear as a
|
|
// substring in the combined stderr/stdout.
|
|
renderDir := m.Source.Directory("test/render")
|
|
expectFiles, err := renderDir.Glob(ctx, "*.expect.txt")
|
|
if err != nil {
|
|
return report.String(), fmt.Errorf("globbing render fixtures: %w", err)
|
|
}
|
|
sort.Strings(expectFiles)
|
|
for _, expect := range expectFiles {
|
|
name := strings.TrimSuffix(expect, ".expect.txt")
|
|
yamlPath := "test/render/" + name + ".yaml"
|
|
expectContents, err := m.Source.File("test/render/" + expect).Contents(ctx)
|
|
if err != nil {
|
|
return report.String(), fmt.Errorf("reading %s: %w", expect, err)
|
|
}
|
|
var expects []string
|
|
for _, line := range strings.Split(expectContents, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
expects = append(expects, line)
|
|
}
|
|
|
|
_, err = base.
|
|
WithExec([]string{"helm", "template", "release-name", "chart", "--values", yamlPath}).
|
|
Sync(ctx)
|
|
if err == nil {
|
|
return report.String(), fmt.Errorf("%s: expected helm template to fail, got success", yamlPath)
|
|
}
|
|
var execErr *dagger.ExecError
|
|
if !errors.As(err, &execErr) {
|
|
return report.String(), fmt.Errorf("%s: dagger error: %w", yamlPath, err)
|
|
}
|
|
combined := execErr.Stdout + "\n" + execErr.Stderr
|
|
var missing []string
|
|
for _, e := range expects {
|
|
if !strings.Contains(combined, e) {
|
|
missing = append(missing, e)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return report.String(), fmt.Errorf("%s: rendered failed (good) but stderr/stdout missing expected substrings: %v\n--- combined output ---\n%s",
|
|
yamlPath, missing, combined)
|
|
}
|
|
fmt.Fprintf(&report, "%s: OK (failed with expected substrings)\n", name)
|
|
}
|
|
|
|
// Positive substring check. Each `test/render/<name>.yaml` paired
|
|
// with a `<name>.expect-pass.txt` MUST `helm template` successfully
|
|
// AND every non-blank, non-comment line of the .expect-pass.txt
|
|
// MUST appear as a substring in stdout. Used to ratchet behaviours
|
|
// the chart promises and that would otherwise only surface as silent
|
|
// regressions — for instance the exact set of PrometheusRule alert
|
|
// names emitted with default values.
|
|
expectPassFiles, err := renderDir.Glob(ctx, "*.expect-pass.txt")
|
|
if err != nil {
|
|
return report.String(), fmt.Errorf("globbing render-pass fixtures: %w", err)
|
|
}
|
|
sort.Strings(expectPassFiles)
|
|
for _, expect := range expectPassFiles {
|
|
name := strings.TrimSuffix(expect, ".expect-pass.txt")
|
|
yamlPath := "test/render/" + name + ".yaml"
|
|
expectContents, err := m.Source.File("test/render/" + expect).Contents(ctx)
|
|
if err != nil {
|
|
return report.String(), fmt.Errorf("reading %s: %w", expect, err)
|
|
}
|
|
var expects []string
|
|
for _, line := range strings.Split(expectContents, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
expects = append(expects, line)
|
|
}
|
|
|
|
stdout, err := base.
|
|
WithExec([]string{"helm", "template", "release-name", "chart", "--values", yamlPath}).
|
|
Stdout(ctx)
|
|
if err != nil {
|
|
return report.String(), fmt.Errorf("%s: expected helm template to succeed, got error: %w", yamlPath, err)
|
|
}
|
|
var missing []string
|
|
for _, e := range expects {
|
|
if !strings.Contains(stdout, e) {
|
|
missing = append(missing, e)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return report.String(), fmt.Errorf("%s: rendered successfully but stdout missing expected substrings: %v",
|
|
yamlPath, missing)
|
|
}
|
|
fmt.Fprintf(&report, "%s: OK (passed with expected substrings)\n", name)
|
|
}
|
|
|
|
return report.String(), nil
|
|
}
|
|
|
|
// validateMountAlignment is the alignment-check core, factored out of
|
|
// the Dagger function so it can be unit-tested without spinning up a
|
|
// container.
|
|
func validateMountAlignment(rendered string) (string, error) {
|
|
type bundle struct {
|
|
mounts []string
|
|
paths []string
|
|
}
|
|
bundles := map[string]*bundle{} // dsName → bundle
|
|
|
|
get := func(name string) *bundle {
|
|
if b, ok := bundles[name]; ok {
|
|
return b
|
|
}
|
|
b := &bundle{}
|
|
bundles[name] = b
|
|
return b
|
|
}
|
|
|
|
decoder := yaml.NewDecoder(strings.NewReader(rendered))
|
|
for {
|
|
var raw map[string]any
|
|
err := decoder.Decode(&raw)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("yaml decode: %w", err)
|
|
}
|
|
if len(raw) == 0 {
|
|
continue
|
|
}
|
|
kind, _ := raw["kind"].(string)
|
|
meta, _ := raw["metadata"].(map[string]any)
|
|
name, _ := meta["name"].(string)
|
|
|
|
switch kind {
|
|
case "DaemonSet":
|
|
b := get(name)
|
|
spec, _ := raw["spec"].(map[string]any)
|
|
tmpl, _ := spec["template"].(map[string]any)
|
|
podSpec, _ := tmpl["spec"].(map[string]any)
|
|
containers, _ := podSpec["containers"].([]any)
|
|
for _, c := range containers {
|
|
cm, _ := c.(map[string]any)
|
|
vms, _ := cm["volumeMounts"].([]any)
|
|
for _, vm := range vms {
|
|
vmm, _ := vm.(map[string]any)
|
|
vname, _ := vmm["name"].(string)
|
|
mp, _ := vmm["mountPath"].(string)
|
|
if mp == "" {
|
|
continue
|
|
}
|
|
// Only consider host-path-related mounts.
|
|
if strings.HasPrefix(vname, "dir-") ||
|
|
strings.HasPrefix(vname, "file-") ||
|
|
strings.HasPrefix(vname, "kube-") {
|
|
b.mounts = append(b.mounts, path.Clean(mp))
|
|
}
|
|
}
|
|
}
|
|
case "ConfigMap":
|
|
// The DaemonSet's ConfigMap is named the same as the DaemonSet.
|
|
b := get(name)
|
|
data, _ := raw["data"].(map[string]any)
|
|
cfgStr, _ := data["config.yaml"].(string)
|
|
if cfgStr == "" {
|
|
continue
|
|
}
|
|
var cfg map[string]any
|
|
if err := yaml.Unmarshal([]byte(cfgStr), &cfg); err != nil {
|
|
return "", fmt.Errorf("nested config.yaml in %q: %w", name, err)
|
|
}
|
|
sources, _ := cfg["sources"].([]any)
|
|
for _, s := range sources {
|
|
sm, _ := s.(map[string]any)
|
|
// Both `file` and `kubeconfig` sources read from on-host
|
|
// paths that the chart binds via volume mounts; both
|
|
// participate in the alignment check.
|
|
kind, _ := sm["kind"].(string)
|
|
if kind != "file" && kind != "kubeconfig" {
|
|
continue
|
|
}
|
|
ps, _ := sm["paths"].([]any)
|
|
for _, p := range ps {
|
|
if ps, ok := p.(string); ok {
|
|
b.paths = append(b.paths, path.Clean(ps))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stable iteration for deterministic output.
|
|
var names []string
|
|
for n := range bundles {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
var fails []string
|
|
var ok int
|
|
for _, name := range names {
|
|
b := bundles[name]
|
|
if len(b.paths) == 0 {
|
|
continue // ConfigMap without scan paths (e.g. secretsExporter-only releases).
|
|
}
|
|
if len(b.mounts) == 0 {
|
|
fails = append(fails, fmt.Sprintf("%s: configmap has %d scan path(s) but daemonset has 0 host-path volume mounts", name, len(b.paths)))
|
|
continue
|
|
}
|
|
mountSet := map[string]bool{}
|
|
for _, mp := range b.mounts {
|
|
mountSet[mp] = true
|
|
}
|
|
for _, p := range b.paths {
|
|
req := requiredMount(p)
|
|
if !mountSet[req] {
|
|
fails = append(fails, fmt.Sprintf("%s: scan path %q requires mountPath %q\n available mounts: %v", name, p, req, b.mounts))
|
|
} else {
|
|
ok++
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(fails) > 0 {
|
|
// Bundle the details into the error itself — the Dagger CLI
|
|
// suppresses the (string, _) tuple's first element when the
|
|
// second is non-nil, so without this the operator only sees
|
|
// "1 issue(s)" with no actionable context.
|
|
return "", fmt.Errorf("chart render misaligned: %d issue(s) across %d daemonset(s):\n %s",
|
|
len(fails), len(bundles), strings.Join(fails, "\n "))
|
|
}
|
|
return fmt.Sprintf("OK — %d scan path(s) aligned across %d daemonset(s)\n", ok, len(bundles)), nil
|
|
}
|
|
|
|
// requiredMount returns the mountPath that must exist for the given
|
|
// configmap scan path. See TestHelmRender for the rule.
|
|
func requiredMount(p string) string {
|
|
p = path.Clean(p)
|
|
parts := strings.Split(p, "/")
|
|
for i, seg := range parts {
|
|
if seg == "**" || strings.ContainsAny(seg, "*?[") {
|
|
// First glob segment found — mount is everything before it.
|
|
return path.Clean("/" + strings.Join(parts[:i], "/"))
|
|
}
|
|
}
|
|
// No glob — literal file path. Mount is the parent directory.
|
|
return path.Dir(p)
|
|
}
|