Files
yucca/.mise/tasks/k8s/validate
T

147 lines
6.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#MISE description="Statically validate the k8s surface (charts + Flux tree)"
#MISE dir="{{config_root}}"
# Renders every chart (helm template + kubeconform) and builds the whole Flux
# tree the way Flux would (flux-local --enable-helm). No cluster needed; this
# is the CI gate for kubernetes/ and charts/ changes.
#
# NB: don't run this while Tilt is converging — Tilt's helm-deps resource
# rebuilds charts/*/charts/ (rm -rf + dependency build) and races the renders.
set -euo pipefail
# Charts are role-grouped: apps/* (services), platform/* (operators/CRs),
# lib/yucca-common (shared library), dev/* (dev-only). Paths below are
# relative to charts/.
LIB_CONSUMERS=(apps/yucca-api apps/yucca-admin-api apps/yucca-metrics-worker apps/futo-backups-bot apps/columbo apps/web apps/meta apps/michael dev/mock-oidc dev/mock-postmark dev/mailpit)
ALL_CHARTS=(apps/yucca-api apps/yucca-admin-api apps/yucca-metrics-worker apps/futo-backups-bot apps/columbo apps/web apps/meta apps/michael dev/mock-oidc dev/mock-postmark dev/mailpit platform/cnpg-cluster platform/ceph-objectuser platform/rook-ceph-cluster)
echo "==> helm dependency build (yucca-common consumers)"
for c in "${LIB_CONSUMERS[@]}"; do
(cd "charts/$c" && helm dependency build >/dev/null)
done
echo "==> helm template + kubeconform"
for c in "${ALL_CHARTS[@]}"; do
ns=yucca
[ "$c" = "platform/rook-ceph-cluster" ] && ns=rook-ceph
# NB: release name must not be YAML-boolean-ish ("y"/"on"/...): it lands in
# labels and kubeconform reads it back as a bool.
helm template yucca "charts/$c" -n "$ns" \
| kubeconform -strict -ignore-missing-schemas - \
&& echo " OK $c"
done
echo "==> kustomize build (Flux entrypoints)"
# partition/region GitOps tree: per-cluster entry points (clusters/<p>/<r>) +
# app overlays (apps/<p>/<r>, which compose components/{infra,roles/<role>}).
# LoadRestrictionsNone mirrors how Flux's kustomize-controller builds within a
# single git artifact: the role Components reference ../../apps/<app>.yaml
# (a sibling subtree under components/), which the CLI's default RootOnly
# restrictor would reject even though Flux allows it.
kb() { kustomize build --load-restrictor=LoadRestrictionsNone "$@"; }
CLUSTERS=(staging/austin prod/htz-fsn1 dev/local)
echo "==> topology substitution safety"
# The real-cluster topology is a JSON document after Flux postBuild
# substitution. Reject characters that would escape its JSON string literals,
# and enforce the immutable internal-code convention before Flux sees them.
node <<'NODE'
const fs = require('node:fs');
const environments = [
'kubernetes/clusters/staging/austin',
'kubernetes/clusters/prod/htz-fsn1',
];
for (const directory of environments) {
const files = [`${directory}/cluster-settings.yaml`, `${directory}/cluster-settings.generated.yaml`];
const text = files.map((file) => fs.readFileSync(file, 'utf8')).join('\n');
const value = (key) => {
const match = text.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
if (!match) throw new Error(`${directory}: missing ${key}`);
const raw = match[1].trim().replace(/\s+#.*$/, '');
return raw.startsWith('"') ? JSON.parse(raw) : raw;
};
const site = value('SITE_CODE');
const cluster = value('STORAGE_CLUSTER_CODE');
const legacySite = value('LEGACY_SITE_CODE');
const legacyCluster = value('LEGACY_STORAGE_CLUSTER_CODE');
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(site)) throw new Error(`${directory}: invalid SITE_CODE ${site}`);
if (!cluster.startsWith(`${site}-`) || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(cluster)) {
throw new Error(`${directory}: STORAGE_CLUSTER_CODE ${cluster} must start with ${site}-`);
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(legacySite)) {
throw new Error(`${directory}: invalid LEGACY_SITE_CODE ${legacySite}`);
}
if (
!legacyCluster.startsWith(`${legacySite}-`) ||
!/^[a-z0-9][a-z0-9-]{0,63}$/.test(legacyCluster)
) {
throw new Error(`${directory}: LEGACY_STORAGE_CLUSTER_CODE ${legacyCluster} must start with ${legacySite}-`);
}
for (const key of [
'SITE_DISPLAY_NAME',
'SITE_DESCRIPTION',
'STORAGE_CLUSTER_DISPLAY_NAME',
'GW_HOST',
'S3_ENDPOINT',
'S3_HOST',
]) {
const label = value(key);
if (/["\\\u0000-\u001f]/.test(label)) {
throw new Error(`${directory}: ${key} contains a character that is unsafe for topology JSON substitution`);
}
}
for (const key of ['GW_HOST', 'S3_HOST']) {
const host = value(key);
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host)) {
throw new Error(`${directory}: invalid ${key} ${host}`);
}
}
const s3Url = new URL(value('S3_ENDPOINT'));
if (!['http:', 'https:'].includes(s3Url.protocol)) {
throw new Error(`${directory}: S3_ENDPOINT must use HTTP(S)`);
}
}
NODE
echo " OK topology identifiers and labels"
for c in "${CLUSTERS[@]}"; do
kb "kubernetes/clusters/$c" >/dev/null && echo " OK kubernetes/clusters/$c"
kb "kubernetes/apps/$c" >/dev/null && echo " OK kubernetes/apps/$c"
done
# Dev-mirror HelmRepository sources (consumed by Tilt + the dev cluster-repos
# Kustomization; kept validated too).
kb kubernetes/apps/dev/local/repos >/dev/null && echo " OK kubernetes/apps/dev/local/repos"
echo "==> prod guardrails (no dev IdP behind the primary app set)"
# The prod cluster-settings carry a placeholder dev OIDC issuer until the real
# IdP exists; this gate makes it mechanically impossible to enable the yucca
# workload set (components/roles/primary) against it. Staging legitimately uses
# the dev issuer, so only prod overlays are checked.
for kz in kubernetes/apps/prod/*/kustomization.yaml; do
region_dir=$(basename "$(dirname "$kz")")
settings="kubernetes/clusters/prod/$region_dir/cluster-settings.yaml"
# primary role enabled = an UNcommented components entry referencing roles/primary
if grep -Eq '^[[:space:]]*-[[:space:]].*components/roles/primary' "$kz"; then
if grep -Eq '^[[:space:]]*OIDC_ISSUER:.*(external-dev-|mock-oidc)' "$settings"; then
echo "FAIL: prod/$region_dir enables roles/primary but $settings still points at a dev OIDC issuer" >&2
exit 1
fi
fi
done
echo " OK prod OIDC guard"
echo "==> flux-local build (full tree, helm rendering as Flux would)"
# Via uvx so uv supplies a Python matching flux-local's requires-python
# (>=3.13) regardless of the host. One retry: flux-local fans out `flux build
# ks` subprocesses which very rarely segfault under load.
flux_local() { uvx --from "flux-local==8.2.0" flux-local "$@"; }
# Per-cluster path: clusters reuse the same Kustomization names (cluster-apps,
# flux-system ns), so flux-local must scope to one cluster at a time.
for c in "${CLUSTERS[@]}"; do
flux_local build all "kubernetes/clusters/$c" --enable-helm --no-enable-dns >/dev/null \
|| flux_local build all "kubernetes/clusters/$c" --enable-helm --no-enable-dns >/dev/null
echo " OK flux-local ($c)"
done
echo "k8s surface: ALL VALID"