mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
147 lines
6.8 KiB
Bash
Executable File
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"
|