diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 9bbe3e7..7eefa64 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -146,3 +146,63 @@ jobs: echo "::error::controlledValues: RequestsOnly rendered no resources.requests. A container with limits and no requests is Guaranteed QoS, which permanently blocks VPA from lowering requests - see the QoS note in _deployment.yaml." exit 1 fi + + # The whole point of native Liquibase support is that application pods do + # not start before the migration finishes. That guarantee lives in two + # places that are easy to break independently: waitFor.active (which + # provisions the RBAC and the ServiceAccount token) and the Deployment's + # $waitForJobs list (which builds the kubectl wait arguments). Assert the + # migration Job is actually waited on, and that the ConfigMaps and Secret + # it depends on are hook-weighted ahead of it. + - name: "Regression test - liquibase migration must block pod startup" + working-directory: helm/helm-framework-test-template + run: | + set -eu + + rendered_lb=$(helm template helm-framework-test-template .) + echo "$rendered_lb" + + # `|| true` is required: grep -c exits 1 on a count of 0, which under + # `set -e` would abort before the diagnostic below can run. + wait_count=$(echo "$rendered_lb" | grep -c 'job/helm-framework-test-template-liquibase' || true) + if [[ "$wait_count" -lt 1 ]]; then + echo "::error::The Deployment's wait-for-job init container does not reference the Liquibase Job. Application pods would start before the migration completes - see helm-framework.waitFor.active in _helpers.tpl and the \$waitForJobs list in _deployment.yaml. Note liquibase.waitForIt defaults to true, so an 'and \$lb.enabled \$lb.waitForIt' check silently skips it." + exit 1 + fi + + rbac_count=$(echo "$rendered_lb" | grep -c 'helm-framework-test-template-wait-for-jobs' || true) + if [[ "$rbac_count" -lt 2 ]]; then + echo "::error::Expected a wait-for-jobs Role AND RoleBinding (2+ references) but found $rbac_count. Without them the init container cannot read Job status and pod startup hangs until the wait times out - see helm-framework.deployment.waitFor.rbac in _role.tpl." + exit 1 + fi + + cm_weight=$(echo "$rendered_lb" | grep -c 'helm.sh/hook-weight: "-20"' || true) + if [[ "$cm_weight" -lt 3 ]]; then + echo "::error::Expected at least 3 resources at hook-weight -20 (changelog ConfigMap, migrations ConfigMap, credentials Secret) but found $cm_weight. At a weight >= the Job's -10 they would be created after it, and the migration pod would fail to mount them." + exit 1 + fi + + # database.existingSecret is the only supported path for production + # credentials. If the generated Secret is still rendered alongside it, a + # plaintext password from values.yaml lands in the cluster anyway. + - name: "Regression test - existingSecret must suppress the generated Secret" + working-directory: helm/helm-framework-test-template + run: | + set -eu + + rendered_ext=$(helm template helm-framework-test-template . \ + --set liquibase.database.password=null \ + --set liquibase.database.existingSecret.name=external-db-credentials) + echo "$rendered_ext" + + gen_count=$(echo "$rendered_ext" | grep -c 'name: helm-framework-test-template-liquibase-env' || true) + if [[ "$gen_count" -ne 0 ]]; then + echo "::error::database.existingSecret.name is set but the generated credentials Secret was still rendered ($gen_count references). See the existingSecret guard in _secret-liquibase.tpl." + exit 1 + fi + + ref_count=$(echo "$rendered_ext" | grep -c 'name: "external-db-credentials"' || true) + if [[ "$ref_count" -lt 2 ]]; then + echo "::error::Expected the Job's username and password to both reference external-db-credentials (2 secretKeyRefs) but found $ref_count. See helm-framework.liquibase.env in _helpers.tpl." + exit 1 + fi diff --git a/docs/superpowers/plans/2026-08-27-liquibase-native-support.md b/docs/superpowers/plans/2026-08-27-liquibase-native-support.md new file mode 100644 index 0000000..c62382f --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-liquibase-native-support.md @@ -0,0 +1,1661 @@ +# Native Liquibase Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a chart declare a `liquibase:` values block and get a complete pre-start database migration — hook Job, changelog/migrations ConfigMaps, connection Secret, and a wait-for-job init container that blocks Deployment pods until the migration completes. + +**Architecture:** A dedicated `_liquibase.tpl` renders the migration Job, sharing its pod-spec fragments with the existing `jobs[]` Job via partials extracted from `_job.yaml` in Task 1. Changelog and migration SQL are read from the *consuming* chart's directory through `.Files` (verified: the consuming chart passes its own root context into the library include). The existing `waitForIt` machinery — `kubectl wait` init container plus auto-provisioned RBAC — is reused by teaching two call sites about the Liquibase Job. + +**Tech Stack:** Helm 3 library chart (Go templates), Node's built-in `node:test` for the skill-generator contract guard, GitHub Actions (`helm template` + `kubeconform` + shell assertions) for template regression tests. + +**Spec:** `docs/superpowers/specs/2026-08-27-liquibase-native-support-design.md` + +> **Superseded in part.** This plan was executed as written, then PR review +> (#9) removed the `database.engine` enum and its per-driver port and +> URL-template defaults. Tasks 3 and 6 below still describe them. The spec is +> the current design — see its "No per-engine defaults" section. Everything +> else in this plan matches what shipped. + +## Global Constraints + +- Every new template lives in `helm/helm-framework/templates/` and is a `define` registered in `_deployment-global.tpl`'s `$documents` list. The library chart renders nothing on its own. +- Values reads use the framework's defensive parenthesised form, e.g. `(.Values.liquibase).enabled`, `((.Values.liquibase).database).host` — never a bare `.Values.liquibase.enabled` that would panic on a missing parent. +- **The values-contract guard is bidirectional.** `helm/helm-framework/values.yaml` declaring `liquibase:` and the first template reading `.Values.liquibase` MUST land in the same commit (Task 2). Either one alone makes `npm test` fail. +- The read-set regex is `/\.Values\.([A-Za-z0-9_]+)/`. At least one template must contain the literal string `.Values.liquibase`. +- `helm/helm-framework/README.md` is auto-generated by the `Docs` workflow from `# --` comments in `values.yaml`. Never hand-edit it. +- Resource names: Job `-`, ConfigMaps `…-changelog` / `…-migrations`, Secret `…-env`. `liquibase.name` defaults to `liquibase`. +- Hook weights are load-bearing: ConfigMaps and Secret at `"-20"`, Job at `"-10"`. +- Built-in sqlserver urlTemplate is `jdbc:sqlserver://%s:%s;database=%s;` — deliberately WITHOUT `encrypt=false`. +- Every task ends green: `npm test` passes and `helm template helm/helm-framework-test-template` renders without error. + +**Setup for every task** — run once at the start of each task's verification steps: + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +``` + +`helm dependency update` re-packages the library chart into `helm/helm-framework-test-template/charts/`. **Template edits are invisible to `helm template` until you re-run it.** This is the single most common way to waste time on this plan. + +--- + +### Task 1: Extract shared Job pod-spec partials + +Pure refactor. No behaviour change, proven by a byte-identical render diff. Nothing Liquibase-specific yet. + +**Files:** +- Create: `helm/helm-framework/templates/_job-partials.tpl` +- Modify: `helm/helm-framework/templates/_job.yaml` (the `metadata.annotations`, `metadata.labels`, `initContainers`, `resources`, `volumeMounts`, `volumes`, and scheduling blocks) +- Test: render-diff against a baseline captured in step 1 (no test file — this is a refactor guard) + +**Interfaces:** +- Consumes: nothing. +- Produces: seven partials, each taking the chart root context except `job.resources`: + - `helm-framework.job.podAnnotations` — context: root. Emits `checksum/appSettings`, `checksum/application`, then `podAnnotations`, at relative indent 0. + - `helm-framework.job.podLabels` — context: root. Emits `helm-framework.labels` then `podLabels`, relative indent 0. + - `helm-framework.job.caBundleInit` — context: root. Emits the `- name: ca-bundle-init` container, relative indent 0. + - `helm-framework.job.resources` — context: `dict "root" "resources" `. Emits a `resources:` block, relative indent 0. + - `helm-framework.job.commonVolumes` — context: root. Emits appSettings / cert-combine / scripts / authorities volume entries, relative indent 0. May emit nothing. + - `helm-framework.job.commonVolumeMounts` — context: root. Emits app-settings and ca-bundle mount entries, relative indent 0. May emit nothing. + - `helm-framework.job.scheduling` — context: root. Emits `nodeSelector` / `affinity` / `tolerations`, relative indent 0. May emit nothing. + +- [ ] **Step 1: Capture the pre-refactor rendering baseline** + +Two renders, because `jobs[]` and the CA-bundle path are only exercised under different value combinations. + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +mkdir -p /tmp/hf-baseline +helm template hf helm/helm-framework-test-template > /tmp/hf-baseline/default.yaml +helm template hf helm/helm-framework-test-template \ + --set initContainer.caBundle.enabled=true \ + --set 'initContainer.caBundle.trustedCertificateAuthorities.test=-----BEGIN CERTIFICATE-----' \ + > /tmp/hf-baseline/cabundle.yaml +wc -l /tmp/hf-baseline/*.yaml +``` + +Both files must be non-empty and `default.yaml` must contain `kind: Job`. Confirm: + +```bash +grep -c 'kind: Job' /tmp/hf-baseline/default.yaml +``` + +Expected: `1` or more. If `0`, the test-template chart's `jobs[]` entry is not enabled and this task's guard is worthless — stop and investigate. + +- [ ] **Step 2: Create the partials file** + +Create `helm/helm-framework/templates/_job-partials.tpl`: + +``` +{{/* +Shared Job pod-spec fragments, included by both _job.yaml (the `jobs[]` list) +and _liquibase.tpl (the native Liquibase migration Job). Extracted so the two +Job kinds cannot drift on CA-bundle wiring, VPA-aware resources, appSettings +mounting, or scheduling. + +Every partial emits at relative indent 0; callers apply `nindent`. Partials +that can emit nothing are wrapped by callers in `with (include ... | trim)` so +an empty result contributes no whitespace. + +Context is the chart root ($ / $root) except helm-framework.job.resources, +which takes a dict — see its own comment. +*/}} + +{{- define "helm-framework.job.podAnnotations" -}} +checksum/appSettings: {{ .Values.appSettings | default dict | toYaml | sha256sum }} +checksum/application: {{ .Values.helmFrameworkSettings | default dict | toYaml | sha256sum }} +{{- with .Values.podAnnotations }} +{{ toYaml . | trim }} +{{- end }} +{{- end }} + +{{- define "helm-framework.job.podLabels" -}} +{{ include "helm-framework.labels" . }} +{{- with .Values.podLabels }} +{{ toYaml . | trim }} +{{- end }} +{{- end }} + +{{- define "helm-framework.job.caBundleInit" -}} +- name: ca-bundle-init + image: "{{ required "Image repository is required!" (.Values.image).repository }}:{{ (.Values.image).tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ (.Values.image).pullPolicy | default "IfNotPresent" }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 4 }} + {{- end }} + command: + - /bin/sh + args: + - -c + - /mnt/scripts/combine-certs.sh + volumeMounts: + - name: scripts + mountPath: /mnt/scripts + - name: cert-combine + mountPath: /mnt/ca-certs + - name: authorities + mountPath: /mnt/authorities +{{- end }} + +{{/* +VPA-aware resources block. Under an enabled verticalPodAutoscaler only +requests are rendered (limits are the VPA's to manage); otherwise the +resources map is emitted verbatim. Expects a dict: + { root: , resources: } +*/}} +{{- define "helm-framework.job.resources" -}} +{{- $root := .root -}} +{{- $res := .resources -}} +{{- if ($root.Values.verticalPodAutoscaler).enabled }} +{{- with ($res).requests }} +resources: + requests: + {{- toYaml . | nindent 4 }} +{{- else }} +resources: {} +{{- end }} +{{- else if $res }} +resources: + {{- toYaml $res | nindent 2 }} +{{- else }} +resources: {} +{{- end }} +{{- end }} + +{{- define "helm-framework.job.commonVolumes" -}} +{{- if .Values.appSettings }} +- name: app-settings + secret: + secretName: {{ include "helm-framework.secret-app-settings" . }} + optional: false +{{- end }} +{{- if ((.Values.initContainer).caBundle).enabled }} +- name: cert-combine + emptyDir: {} +- name: scripts + secret: + secretName: {{ include "helm-framework.secret-scripts" . }} + optional: false + defaultMode: 0555 +- name: authorities + secret: + secretName: {{ include "helm-framework.secret-authorities" . }} + optional: false +{{- end }} +{{- end }} + +{{- define "helm-framework.job.commonVolumeMounts" -}} +{{- if .Values.appSettings }} +- name: app-settings + mountPath: "{{ include "helm-framework.values.application.configPath" . }}/{{ include "helm-framework.values.configFileName" . }}" + subPath: {{ include "helm-framework.values.configFileName" . | quote }} + readOnly: true +{{- end }} +{{- if ((.Values.initContainer).caBundle).enabled }} +- name: cert-combine + mountPath: {{ include "helm-framework.values.ca-bundle-path" . }} + readOnly: true +{{- end }} +{{- end }} + +{{- define "helm-framework.job.scheduling" -}} +{{- with .Values.nodeSelector }} +nodeSelector: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.affinity }} +affinity: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.tolerations }} +tolerations: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} +``` + +- [ ] **Step 3: Rewrite `_job.yaml` to use the partials** + +Six replacements in `helm/helm-framework/templates/_job.yaml`. Apply each exactly. + +**3a — pod annotations and labels.** Replace: + +``` + annotations: + checksum/appSettings: {{ $root.Values.appSettings | default dict | toYaml | sha256sum }} + checksum/application: {{ $root.Values.helmFrameworkSettings | default dict | toYaml | sha256sum }} + {{- with $root.Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "helm-framework.labels" $root | nindent 8 }} + {{- with $root.Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} +``` + +with: + +``` + annotations: + {{- include "helm-framework.job.podAnnotations" $root | nindent 8 }} + labels: + {{- include "helm-framework.job.podLabels" $root | nindent 8 }} +``` + +**3b — CA-bundle init container.** Replace the whole block from `{{- if (($root.Values.initContainer).caBundle).enabled }}` / `initContainers:` down to the `mountPath: /mnt/authorities` line and its closing `{{- end }}` with: + +``` + {{- if (($root.Values.initContainer).caBundle).enabled }} + initContainers: + {{- include "helm-framework.job.caBundleInit" $root | nindent 8 }} + {{- end }} +``` + +**3c — resources.** Replace: + +``` + {{- $res := $job.resources | default $root.Values.resources }} + {{- if ($root.Values.verticalPodAutoscaler).enabled }} + {{- with ($res).requests }} + resources: + requests: + {{- toYaml . | nindent 14 }} + {{- else }} + resources: {} + {{- end }} + {{- else if $res }} + resources: + {{- toYaml $res | nindent 12 }} + {{- else }} + resources: {} + {{- end }} +``` + +with: + +``` + {{- $res := $job.resources | default $root.Values.resources }} + {{- include "helm-framework.job.resources" (dict "root" $root "resources" $res) | nindent 10 }} +``` + +**3d — volumeMounts.** Replace: + +``` + volumeMounts: + {{- if $root.Values.appSettings }} + - name: app-settings + mountPath: "{{ include "helm-framework.values.application.configPath" $root }}/{{ include "helm-framework.values.configFileName" $root }}" + subPath: {{ include "helm-framework.values.configFileName" $root | quote }} + readOnly: true + {{- end }} + {{- if (($root.Values.initContainer).caBundle).enabled }} + - name: cert-combine + mountPath: {{ include "helm-framework.values.ca-bundle-path" $root }} + readOnly: true + {{- end }} +``` + +with: + +``` + volumeMounts: + {{- with (include "helm-framework.job.commonVolumeMounts" $root | trim) }} + {{- . | nindent 12 }} + {{- end }} +``` + +**3e — volumes.** Replace: + +``` + volumes: + {{- if $root.Values.appSettings }} + - name: app-settings + secret: + secretName: {{ include "helm-framework.secret-app-settings" $root }} + optional: false + {{- end }} + {{- if (($root.Values.initContainer).caBundle).enabled }} + - name: cert-combine + emptyDir: {} + - name: scripts + secret: + secretName: {{ include "helm-framework.secret-scripts" $root }} + optional: false + defaultMode: 0555 + - name: authorities + secret: + secretName: {{ include "helm-framework.secret-authorities" $root }} + optional: false + {{- end }} +``` + +with: + +``` + volumes: + {{- with (include "helm-framework.job.commonVolumes" $root | trim) }} + {{- . | nindent 8 }} + {{- end }} +``` + +**3f — scheduling.** Replace: + +``` + {{- with $root.Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $root.Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $root.Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +``` + +with: + +``` + {{- with (include "helm-framework.job.scheduling" $root | trim) }} + {{- . | nindent 6 }} + {{- end }} +``` + +- [ ] **Step 4: Verify the render is byte-identical** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /tmp/hf-after-default.yaml +helm template hf helm/helm-framework-test-template \ + --set initContainer.caBundle.enabled=true \ + --set 'initContainer.caBundle.trustedCertificateAuthorities.test=-----BEGIN CERTIFICATE-----' \ + > /tmp/hf-after-cabundle.yaml +diff -u /tmp/hf-baseline/default.yaml /tmp/hf-after-default.yaml +diff -u /tmp/hf-baseline/cabundle.yaml /tmp/hf-after-cabundle.yaml +``` + +Expected: both diffs produce **no output** and exit 0. + +If a diff shows only leading-whitespace or blank-line changes, the partial's relative indentation or the caller's `nindent` is off by the difference shown — adjust and re-run. Do NOT proceed to Task 2 with a non-empty diff; the whole point of this task is that it changes nothing. + +- [ ] **Step 5: Verify lint and the node suite still pass** + +```bash +helm lint helm/helm-framework-test-template +npm test +``` + +Expected: lint reports `0 chart(s) failed`; `npm test` passes. The contract guard is unaffected — no new top-level values key was introduced. + +- [ ] **Step 6: Commit** + +```bash +git add helm/helm-framework/templates/_job-partials.tpl helm/helm-framework/templates/_job.yaml +git commit -m "refactor(job): extract shared Job pod-spec partials + +Pulls the pod annotations/labels, CA-bundle init container, VPA-aware +resources, appSettings/ca-bundle volumes and mounts, and scheduling blocks +out of _job.yaml into _job-partials.tpl so a second Job template can reuse +them without duplication. Rendered output of the test template chart is +byte-identical before and after." +``` + +--- + +### Task 2: Values contract — `liquibase` block, name helpers, ConfigMaps + +The contract guard forces the `values.yaml` declaration and the first `.Values.liquibase` read into one commit, so they are one task. + +**Files:** +- Modify: `helm/helm-framework/values.yaml` (new `LIQUIBASE DATABASE MIGRATIONS` section, appended after the `JOBS` section) +- Modify: `helm/helm-framework/templates/_helpers.tpl` (append name helpers) +- Create: `helm/helm-framework/templates/_configmap-liquibase.tpl` +- Modify: `helm/helm-framework/templates/_deployment-global.tpl` (register the new define) +- Create: `helm/helm-framework-test-template/liquibase/changelog.xml` +- Create: `helm/helm-framework-test-template/liquibase/migrations/001_init.sql` +- Modify: `helm/helm-framework-test-template/values.yaml` (enable liquibase) + +**Interfaces:** +- Consumes: `helm-framework.fullname`, `helm-framework.labels` (existing, `_helpers.tpl`). +- Produces: + - `helm-framework.liquibase.name` → root context → `liquibase.name` or `"liquibase"` + - `helm-framework.liquibase.job-name` → root → `-` + - `helm-framework.liquibase.changelog-configmap-name` → root → `-changelog` + - `helm-framework.liquibase.migrations-configmap-name` → root → `-migrations` + - `helm-framework.liquibase.env-secret-name` → root → `-env` + - `helm-framework.liquibase.changelog-key` → root → `base changelog.mountPath`, the ConfigMap key AND the volume `subPath` + - `helm-framework.liquibase.has-migrations` → root → `"true"` when any migration file resolves, else `""` + - `helm-framework.deployment.liquibase-configmaps` → root → the ConfigMap document(s) + +- [ ] **Step 1: Add the fixtures the test will assert on** + +Create `helm/helm-framework-test-template/liquibase/changelog.xml`: + +```xml + + + + +``` + +Create `helm/helm-framework-test-template/liquibase/migrations/001_init.sql`: + +```sql +--liquibase formatted sql +--changeset helm-framework:001-init +CREATE TABLE helm_framework_smoke (id INT NOT NULL); +``` + +- [ ] **Step 2: Enable liquibase in the test template chart** + +Append to `helm/helm-framework-test-template/values.yaml` (after the `jobs:` block, before the `SIDECARS` section): + +```yaml +# ============================================================================= +# LIQUIBASE DATABASE MIGRATIONS +# ============================================================================= + +# Native Liquibase migration — enabled so `helm template` renders the Job, the +# changelog/migrations ConfigMaps, the connection Secret, and the wait-for-job +# init container in the Deployment. +liquibase: + enabled: true + database: + engine: sqlserver + host: sql-server + name: SmokeTestStore + userName: sa + password: smoke-test-password + changelog: + file: liquibase/changelog.xml + migrations: + paths: + - liquibase/migrations/*.sql +``` + +- [ ] **Step 3: Declare the `liquibase` block in the library values.yaml** + +Append to `helm/helm-framework/values.yaml`, immediately after the `jobs: []` commented reference block and before the `SIDECARS` section header: + +```yaml +# ============================================================================= +# LIQUIBASE DATABASE MIGRATIONS +# ============================================================================= + +# -- Native Liquibase migration support. Renders a pre-install/pre-upgrade hook +# Job that runs Liquibase against your database, plus ConfigMaps holding your +# changelog and migration SQL and a Secret holding the database credentials. +# With `waitForIt` (the default) the Deployment's pods get a wait-for-job init +# container and will not start until the migration Job completes. Changelog and +# migration files are read from YOUR chart's directory via `.Files`, so the SQL +# lives as real files beside your chart rather than inside values.yaml. +liquibase: + enabled: false + # -- Suffix for every generated resource name: Job `-`, plus + # `-changelog` / `-migrations` / `-env`. Must not collide with a `jobs[]` name. + name: liquibase + # -- Block Deployment pod startup until the migration Job completes. Reuses + # the same wait-for-job init container and auto-provisioned RBAC as + # `jobs[].waitForIt` — see `initContainer.waitFor` to tune image and timeout. + waitForIt: true + restartPolicy: OnFailure + backoffLimit: 6 + image: + repository: liquibase/liquibase + tag: "4.33" + pullPolicy: IfNotPresent + command: + - liquibase + args: + - update + - "--changeLogFile=changelog.xml" + log: + # -- Liquibase log level, passed as LIQUIBASE_LOG_LEVEL: SEVERE, WARNING, + # INFO, FINE, or OFF. + level: INFO + database: + # -- sqlserver | postgresql | mysql | oracle. Selects the default port and + # the built-in JDBC URL template. An unrecognised engine is allowed only + # together with an explicit `urlTemplate` AND an explicit `port`. + engine: sqlserver + # -- Database host. Required unless `url` is set. + host: "" + # -- 0 selects the engine default: sqlserver 1433, postgresql 5432, + # mysql 3306, oracle 1521. + port: 0 + # -- Database name. Required unless `url` is set. + name: "" + userName: "" + password: "" + # -- printf override for the JDBC URL template; arguments are host, port, + # and name in that order. The built-in sqlserver template deliberately + # omits `encrypt=false` — set it here if your server needs it, e.g. + # "jdbc:sqlserver://%s:%s;database=%s;encrypt=false;". + urlTemplate: "" + # -- Literal JDBC URL, rendered through `tpl`. Bypasses composition + # entirely; host, port, name, and urlTemplate are then ignored. + url: "" + # -- Source credentials from an existing Secret instead of `userName` and + # `password`. When `name` is set, no connection Secret is generated and the + # username/password come from secretKeyRef — use this with ExternalSecret + # rather than putting a production password in values.yaml. + existingSecret: + name: "" + usernameKey: username + passwordKey: password + changelog: + # -- Path to the changelog file within YOUR chart, e.g. + # "liquibase/changelog.xml". Read via `.Files.Get`. + file: "" + # -- Inline changelog alternative. `file` wins when both are set. + content: "" + # -- Where the changelog is mounted in the Job container. Its basename is + # both the ConfigMap key and the mount subPath, and its directory becomes + # LIQUIBASE_SEARCH_PATH — so the default `--changeLogFile=changelog.xml` + # keeps resolving if you change this. + mountPath: /liquibase/changelog.xml + migrations: + # -- Globs within YOUR chart, e.g. ["liquibase/migrations/*.sql"]. Read via + # `.Files.Glob`; each file's basename becomes its ConfigMap key. + paths: [] + # -- Inline alternative, filename -> content. Merged with `paths`; globbed + # files win on key collision. Note the 1 MiB ConfigMap limit, and that + # ConfigMap data must be valid UTF-8 text. + files: {} + mountPath: /liquibase/migrations + # -- Job container resources. Falls back to the top-level `resources` when + # empty, and is VPA-aware like `jobs[].resources`. + resources: {} + # -- Extra plain env vars (list of {name, value}), appended after the + # composed LIQUIBASE_* variables. Rendered through `tpl`. + extraEnvVars: [] + # -- Extra raw envFrom sources (secretRef/configMapRef). Rendered through + # `tpl`, so entries may reference chart helpers. + extraEnvFrom: [] + volumes: [] + volumeMounts: [] +``` + +- [ ] **Step 4: Add the name helpers** + +Append to `helm/helm-framework/templates/_helpers.tpl`: + +``` +{{/* +Liquibase resource names. Everything derives from +- so the Job, its ConfigMaps, and its Secret stay +grouped and predictable. +*/}} +{{- define "helm-framework.liquibase.name" -}} +{{- (.Values.liquibase).name | default "liquibase" }} +{{- end }} + +{{- define "helm-framework.liquibase.job-name" -}} +{{- include "helm-framework.fullname" . }}-{{ include "helm-framework.liquibase.name" . }} +{{- end }} + +{{- define "helm-framework.liquibase.changelog-configmap-name" -}} +{{- include "helm-framework.liquibase.job-name" . }}-changelog +{{- end }} + +{{- define "helm-framework.liquibase.migrations-configmap-name" -}} +{{- include "helm-framework.liquibase.job-name" . }}-migrations +{{- end }} + +{{- define "helm-framework.liquibase.env-secret-name" -}} +{{- include "helm-framework.liquibase.job-name" . }}-env +{{- end }} + +{{/* +The changelog's ConfigMap key, which is also the volume subPath. Derived from +changelog.mountPath's basename so the ConfigMap, the mount, and +LIQUIBASE_SEARCH_PATH can never disagree. +*/}} +{{- define "helm-framework.liquibase.changelog-key" -}} +{{- base (((.Values.liquibase).changelog).mountPath | default "/liquibase/changelog.xml") }} +{{- end }} + +{{/* +Returns "true" when at least one migration file resolves, from either +migrations.paths globs or the inline migrations.files map. Used to decide +whether the migrations ConfigMap, volume, and mount are rendered at all — a +self-contained changelog needs none of them. +*/}} +{{- define "helm-framework.liquibase.has-migrations" -}} +{{- $found := false -}} +{{- if ((.Values.liquibase).migrations).files -}} +{{- $found = true -}} +{{- end -}} +{{- range $pattern := ((.Values.liquibase).migrations).paths -}} +{{- if $.Files.Glob $pattern -}} +{{- $found = true -}} +{{- end -}} +{{- end -}} +{{- if $found }}true{{- end -}} +{{- end }} +``` + +- [ ] **Step 5: Create the ConfigMaps template** + +Create `helm/helm-framework/templates/_configmap-liquibase.tpl`: + +``` +{{/* +ConfigMaps holding the Liquibase changelog and migration SQL. Both are +pre-install/pre-upgrade hooks at weight -20, below the migration Job at -10, +so they exist before the Job pod starts. + +Content comes from the CONSUMING chart's files: the chart that includes +helm-framework.deployment.global passes its own root context, so `.Files` +resolves against that chart's directory, not the library's. Inline +`changelog.content` / `migrations.files` are the fallback for charts that +would rather keep everything in values.yaml. +*/}} +{{- define "helm-framework.deployment.liquibase-configmaps" -}} +{{- if (.Values.liquibase).enabled -}} +{{- $changelog := "" -}} +{{- with ((.Values.liquibase).changelog).file -}} +{{- $changelog = $.Files.Get . -}} +{{- end -}} +{{- if not $changelog -}} +{{- $changelog = (((.Values.liquibase).changelog).content | default "") -}} +{{- end -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "helm-framework.liquibase.changelog-configmap-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-weight: "-20" + labels: + {{- include "helm-framework.labels" . | nindent 4 }} +data: + {{ include "helm-framework.liquibase.changelog-key" . }}: | +{{ $changelog | indent 4 }} +{{- if eq (include "helm-framework.liquibase.has-migrations" .) "true" }} +{{- $migrations := dict -}} +{{- range $name, $content := ((.Values.liquibase).migrations).files -}} +{{- $_ := set $migrations $name (toString $content) -}} +{{- end -}} +{{- range $pattern := ((.Values.liquibase).migrations).paths -}} +{{- range $path, $bytes := $.Files.Glob $pattern -}} +{{- $_ := set $migrations (base $path) (toString $bytes) -}} +{{- end -}} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "helm-framework.liquibase.migrations-configmap-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-weight: "-20" + labels: + {{- include "helm-framework.labels" . | nindent 4 }} +data: + {{- range $name, $content := $migrations }} + {{ $name }}: | +{{ $content | indent 4 }} + {{- end }} +{{- end }} +{{- end }} +{{- end }} +``` + +- [ ] **Step 6: Register the define** + +In `helm/helm-framework/templates/_deployment-global.tpl`, add one line to `$documents` immediately after the `helm-framework.deployment.job` line: + +``` + (include "helm-framework.deployment.liquibase-configmaps" .) +``` + +- [ ] **Step 7: Verify the ConfigMaps render correctly** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /tmp/hf-t2.yaml +grep -n 'hf-helm-framework-test-template-liquibase-changelog\|hf-helm-framework-test-template-liquibase-migrations' /tmp/hf-t2.yaml +grep -n 'includeAll path="migrations"' /tmp/hf-t2.yaml +grep -n '001_init.sql' /tmp/hf-t2.yaml +grep -n 'CREATE TABLE helm_framework_smoke' /tmp/hf-t2.yaml +``` + +Expected: the two ConfigMap names each appear once; the changelog's `includeAll` line appears (proving `.Files.Get` read the consuming chart); `001_init.sql` appears as a data key (proving `base $path` keying); the `CREATE TABLE` line appears (proving `.Files.Glob` read the content). + +Then confirm the rendered manifests are valid Kubernetes: + +```bash +helm lint helm/helm-framework-test-template +``` + +Expected: `0 chart(s) failed`. + +- [ ] **Step 8: Verify the values contract guard is satisfied** + +```bash +npm test +``` + +Expected: PASS. If it fails with `Templates read N top-level value(s) that values.yaml never declares` mentioning `liquibase`, Step 3 was skipped or the block is indented under another key. If it fails with `values.yaml declares N top-level key(s) that no template reads`, the template in Step 5 is not spelling `.Values.liquibase` literally. + +- [ ] **Step 9: Commit** + +```bash +git add helm/helm-framework/values.yaml \ + helm/helm-framework/templates/_helpers.tpl \ + helm/helm-framework/templates/_configmap-liquibase.tpl \ + helm/helm-framework/templates/_deployment-global.tpl \ + helm/helm-framework-test-template/values.yaml \ + helm/helm-framework-test-template/liquibase +git commit -m "feat(liquibase): add liquibase values contract and changelog/migrations ConfigMaps + +Declares the liquibase block in values.yaml and renders the changelog and +migration ConfigMaps from the consuming chart's own files via .Files, keyed by +basename. Both land together because the values-contract guard is +bidirectional." +``` + +--- + +### Task 3: JDBC URL composition, env, and the credentials Secret + +**Files:** +- Modify: `helm/helm-framework/templates/_values.tpl` (append engine lookups) +- Modify: `helm/helm-framework/templates/_helpers.tpl` (append URL and env helpers) +- Create: `helm/helm-framework/templates/_secret-liquibase.tpl` +- Modify: `helm/helm-framework/templates/_deployment-global.tpl` (register) + +**Interfaces:** +- Consumes: `helm-framework.liquibase.env-secret-name`, `helm-framework.labels`. +- Produces: + - `helm-framework.values.liquibase.port` → root → resolved port as a string + - `helm-framework.values.liquibase.urlTemplate` → root → printf template, `""` for an unknown engine with no override + - `helm-framework.liquibase.url` → root → the composed JDBC URL string + - `helm-framework.liquibase.env` → root → a YAML `env:` list at relative indent 0 + - `helm-framework.deployment.liquibase-secret` → root → the Secret document, or nothing + +- [ ] **Step 1: Add the engine lookup tables** + +Append to `helm/helm-framework/templates/_values.tpl`: + +``` +{{/* +Liquibase engine defaults. `get` on an unrecognised engine returns the zero +value (0 / ""), which _values-validation.tpl turns into an actionable error +rather than a silent bad URL. +*/}} +{{- define "helm-framework.values.liquibase.port" -}} +{{- $engine := (((.Values.liquibase).database).engine | default "sqlserver") -}} +{{- $ports := dict "sqlserver" 1433 "postgresql" 5432 "mysql" 3306 "oracle" 1521 -}} +{{- (((.Values.liquibase).database).port | default (get $ports $engine)) -}} +{{- end }} + +{{- define "helm-framework.values.liquibase.urlTemplate" -}} +{{- $engine := (((.Values.liquibase).database).engine | default "sqlserver") -}} +{{- $templates := dict + "sqlserver" "jdbc:sqlserver://%s:%s;database=%s;" + "postgresql" "jdbc:postgresql://%s:%s/%s" + "mysql" "jdbc:mysql://%s:%s/%s" + "oracle" "jdbc:oracle:thin:@%s:%s/%s" -}} +{{- (((.Values.liquibase).database).urlTemplate | default (get $templates $engine)) -}} +{{- end }} +``` + +Note `port: 0` relies on Helm's `default` treating 0 as empty — that is exactly the documented "0 selects the engine default" behaviour, not a bug. + +- [ ] **Step 2: Add the URL and env helpers** + +Append to `helm/helm-framework/templates/_helpers.tpl`: + +``` +{{/* +The composed JDBC URL. `database.url` wins outright and is tpl-rendered so it +can reference other values; otherwise the engine (or overridden) printf +template is filled with host, resolved port, and database name. +*/}} +{{- define "helm-framework.liquibase.url" -}} +{{- $db := ((.Values.liquibase).database | default dict) -}} +{{- if $db.url -}} +{{- tpl $db.url . -}} +{{- else -}} +{{- printf (include "helm-framework.values.liquibase.urlTemplate" .) ($db.host | toString) (include "helm-framework.values.liquibase.port" .) ($db.name | toString) -}} +{{- end -}} +{{- end }} + +{{/* +The Liquibase Job container's env list, at relative indent 0. + +The URL is a plain value: a connection target is not a credential, and keeping +it in the pod spec makes `kubectl describe job` diagnostic. Only the username +and password are Secret-sourced — from the generated Secret, or from +database.existingSecret when that is set. + +LIQUIBASE_SEARCH_PATH is the changelog mount's directory, so the default +`--changeLogFile=changelog.xml` keeps resolving even if mountPath is changed. +*/}} +{{- define "helm-framework.liquibase.env" -}} +{{- $db := ((.Values.liquibase).database | default dict) -}} +{{- $existing := ($db.existingSecret | default dict) -}} +{{- $changelogMount := (((.Values.liquibase).changelog).mountPath | default "/liquibase/changelog.xml") -}} +- name: LIQUIBASE_COMMAND_URL + value: {{ include "helm-framework.liquibase.url" . | quote }} +{{- if $existing.name }} +- name: LIQUIBASE_COMMAND_USERNAME + valueFrom: + secretKeyRef: + name: {{ tpl $existing.name . | quote }} + key: {{ $existing.usernameKey | default "username" | quote }} +- name: LIQUIBASE_COMMAND_PASSWORD + valueFrom: + secretKeyRef: + name: {{ tpl $existing.name . | quote }} + key: {{ $existing.passwordKey | default "password" | quote }} +{{- else }} +- name: LIQUIBASE_COMMAND_USERNAME + valueFrom: + secretKeyRef: + name: {{ include "helm-framework.liquibase.env-secret-name" . | quote }} + key: LIQUIBASE_COMMAND_USERNAME +- name: LIQUIBASE_COMMAND_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "helm-framework.liquibase.env-secret-name" . | quote }} + key: LIQUIBASE_COMMAND_PASSWORD +{{- end }} +- name: LIQUIBASE_LOG_LEVEL + value: {{ (((.Values.liquibase).log).level | default "INFO") | quote }} +- name: LIQUIBASE_SEARCH_PATH + value: {{ dir $changelogMount | quote }} +{{- with (.Values.liquibase).extraEnvVars }} +{{ tpl (toYaml .) $ | trim }} +{{- end }} +{{- end }} +``` + +- [ ] **Step 3: Create the Secret template** + +Create `helm/helm-framework/templates/_secret-liquibase.tpl`: + +``` +{{/* +The Liquibase database credentials Secret. A pre-install/pre-upgrade hook at +weight -20, below the migration Job at -10, so it exists before the Job pod +starts. + +Skipped entirely when database.existingSecret.name is set — that is the +supported path for production credentials (point it at an ExternalSecret) +instead of putting a password in values.yaml. Holds only the username and +password; the JDBC URL is a plain env var on the Job container. +*/}} +{{- define "helm-framework.deployment.liquibase-secret" -}} +{{- if (.Values.liquibase).enabled -}} +{{- $db := ((.Values.liquibase).database | default dict) -}} +{{- if not ($db.existingSecret | default dict).name -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "helm-framework.liquibase.env-secret-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-weight: "-20" + labels: + {{- include "helm-framework.labels" . | nindent 4 }} +data: + LIQUIBASE_COMMAND_USERNAME: {{ $db.userName | default "" | toString | b64enc | quote }} + LIQUIBASE_COMMAND_PASSWORD: {{ $db.password | default "" | toString | b64enc | quote }} +{{- end }} +{{- end }} +{{- end }} +``` + +- [ ] **Step 4: Register the define** + +In `_deployment-global.tpl`, add after the `liquibase-configmaps` line: + +``` + (include "helm-framework.deployment.liquibase-secret" .) +``` + +- [ ] **Step 5: Verify the Secret and URL composition** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /tmp/hf-t3.yaml +grep -n 'hf-helm-framework-test-template-liquibase-env' /tmp/hf-t3.yaml +grep -n 'LIQUIBASE_COMMAND_USERNAME' /tmp/hf-t3.yaml +``` + +Expected: the Secret name appears once and carries both `LIQUIBASE_COMMAND_*` keys. Decode to confirm the values round-trip: + +```bash +python3 - <<'PY' +import base64, re +text = open('/tmp/hf-t3.yaml').read() +for key in ('LIQUIBASE_COMMAND_USERNAME', 'LIQUIBASE_COMMAND_PASSWORD'): + m = re.search(rf'^ {key}: "([^"]+)"$', text, re.M) + assert m, f'{key} not found in Secret data' + print(key, '=', base64.b64decode(m.group(1)).decode()) +PY +``` + +Expected output: + +``` +LIQUIBASE_COMMAND_USERNAME = sa +LIQUIBASE_COMMAND_PASSWORD = smoke-test-password +``` + +The URL helper has no consumer until Task 4, so verify it via a throwaway override that exercises the sqlserver default template and the engine default port: + +```bash +helm template hf helm/helm-framework-test-template \ + --show-only templates/deployment.yaml \ + --set liquibase.database.existingSecret.name=my-external-secret > /tmp/hf-t3-existing.yaml +grep -c 'liquibase-env' /tmp/hf-t3-existing.yaml || true +``` + +Expected: `0` — with `existingSecret.name` set, no generated Secret is referenced from the Deployment, and the standalone Secret is not rendered. Confirm the Secret really disappeared: + +```bash +helm template hf helm/helm-framework-test-template \ + --set liquibase.database.existingSecret.name=my-external-secret \ + | grep -c 'name: hf-helm-framework-test-template-liquibase-env' || true +``` + +Expected: `0`. + +- [ ] **Step 6: Verify lint and the node suite** + +```bash +helm lint helm/helm-framework-test-template +npm test +``` + +Expected: both pass. + +- [ ] **Step 7: Commit** + +```bash +git add helm/helm-framework/templates/_values.tpl \ + helm/helm-framework/templates/_helpers.tpl \ + helm/helm-framework/templates/_secret-liquibase.tpl \ + helm/helm-framework/templates/_deployment-global.tpl +git commit -m "feat(liquibase): compose JDBC URL, env, and credentials Secret + +Adds per-engine port and URL-template defaults with urlTemplate and url +overrides, the LIQUIBASE_* env list, and the credentials Secret. The URL is a +plain env value; only username and password are Secret-sourced, and the Secret +is skipped when database.existingSecret is used." +``` + +--- + +### Task 4: The Liquibase migration Job + +**Files:** +- Create: `helm/helm-framework/templates/_liquibase.tpl` +- Modify: `helm/helm-framework/templates/_deployment-global.tpl` (register) + +**Interfaces:** +- Consumes: all seven partials from Task 1; `helm-framework.liquibase.job-name`, `…changelog-configmap-name`, `…migrations-configmap-name`, `…changelog-key`, `…has-migrations` (Task 2); `helm-framework.liquibase.env` (Task 3); `helm-framework.serviceAccountName`, `helm-framework.labels` (existing). +- Produces: `helm-framework.deployment.liquibase` → root → the Job document. + +- [ ] **Step 1: Create the Job template** + +Create `helm/helm-framework/templates/_liquibase.tpl`: + +``` +{{/* +The native Liquibase migration Job. A pre-install/pre-upgrade hook at weight +-10 — after the changelog/migrations ConfigMaps and the credentials Secret at +-20, and before the release's own manifests — so the migration runs ahead of +the Deployment. `before-hook-creation` deletion means a re-run replaces the +previous Job rather than colliding with it. + +Pod-spec fragments are shared with the `jobs[]` Job via _job-partials.tpl; see +the spec's "Refactor: shared Job partials" section for why this template is +standalone rather than a synthetic jobs[] entry. +*/}} +{{- define "helm-framework.deployment.liquibase" -}} +{{- if (.Values.liquibase).enabled -}} +{{- $lb := .Values.liquibase -}} +{{- $res := ($lb.resources | default .Values.resources) -}} +{{- $changelogMount := ($lb.changelog).mountPath | default "/liquibase/changelog.xml" -}} +{{- $changelogKey := include "helm-framework.liquibase.changelog-key" . -}} +{{- $hasMigrations := eq (include "helm-framework.liquibase.has-migrations" .) "true" -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "helm-framework.liquibase.job-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-delete-policy: before-hook-creation + helm.sh/hook-weight: "-10" + labels: + job: {{ include "helm-framework.liquibase.job-name" . }} + {{- include "helm-framework.labels" . | nindent 4 }} +spec: + backoffLimit: {{ $lb.backoffLimit | default 6 | int }} + template: + metadata: + annotations: + {{- include "helm-framework.job.podAnnotations" . | nindent 8 }} + labels: + {{- include "helm-framework.job.podLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "helm-framework.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ $lb.restartPolicy | default "OnFailure" }} + {{- if ((.Values.initContainer).caBundle).enabled }} + initContainers: + {{- include "helm-framework.job.caBundleInit" . | nindent 8 }} + {{- end }} + containers: + - name: liquibase + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ ($lb.image).repository | default "liquibase/liquibase" }}:{{ ($lb.image).tag | default "4.33" }}" + imagePullPolicy: {{ ($lb.image).pullPolicy | default "IfNotPresent" }} + {{- with $lb.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with $lb.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- include "helm-framework.liquibase.env" . | nindent 12 }} + {{- with $lb.extraEnvFrom }} + envFrom: + {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} + {{- include "helm-framework.job.resources" (dict "root" . "resources" $res) | nindent 10 }} + volumeMounts: + - name: liquibase-changelog + mountPath: {{ $changelogMount | quote }} + subPath: {{ $changelogKey | quote }} + readOnly: true + {{- if $hasMigrations }} + - name: liquibase-migrations + mountPath: {{ ($lb.migrations).mountPath | default "/liquibase/migrations" | quote }} + readOnly: true + {{- end }} + {{- with (include "helm-framework.job.commonVolumeMounts" . | trim) }} + {{- . | nindent 12 }} + {{- end }} + {{- with $lb.volumeMounts }} + {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} + volumes: + - name: liquibase-changelog + configMap: + name: {{ include "helm-framework.liquibase.changelog-configmap-name" . }} + items: + - key: {{ $changelogKey | quote }} + path: {{ $changelogKey | quote }} + {{- if $hasMigrations }} + - name: liquibase-migrations + configMap: + name: {{ include "helm-framework.liquibase.migrations-configmap-name" . }} + {{- end }} + {{- with (include "helm-framework.job.commonVolumes" . | trim) }} + {{- . | nindent 8 }} + {{- end }} + {{- with $lb.volumes }} + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} + {{- with (include "helm-framework.job.scheduling" . | trim) }} + {{- . | nindent 6 }} + {{- end }} +{{- end }} +{{- end }} +``` + +- [ ] **Step 2: Register the define** + +In `_deployment-global.tpl`, add after the `liquibase-secret` line: + +``` + (include "helm-framework.deployment.liquibase" .) +``` + +- [ ] **Step 3: Verify the Job renders as intended** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /tmp/hf-t4.yaml +``` + +Check each expectation: + +```bash +grep -n 'name: hf-helm-framework-test-template-liquibase$' /tmp/hf-t4.yaml +grep -n 'image: "liquibase/liquibase:4.33"' /tmp/hf-t4.yaml +grep -n 'jdbc:sqlserver://sql-server:1433;database=SmokeTestStore;' /tmp/hf-t4.yaml +grep -n 'LIQUIBASE_SEARCH_PATH' -A1 /tmp/hf-t4.yaml +grep -n 'subPath: "changelog.xml"' /tmp/hf-t4.yaml +grep -n 'name: liquibase-migrations' /tmp/hf-t4.yaml +``` + +Expected: the Job name appears; the image is the default `liquibase/liquibase:4.33`; the composed URL shows the engine default port 1433 and no `encrypt=` flag; `LIQUIBASE_SEARCH_PATH` is `"/liquibase"`; the changelog subPath is `changelog.xml`; `liquibase-migrations` appears twice (one mount, one volume). + +Verify hook ordering — the ConfigMaps and Secret must sort before the Job: + +```bash +grep -n 'helm.sh/hook-weight' /tmp/hf-t4.yaml +``` + +Expected: `"-20"` entries for the two ConfigMaps and the Secret, `"-10"` for the two Jobs. + +Verify the manifests are schema-valid, the same way CI does: + +```bash +helm lint helm/helm-framework-test-template +``` + +Expected: `0 chart(s) failed`. + +- [ ] **Step 4: Verify the migrations-absent path** + +A self-contained changelog must render a Job with no migrations volume or mount, and no migrations ConfigMap. + +```bash +helm template hf helm/helm-framework-test-template \ + --set 'liquibase.migrations.paths=null' > /tmp/hf-t4-nomig.yaml +grep -c 'liquibase-migrations' /tmp/hf-t4-nomig.yaml || true +``` + +Expected: `0`. + +- [ ] **Step 5: Verify the node suite** + +```bash +npm test +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add helm/helm-framework/templates/_liquibase.tpl \ + helm/helm-framework/templates/_deployment-global.tpl +git commit -m "feat(liquibase): render the migration Job + +Adds _liquibase.tpl, a pre-install/pre-upgrade hook Job at weight -10 that +mounts the changelog by subPath and the migrations directory, and reuses the +shared Job pod-spec partials for CA-bundle init, VPA-aware resources, +appSettings mounting, and scheduling." +``` + +--- + +### Task 5: Wire the Job into the wait-for-job init container + +**Files:** +- Modify: `helm/helm-framework/templates/_helpers.tpl:76-84` (`helm-framework.waitFor.active`) +- Modify: `helm/helm-framework/templates/_deployment.yaml:67-75` (the `$waitForJobs` collection) + +**Interfaces:** +- Consumes: `helm-framework.liquibase.job-name` (Task 2). +- Produces: no new defines. `helm-framework.waitFor.active` gains a second trigger, which transitively enables ServiceAccount creation (`_serviceaccount.tpl`), token automounting (`_deployment.yaml`), and the `-wait-for-jobs` Role/RoleBinding (`_role.tpl`). + +- [ ] **Step 1: Teach `waitFor.active` about liquibase** + +In `helm/helm-framework/templates/_helpers.tpl`, replace: + +``` +{{/* +Returns "true" when at least one enabled job is flagged with waitForIt. +Used to auto-provision RBAC so the wait-for-job init container can read jobs. +*/}} +{{- define "helm-framework.waitFor.active" -}} +{{- $active := false -}} +{{- range $index, $job := .Values.jobs -}} +{{- if and $job.enabled $job.waitForIt -}} +{{- $active = true -}} +{{- end -}} +{{- end -}} +{{- if $active }}true{{- end -}} +{{- end -}} +``` + +with: + +``` +{{/* +Returns "true" when at least one enabled job is flagged with waitForIt, or +when the native Liquibase migration is enabled and not opted out of waiting. +Used to auto-provision RBAC so the wait-for-job init container can read jobs. + +liquibase.waitForIt defaults to TRUE, so the check is "key absent OR truthy" — +`and $lb.enabled $lb.waitForIt` would wrongly skip the wait whenever a chart +enables liquibase without restating waitForIt. +*/}} +{{- define "helm-framework.waitFor.active" -}} +{{- $active := false -}} +{{- range $index, $job := .Values.jobs -}} +{{- if and $job.enabled $job.waitForIt -}} +{{- $active = true -}} +{{- end -}} +{{- end -}} +{{- $lb := (.Values.liquibase | default dict) -}} +{{- if and $lb.enabled (or (not (hasKey $lb "waitForIt")) $lb.waitForIt) -}} +{{- $active = true -}} +{{- end -}} +{{- if $active }}true{{- end -}} +{{- end -}} +``` + +- [ ] **Step 2: Add the Job to the wait list** + +In `helm/helm-framework/templates/_deployment.yaml`, replace: + +``` + {{- /* Collect names of enabled jobs flagged with waitForIt: true */ -}} + {{- $waitForJobs := list -}} + {{- range $index, $job := .Values.jobs -}} + {{- if and $job.enabled $job.waitForIt -}} + {{- $jobName := $job.name | default (printf "job-%d" $index) -}} + {{- $waitForJobs = append $waitForJobs (printf "job/%s-%s" (include "helm-framework.fullname" $) $jobName) -}} + {{- end -}} + {{- end -}} +``` + +with: + +``` + {{- /* Collect names of enabled jobs flagged with waitForIt: true, plus + the native Liquibase migration Job (which waits by default) */ -}} + {{- $waitForJobs := list -}} + {{- $lb := (.Values.liquibase | default dict) -}} + {{- if and $lb.enabled (or (not (hasKey $lb "waitForIt")) $lb.waitForIt) -}} + {{- $waitForJobs = append $waitForJobs (printf "job/%s" (include "helm-framework.liquibase.job-name" $)) -}} + {{- end -}} + {{- range $index, $job := .Values.jobs -}} + {{- if and $job.enabled $job.waitForIt -}} + {{- $jobName := $job.name | default (printf "job-%d" $index) -}} + {{- $waitForJobs = append $waitForJobs (printf "job/%s-%s" (include "helm-framework.fullname" $) $jobName) -}} + {{- end -}} + {{- end -}} +``` + +The migration is prepended so `kubectl wait` lists it first — it is the dependency the application pod actually cares about. + +- [ ] **Step 3: Verify the wait wiring** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /tmp/hf-t5.yaml +grep -n 'job/hf-helm-framework-test-template-liquibase' /tmp/hf-t5.yaml +grep -n 'name: wait-for-job' /tmp/hf-t5.yaml +grep -n 'hf-helm-framework-test-template-wait-for-jobs' /tmp/hf-t5.yaml +``` + +Expected: the `job/…-liquibase` argument appears inside the `wait-for-job` init container's args; the `…-wait-for-jobs` Role and RoleBinding are present. + +Now prove the wait can be turned off, and that liquibase alone is enough to trigger it: + +```bash +helm template hf helm/helm-framework-test-template \ + --set liquibase.waitForIt=false \ + --set 'jobs[0].enabled=false' \ + | grep -c 'name: wait-for-job' || true +``` + +Expected: `0`. + +```bash +helm template hf helm/helm-framework-test-template \ + --set 'jobs[0].enabled=false' \ + | grep -c 'job/hf-helm-framework-test-template-liquibase' || true +``` + +Expected: `1` — liquibase alone triggers the init container with no `jobs[]` entry enabled. + +- [ ] **Step 4: Verify lint and the node suite** + +```bash +helm lint helm/helm-framework-test-template +npm test +``` + +Expected: both pass. + +- [ ] **Step 5: Commit** + +```bash +git add helm/helm-framework/templates/_helpers.tpl helm/helm-framework/templates/_deployment.yaml +git commit -m "feat(liquibase): block pod startup on the migration Job + +Teaches waitFor.active and the Deployment's wait list about the Liquibase Job, +so the existing kubectl-wait init container, ServiceAccount automounting, and +wait-for-jobs RBAC all apply with no liquibase-specific code. waitForIt +defaults to true, so the check treats an absent key as enabled." +``` + +--- + +### Task 6: Validation + +Eight fail-fast rules, all scoped to `liquibase.enabled`. + +**Files:** +- Modify: `helm/helm-framework/templates/_values-validation.tpl` (append inside `helm-framework.values.validate`, immediately before the final `{{- end -}}`) + +**Interfaces:** +- Consumes: `helm-framework.liquibase.name`, `helm-framework.liquibase.has-migrations` (Task 2); `.Files` from the consuming chart's context. +- Produces: nothing renderable — `fail` side effects only. + +- [ ] **Step 1: Add the validation block** + +In `helm/helm-framework/templates/_values-validation.tpl`, insert immediately before the file's final `{{- end -}}`: + +``` +{{- if (.Values.liquibase).enabled }} +{{- $lb := .Values.liquibase -}} +{{- $db := ($lb.database | default dict) -}} +{{- $existing := ($db.existingSecret | default dict) -}} +{{- $engines := list "sqlserver" "postgresql" "mysql" "oracle" -}} +{{- $customUrl := or $db.url $db.urlTemplate -}} + {{- if and (not $db.url) (or (not $db.host) (not $db.name)) }} +{{- fail "liquibase.enabled is true but the database is not addressable: set liquibase.database.host and liquibase.database.name, or set liquibase.database.url to a literal JDBC URL." }} + {{- end }} + {{- if and (not ($lb.changelog | default dict).file) (not ($lb.changelog | default dict).content) }} +{{- fail "liquibase.enabled is true but no changelog is configured: set liquibase.changelog.file to a path inside your chart (e.g. \"liquibase/changelog.xml\"), or liquibase.changelog.content to an inline changelog." }} + {{- end }} + {{- if and (not $customUrl) (not (has ($db.engine | default "sqlserver") $engines)) }} +{{- fail (printf "liquibase.database.engine %q is not one of %s: pick a supported engine, or set liquibase.database.urlTemplate (and liquibase.database.port) to drive an unsupported driver yourself." ($db.engine | default "sqlserver") (join ", " $engines)) }} + {{- end }} + {{- if and (not (has ($db.engine | default "sqlserver") $engines)) (not $db.url) (not $db.port) }} +{{- fail (printf "liquibase.database.engine %q is unrecognised and liquibase.database.port is unset: there is no engine default to fall back on, so set the port explicitly." ($db.engine | default "sqlserver")) }} + {{- end }} + {{- if and $existing.name $db.password }} +{{- fail "liquibase.database.existingSecret.name and liquibase.database.password are both set: it is ambiguous which credential wins. Unset password to source it from the existing Secret, or unset existingSecret.name to use the generated one." }} + {{- end }} + {{- with ($lb.changelog | default dict).file }} + {{- if not ($.Files.Get .) }} +{{- fail (printf "liquibase.changelog.file %q resolves to nothing in this chart: .Files.Get returned empty, which would ship an empty changelog ConfigMap and a migration that silently does nothing. Check the path is relative to your chart root and that the file is not excluded by .helmignore." .) }} + {{- end }} + {{- end }} + {{- range $pattern := ($lb.migrations | default dict).paths }} + {{- if not ($.Files.Glob $pattern) }} +{{- fail (printf "liquibase.migrations.paths pattern %q matches no files in this chart: this would ship an empty migrations ConfigMap. Check the glob is relative to your chart root and that the files are not excluded by .helmignore." $pattern) }} + {{- end }} + {{- end }} + {{- $lbName := include "helm-framework.liquibase.name" . -}} + {{- range $index, $job := .Values.jobs }} + {{- if $job.enabled }} + {{- if eq ($job.name | default (printf "job-%d" $index)) $lbName }} +{{- fail (printf "jobs[%d]'s name %q collides with liquibase.name: both would render a Job named \"-%s\". Rename one of them." $index $lbName $lbName) }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} +``` + +Note `.helmignore` is called out in two messages on purpose: it is the non-obvious reason a correct-looking path resolves to nothing. + +- [ ] **Step 2: Verify the happy path still renders** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +helm dependency update helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /dev/null && echo "happy path OK" +``` + +Expected: `happy path OK`. + +- [ ] **Step 3: Verify each rule fires** + +Each command must FAIL with the quoted text. `2>&1 | grep` is used because `fail` writes to stderr. + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +T=helm/helm-framework-test-template + +# 1. not addressable +helm template hf $T --set liquibase.database.host=null 2>&1 | grep -q 'not addressable' && echo "1 OK" + +# 2. no changelog +helm template hf $T --set liquibase.changelog.file=null 2>&1 | grep -q 'no changelog is configured' && echo "2 OK" + +# 3. unknown engine, no override +helm template hf $T --set liquibase.database.engine=db2 2>&1 | grep -q 'is not one of sqlserver, postgresql, mysql, oracle' && echo "3 OK" + +# 4. unknown engine with urlTemplate but no port +helm template hf $T --set liquibase.database.engine=db2 \ + --set 'liquibase.database.urlTemplate=jdbc:db2://%s:%s/%s' 2>&1 \ + | grep -q 'there is no engine default to fall back on' && echo "4 OK" + +# 5. ambiguous credentials +helm template hf $T --set liquibase.database.existingSecret.name=ext 2>&1 \ + | grep -q 'ambiguous which credential wins' && echo "5 OK" + +# 6. changelog path typo +helm template hf $T --set liquibase.changelog.file=liquibase/nope.xml 2>&1 \ + | grep -q 'resolves to nothing in this chart' && echo "6 OK" + +# 7. migrations glob matches nothing +helm template hf $T --set 'liquibase.migrations.paths[0]=liquibase/nope/*.sql' 2>&1 \ + | grep -q 'matches no files in this chart' && echo "7 OK" + +# 8. name collision with a jobs[] entry +helm template hf $T --set liquibase.name=migrate 2>&1 \ + | grep -q 'collides with liquibase.name' && echo "8 OK" +``` + +Expected: all eight print `N OK`. Rule 8 relies on the test-template chart's `jobs[0].name` being `migrate` — confirm with `grep -n 'name: migrate' helm/helm-framework-test-template/values.yaml` if it does not fire. + +Also confirm rule 4 does NOT fire when the port is supplied, so the escape hatch actually works: + +```bash +helm template hf $T --set liquibase.database.engine=db2 \ + --set 'liquibase.database.urlTemplate=jdbc:db2://%s:%s/%s' \ + --set liquibase.database.port=50000 \ + | grep -q 'jdbc:db2://sql-server:50000/SmokeTestStore' && echo "escape hatch OK" +``` + +Expected: `escape hatch OK`. + +- [ ] **Step 4: Verify lint and the node suite** + +```bash +helm lint helm/helm-framework-test-template +npm test +``` + +Expected: both pass. + +- [ ] **Step 5: Commit** + +```bash +git add helm/helm-framework/templates/_values-validation.tpl +git commit -m "feat(liquibase): fail fast on unusable migration config + +Validates addressability, changelog presence, engine support, credential +ambiguity, and name collisions. Notably a changelog path or migrations glob +that resolves to nothing now fails instead of shipping an empty ConfigMap and +a migration that silently does nothing." +``` + +--- + +### Task 7: CI regression coverage and regenerated skill + +**Files:** +- Modify: `.github/workflows/lint.yaml` (two new steps after the RequestsOnly regression test) +- Modify: `plugins/helm-framework/skills/helm-framework/resources/values-reference.md` (regenerated) +- Modify: `plugins/helm-framework/skills/helm-framework/SKILL.md`, `plugins/helm-framework/skills/helm-framework-migration/*` (regenerated) + +**Interfaces:** +- Consumes: everything from Tasks 2-6. +- Produces: no template changes. + +- [ ] **Step 1: Add the CI regression steps** + +Append to `.github/workflows/lint.yaml`, after the `Regression test - RequestsOnly must render requests alongside limits` step, matching that file's existing style: + +```yaml + # The whole point of native Liquibase support is that application pods do + # not start before the migration finishes. That guarantee lives in two + # places that are easy to break independently: waitFor.active (which + # provisions the RBAC and the ServiceAccount token) and the Deployment's + # $waitForJobs list (which builds the kubectl wait arguments). Assert the + # migration Job is actually waited on, and that the ConfigMaps and Secret + # it depends on are hook-weighted ahead of it. + - name: "Regression test - liquibase migration must block pod startup" + working-directory: helm/helm-framework-test-template + run: | + set -eu + + rendered_lb=$(helm template helm-framework-test-template .) + echo "$rendered_lb" + + # `|| true` is required: grep -c exits 1 on a count of 0, which under + # `set -e` would abort before the diagnostic below can run. + wait_count=$(echo "$rendered_lb" | grep -c 'job/helm-framework-test-template-liquibase' || true) + if [[ "$wait_count" -lt 1 ]]; then + echo "::error::The Deployment's wait-for-job init container does not reference the Liquibase Job. Application pods would start before the migration completes - see helm-framework.waitFor.active in _helpers.tpl and the \$waitForJobs list in _deployment.yaml. Note liquibase.waitForIt defaults to true, so an 'and \$lb.enabled \$lb.waitForIt' check silently skips it." + exit 1 + fi + + rbac_count=$(echo "$rendered_lb" | grep -c 'helm-framework-test-template-wait-for-jobs' || true) + if [[ "$rbac_count" -lt 2 ]]; then + echo "::error::Expected a wait-for-jobs Role AND RoleBinding (2+ references) but found $rbac_count. Without them the init container cannot read Job status and pod startup hangs until the wait times out - see helm-framework.deployment.waitFor.rbac in _role.tpl." + exit 1 + fi + + cm_weight=$(echo "$rendered_lb" | grep -c 'helm.sh/hook-weight: "-20"' || true) + if [[ "$cm_weight" -lt 3 ]]; then + echo "::error::Expected at least 3 resources at hook-weight -20 (changelog ConfigMap, migrations ConfigMap, credentials Secret) but found $cm_weight. At a weight >= the Job's -10 they would be created after it, and the migration pod would fail to mount them." + exit 1 + fi + + # database.existingSecret is the only supported path for production + # credentials. If the generated Secret is still rendered alongside it, a + # plaintext password from values.yaml lands in the cluster anyway. + - name: "Regression test - existingSecret must suppress the generated Secret" + working-directory: helm/helm-framework-test-template + run: | + set -eu + + rendered_ext=$(helm template helm-framework-test-template . \ + --set liquibase.database.password=null \ + --set liquibase.database.existingSecret.name=external-db-credentials) + echo "$rendered_ext" + + gen_count=$(echo "$rendered_ext" | grep -c 'name: helm-framework-test-template-liquibase-env' || true) + if [[ "$gen_count" -ne 0 ]]; then + echo "::error::database.existingSecret.name is set but the generated credentials Secret was still rendered ($gen_count references). See the existingSecret guard in _secret-liquibase.tpl." + exit 1 + fi + + ref_count=$(echo "$rendered_ext" | grep -c 'name: "external-db-credentials"' || true) + if [[ "$ref_count" -lt 2 ]]; then + echo "::error::Expected the Job's username and password to both reference external-db-credentials (2 secretKeyRefs) but found $ref_count. See helm-framework.liquibase.env in _helpers.tpl." + exit 1 + fi +``` + +Note the release name in CI is `helm-framework-test-template`, not the `hf` used in local verification, so the rendered `fullname` differs — that is why these greps omit the `hf-` prefix. + +- [ ] **Step 2: Verify the CI assertions pass locally** + +Run the same commands with CI's release name: + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework/helm/helm-framework-test-template +helm template helm-framework-test-template . | grep -c 'job/helm-framework-test-template-liquibase' +helm template helm-framework-test-template . | grep -c 'helm-framework-test-template-wait-for-jobs' +helm template helm-framework-test-template . | grep -c 'helm.sh/hook-weight: "-20"' +helm template helm-framework-test-template . \ + --set liquibase.database.password=null \ + --set liquibase.database.existingSecret.name=external-db-credentials \ + | grep -c 'name: helm-framework-test-template-liquibase-env' || true +``` + +Expected: `1` or more; `2` or more; `3` or more; `0`. + +- [ ] **Step 3: Validate against the Kubernetes schema, as CI does** + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +curl -sSL https://github.com/yannh/kubeconform/releases/download/v0.8.0/kubeconform-darwin-amd64.tar.gz | tar xz kubeconform +cd helm/helm-framework-test-template +helm template helm-framework-test-template . | ../../kubeconform -strict -summary -schema-location default +cd ../.. && rm -f kubeconform +``` + +Expected: a summary with `0 errors`. The new ConfigMaps, Secret, and Job must all validate. + +- [ ] **Step 4: Regenerate the Claude Code skill** + +The version argument only stamps the generated files; use the current released version. + +```bash +cd /Users/piotr.laczykowski/Repos/personal/helm-framework +node scripts/generate-skill.mjs --version 1.2.1 +git diff --stat plugins/ +``` + +Expected: `values-reference.md` and the migration skill's values contract gain the `liquibase` keys, and the skill's section list gains `LIQUIBASE DATABASE MIGRATIONS` (from the `# === / # TITLE / # ===` header added in Task 2). If the section does not appear, the header in `values.yaml` is malformed — it must be exactly three lines: `# =…=`, `# TITLE`, `# =…=`. + +- [ ] **Step 5: Run the full suite one last time** + +```bash +npm test +helm lint helm/helm-framework-test-template +helm template hf helm/helm-framework-test-template > /dev/null && echo "render OK" +``` + +Expected: all three pass. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/lint.yaml plugins/ +git commit -m "test(liquibase): add CI regression coverage and regenerate skill + +Asserts the migration Job is waited on, its RBAC exists, its ConfigMaps and +Secret are hook-weighted ahead of it, and that existingSecret suppresses the +generated Secret. Regenerates the plugin skill so the values reference +includes the liquibase block." +``` + +--- + +## Self-Review + +**Spec coverage.** Every spec section maps to a task: values API and engine defaults → Tasks 2 and 3; composed environment → Task 3; rendered resources → Tasks 2, 3, 4; name helpers → Task 2; shared Job partials → Task 1; wait-for-job wiring → Task 5; validation (all eight rules) → Task 6; testing → the verification steps in every task plus Task 7; documentation → Task 2 (values.yaml) and Task 7 (skill regeneration). The spec's "Migration path for the reference chart" section is deliberately *not* a task — it describes work in a different repository and is out of scope for this plan. + +**Placeholder scan.** No TBD/TODO, no "add appropriate validation" — all eight validation rules are written out with their exact messages. No "similar to Task N": the `_job.yaml` before/after snippets are quoted in full even where they repeat structure. + +**Type consistency.** Helper names are consistent across tasks: `helm-framework.liquibase.changelog-key` is defined in Task 2 and consumed under that exact name in Tasks 3 and 4; `helm-framework.liquibase.has-migrations` likewise in Tasks 2, 4, and 6; `helm-framework.job.resources` takes `dict "root" … "resources" …` in Task 1 and is called with exactly that shape in Tasks 1 and 4. The `liquibase-changelog` / `liquibase-migrations` volume names match between the `volumeMounts` and `volumes` blocks in Task 4. + +**Known fragility.** Task 1's byte-identical requirement is the highest-risk step in the plan; the whitespace conventions (`nindent` at the caller, relative indent 0 in the partial, `with (include … | trim)` for partials that can be empty) are stated in the partials file's own header comment so a future editor does not have to re-derive them. diff --git a/docs/superpowers/specs/2026-08-27-liquibase-native-support-design.md b/docs/superpowers/specs/2026-08-27-liquibase-native-support-design.md new file mode 100644 index 0000000..6c0c4e6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-liquibase-native-support-design.md @@ -0,0 +1,411 @@ +# Native Liquibase migration support + +Date: 2026-08-27 +Status: Approved design, not yet implemented + +## Problem + +Charts that need a database migration to complete before their pods start +currently hand-roll the whole mechanism. The reference case is +`spark-customer-webhooks-agentcontainer`, which carries its own singular +`job:` values block, its own `rolePodUpdater` RBAC, its own +`initContainer.waitForEnabled` flag, two ConfigMap templates (changelog and +migration SQL), and a Secret template for the Liquibase connection +environment — none of which the framework can express. + +The framework already provides most of the underlying machinery: + +- `jobs[]` renders one pre-install/pre-upgrade hook Job per enabled entry + (`templates/_job.yaml`). +- `waitForIt: true` on a job entry renders a `kubectl wait` init container in + the Deployment, auto-provisions a `-wait-for-jobs` Role and + RoleBinding, and forces `automountServiceAccountToken` + (`templates/_deployment.yaml`, `_role.tpl`, `_helpers.tpl`, + `_serviceaccount.tpl`). + +What is missing is Liquibase-shaped: JDBC URL composition, credential +plumbing, ConfigMaps for the changelog and migration files, and image / +command defaults. There is no ConfigMap template in the framework at all +today. + +## Goals + +- A consuming chart declares a `liquibase:` block and gets a complete, + correct pre-start migration: Job, changelog ConfigMap, migrations + ConfigMap, connection Secret, and the wait-for-job init container that + blocks the Deployment until the migration completes. +- Migration SQL lives as real files in the consuming chart, not as YAML + blobs. +- Credentials have a supported path to an external secret manager, not only + plaintext values. +- No regression to the existing `jobs[]` path. + +## Non-goals + +- Tool neutrality. This is deliberately Liquibase-specific; a + `dbMigration.tool` abstraction was considered and rejected as premature. +- Framework knowledge of any JDBC driver: no engine list, no default ports, no + built-in URL templates. The chart author supplies the URL or its printf + shape, and the framework substitutes and stops there. +- Rollback, diff, or any Liquibase command beyond what `command`/`args` + expose. + +## Verified assumptions + +`.Files` inside a library-chart template resolves to the **consuming** +chart's files, because the consuming chart's `templates/deployment.yaml` +passes its own root context (`.`) into the library `include`. Verified with a +throwaway two-chart probe: a library template rendered from a parent chart +read the parent's `liquibase/changelog.xml` via `.Files.Get` and enumerated +`liquibase/migrations/*.sql` via `.Files.Glob`, with `base $path` yielding +clean ConfigMap keys. This is the foundation of the file-sourced design +below. + +## Values API + +```yaml +liquibase: + enabled: false + name: liquibase # suffix for Job/ConfigMap/Secret names + waitForIt: true # block the Deployment until the Job completes + restartPolicy: OnFailure + backoffLimit: 6 + image: + repository: liquibase/liquibase + tag: "4.33" + pullPolicy: IfNotPresent + command: ["liquibase"] + args: ["update", "--changeLogFile=changelog.xml"] + log: + level: INFO # SEVERE WARNING INFO FINE OFF + database: + url: "" # literal JDBC URL, tpl-rendered; wins over urlTemplate + urlTemplate: "" # printf, exactly three %s: host, port, name + host: "" # required when urlTemplate is used + port: 0 # required when urlTemplate is used; no default + name: "" # required when urlTemplate is used + userName: "" + password: "" + existingSecret: + name: "" # when set, credentials come from secretKeyRef + usernameKey: username + passwordKey: password + changelog: + file: "" # path within the consuming chart + content: "" # inline alternative; `file` wins if both set + mountPath: /liquibase/changelog.xml + migrations: + paths: [] # globs within the consuming chart + files: {} # inline alternative: filename -> content + mountPath: /liquibase/migrations + resources: {} # falls back to .Values.resources; VPA-aware + extraEnvVars: [] + extraEnvFrom: [] # tpl-rendered + volumes: [] + volumeMounts: [] +``` + +### No per-engine defaults + +**Revised during PR review (#9).** An earlier revision of this design had a +`database.engine` enum (`sqlserver` / `postgresql` / `mysql` / `oracle`) +selecting a default port and a built-in JDBC URL template. That is removed. +Two objections, both sound: + +- A closed enum in a library chart's public API is a liability. Every driver + the framework does not list is a chart that cannot express itself without an + escape hatch, and adding, renaming, or correcting an entry later is a + behaviour change for every chart pinned to it — a breaking change dressed up + as a default. +- Baked-in URL templates hide the connection string from the chart author. + Real deployments carry vendor-specific parameters (`encrypt`, + `trustServerCertificate`, `oracle.net.ssl_server_dn_match`, connection-pool + and timeout options); a fixed three-substitution template silently cannot + express them, and the failure shows up at migration time, not render time. + +The framework therefore ships **no** knowledge of any driver. The chart author +always writes the URL shape, one of two ways: + +| Value | Shape | When | +|---|---|---| +| `database.url` | a literal JDBC URL, `tpl`-rendered | any driver, any vendor-specific parameter; the framework never parses or rewrites it | +| `database.urlTemplate` | named placeholders `{host}`, `{port}`, `{name}` | keeps host/port/name as separate values, so a per-environment overlay can change just the host | + +`url` wins when both are set. Neither has a default, so one is always +required — enforced by validation rather than guessed. + +Common templates, documented in `values.yaml` as examples rather than +implemented as code: + +``` +SQL Server: jdbc:sqlserver://{host}:{port};database={name}; +PostgreSQL: jdbc:postgresql://{host}:{port}/{name} +MySQL: jdbc:mysql://{host}:{port}/{name} +Oracle: jdbc:oracle:thin:@{host}:{port}/{name} +``` + +Note that the SQL Server example omits `encrypt=false`: a chart that needs +transport encryption relaxed writes it into its own `urlTemplate`, where it is +visible in that chart's values and in review. + +### Why the placeholders are named, not positional + +**Revised during PR review (#9).** `urlTemplate` was first specified as a +`printf` template taking three `%s` verbs filled with host, port, name in that +order, and validation checked that exactly three verbs were present. A reviewer +pointed out the hole: verb *count* is checkable, verb *meaning* is not. A +template written `jdbc:custom:%s@%s:%s` intending name/host/port receives +host/port/name, yielding `jdbc:custom:sql-server@1433/MyStore` — a +syntactically valid URL that renders cleanly, passes validation, and fails only +when Liquibase tries to connect. Exactly the silent-failure class the rest of +this design goes out of its way to catch at render time. + +Named placeholders cannot be mis-ordered, and they compose better: each is +independently optional, so a driver URL needing only host and port simply omits +`{name}`. Substitution is `replace`, not `printf`. + +The cost is that charts migrating from a hand-rolled printf +`connectionStringTemplate` must rewrite `%s` into named placeholders rather +than renaming the key. Validation makes that a render-time error with the +rewrite spelled out, rather than a wrong URL: a `%s`, `%d`, `%v` or `%q` verb +anywhere in `urlTemplate` is rejected outright. + +Full `urlTemplate` validation: + +- printf verbs (`%s`/`%d`/`%v`/`%q`) rejected, with the named-placeholder + rewrite shown. +- Unknown placeholders rejected — `{database}` and `{dbname}` are the obvious + near-misses — listing the supported set and pointing at `database.url` for + anything else. +- A template with no placeholders at all rejected: it would render as a + constant, so `database.url` is the right home for it. +- Every placeholder actually used must have its value set; the message names + which. Placeholders *not* used impose no requirement. + +The supported placeholder names live in one helper, +`helm-framework.liquibase.urlTemplate.placeholders`, read by both the URL +composer and the validation, so the two cannot disagree about what is legal. + +### Composed environment + +The Job container receives: + +| Variable | Source | +|---|---| +| `LIQUIBASE_COMMAND_URL` | plain `value:`, composed as above | +| `LIQUIBASE_COMMAND_USERNAME` | `secretKeyRef` — the generated Secret, or `existingSecret` when set | +| `LIQUIBASE_COMMAND_PASSWORD` | `secretKeyRef` — the generated Secret, or `existingSecret` when set | +| `LIQUIBASE_LOG_LEVEL` | `log.level` | +| `LIQUIBASE_SEARCH_PATH` | `dir changelog.mountPath` (so `/liquibase` by default), making the default `--changeLogFile=changelog.xml` resolve against the mount even if `mountPath` is overridden | + +The URL is a plain `value:` rather than a Secret key: a JDBC URL is a +connection target, not a credential, and keeping it in the pod spec makes +`kubectl describe job` diagnostic. Only the username and password are +Secret-sourced. Consequently the generated Secret holds exactly two keys, +`LIQUIBASE_COMMAND_USERNAME` and `LIQUIBASE_COMMAND_PASSWORD`, and is skipped +entirely when `existingSecret.name` is set. + +`extraEnvVars` is appended, and `extraEnvFrom` is passed through `tpl` so it +can reference other helpers (matching how the reference chart references a +Secret name through an `include`). + +## Rendered resources + +All are Helm hooks, so they are created before the release's main manifests. +Hook weights matter: the ConfigMaps and Secret must exist before the Job +pod starts. + +| Template file | Define | Resource | Hook weight | +|---|---|---|---| +| `_liquibase.tpl` | `helm-framework.deployment.liquibase` | Job `-` | `-10` | +| `_configmap-liquibase.tpl` | `helm-framework.deployment.liquibase-configmaps` | ConfigMaps `--changelog`, `--migrations` | `-20` | +| `_secret-liquibase.tpl` | `helm-framework.deployment.liquibase-secret` | Secret `--env` | `-20` | + +The Secret is skipped entirely when `database.existingSecret.name` is set. +Each define is added to `$documents` in `_deployment-global.tpl`. + +The migrations ConfigMap is skipped when neither `migrations.paths` nor +`migrations.files` yields anything, since a self-contained changelog is +valid. The changelog ConfigMap is always rendered when `enabled`. + +ConfigMap keys are `base $path` for globbed files and the map key for inline +files, so `liquibase/migrations/001_init.sql` mounts as +`/liquibase/migrations/001_init.sql`. + +When both `migrations.paths` and `migrations.files` are set, the two sources +are merged into one ConfigMap and globbed files win on key collision — +matching the changelog precedence, where `file` beats `content`. Files on +disk are the source of truth in both cases. + +### Name helpers + +Added to `_helpers.tpl`, so consuming charts and the templates share one +definition of every generated name: + +| Helper | Returns | +|---|---| +| `helm-framework.liquibase.enabled` | `"true"` when `liquibase.enabled` | +| `helm-framework.liquibase.job-name` | `-` | +| `helm-framework.liquibase.changelog-configmap-name` | `--changelog` | +| `helm-framework.liquibase.migrations-configmap-name` | `--migrations` | +| `helm-framework.liquibase.env-secret-name` | `--env` | +| `helm-framework.liquibase.url` | the composed JDBC URL | +| `helm-framework.liquibase.env` | the full `env:` list | + +`_values.tpl` gains nothing for Liquibase. That file is the per-value defaults +layer, and with the engine enum removed there are no Liquibase defaults left to +host there — `helm-framework.liquibase.url` reads `database.url` / +`database.urlTemplate` directly. + +## Refactor: shared Job partials + +The new Job template is standalone rather than a synthetic `jobs[]` entry. +To avoid maintaining two divergent pod specs, the common parts of +`_job.yaml` are extracted into partials that both templates include: + +| Partial | Covers | +|---|---| +| `helm-framework.job.podAnnotations` | appSettings / helmFrameworkSettings checksums plus `podAnnotations` | +| `helm-framework.job.podLabels` | `helm-framework.labels` plus `podLabels` | +| `helm-framework.job.caBundleInit` | the `ca-bundle-init` init container | +| `helm-framework.job.resources` | VPA-aware resources; takes `dict "root" $ "resources" $res` | +| `helm-framework.job.commonVolumes` | appSettings, cert-combine, scripts, authorities volumes | +| `helm-framework.job.commonVolumeMounts` | appSettings and ca-bundle mounts | +| `helm-framework.job.scheduling` | `nodeSelector`, `affinity`, `tolerations` | + +`imagePullSecrets` is deliberately *not* extracted: it is four lines of pure +`with` + `toYaml` passthrough with no logic that can drift, so a partial would +add byte-identity risk to the refactor without reducing maintenance. + +This refactor lands first and must be provably behaviour-preserving: the +`helm template` output of `helm/helm-framework-test-template` must diff empty +before and after. Only then is `_liquibase.tpl` written against the +partials. + +The Liquibase Job's container is named `liquibase` rather than +`$root.Chart.Name` (which is what `_job.yaml` uses), because the image is not +the application image. + +## Wait-for-job wiring + +Two call sites currently iterate `.Values.jobs`: + +- `helm-framework.waitFor.active` in `_helpers.tpl` — also returns `true` + when `liquibase.enabled` and `liquibase.waitForIt`. +- the `$waitForJobs` list in `_deployment.yaml` — prepends + `job/-` under the same condition. + +Everything downstream follows for free: `automountServiceAccountToken` +(`_deployment.yaml`), ServiceAccount auto-creation (`_serviceaccount.tpl`, +`_helpers.tpl`), and the `-wait-for-jobs` Role and RoleBinding +(`_role.tpl`). No new RBAC code, and `initContainer.waitFor` (image, +timeout, command/args override) applies unchanged. + +## Validation + +Added to `helm-framework.values.validate` in `_values-validation.tpl`, +following that file's existing `fail (printf ...)` style. All only apply +when `liquibase.enabled`. + +1. Neither `database.url` nor `database.urlTemplate` set — with no per-driver + defaults, one of the two is always required. +2. Neither `changelog.file` nor `changelog.content` set — there is nothing to + migrate from. +3. `database.urlTemplate` uses a placeholder whose value is unset. The message + names which, since substitution would otherwise put empty strings into a + syntactically valid but unusable URL. +4. `database.urlTemplate` is malformed — a printf verb, an unknown + placeholder, or no placeholders at all. See "Why the placeholders are + named, not positional" above for each case. +5. `database.existingSecret.name` set together with a non-empty + `database.password` — ambiguous about which wins. +6. `changelog.file` set but `.Files.Get` returns empty — a typo'd path would + otherwise ship a silently empty ConfigMap and a migration that does + nothing. +7. `migrations.paths` non-empty but `.Files.Glob` matches zero files — same + silent-success failure mode. +8. `liquibase.name` collides with the name of an enabled `jobs[]` entry — + both would render the same Job name. + +Not validated, documented instead: the 1 MiB ConfigMap size limit, and that +migration files must be valid UTF-8 text (the ConfigMap `data` field cannot +carry binary content). + +## Testing + +- `helm/helm-framework-test-template` gains a `liquibase:` block with + `enabled: true`, plus fixtures `liquibase/changelog.xml` and + `liquibase/migrations/001_init.sql`. The existing `lint.yaml` CI workflow + (`helm lint` and `ct lint` over the test template) then covers the new + templates with no workflow changes. +- The `_job.yaml` refactor is gated on a byte-identical `helm template` diff + of the test template chart, captured before the refactor begins. +- `npm test` must pass. `scripts/generate-skill.mjs` enforces a values + contract: every top-level key read by a template must be declared in + `helm/helm-framework/values.yaml`, and vice versa. Adding `liquibase` to + both sides satisfies it; `scripts/generate-skill.test.mjs` exercises the + guard. + + Two consequences for sequencing. First, the guard is bidirectional, so the + `liquibase:` declaration in `values.yaml` and the first template that reads + `.Values.liquibase` must land in the *same* commit — either alone leaves + `npm test` red. Second, the read-set is detected by the regex + `/\.Values\.([A-Za-z0-9_]+)/`, so at least one template must spell it + literally as `.Values.liquibase`; the framework's defensive + `(.Values.liquibase).foo` form satisfies this, but a hypothetical + `(.Values).liquibase` would not. +- Manual verification that the rendered Deployment's `wait-for-job` init + container lists the Liquibase Job, and that hook weights order the + ConfigMaps and Secret before the Job. + +## Documentation + +- A `LIQUIBASE` section in `helm/helm-framework/values.yaml`, following the + file's existing convention of a live default block followed by a commented + full reference. + The chart's own `helm/helm-framework/README.md` is auto-generated from those + `# --` comments by the `Docs` workflow (`losisin/helm-docs-github-action`), + so it is never hand-edited. The root `README.md` has no per-template table — + it points at the generated chart README — so no root README change is + needed. +- Regenerated plugin skill via `node scripts/generate-skill.mjs --version + `, which rewrites + `plugins/helm-framework/skills/helm-framework/resources/values-reference.md` + and the migration skill's values contract. + +## Migration path for the reference chart + +`spark-customer-webhooks-agentcontainer` after this lands: + +- `job:` becomes `liquibase:`; `job.database.connectionStringTemplate` + becomes `liquibase.database.urlTemplate` (keeping its `encrypt=false;` + explicitly). +- `initContainer.waitForEnabled` is deleted — `liquibase.waitForIt` + defaults to true. +- `rolePodUpdater` is deleted — the framework provisions the wait RBAC. +- The changelog ConfigMap template, migrations ConfigMap template, and + Liquibase env Secret template are deleted, along with the + `liquibase-configmap-name`, `migration-configmap-name`, and + `liquibase-env-secret-name` helpers, and the `volumes` / `volumeMounts` + entries that wired them. +- The plaintext `database.password` should move to + `database.existingSecret`, wired to the chart's existing ExternalSecret if + it has one. + +## Rejected alternatives + +- **Synthetic `jobs[]` entry.** A helper composing a job dict from + `liquibase` and prepending it to `.Values.jobs`, with `_job.yaml` and the + wait logic iterating the helper's output. Maximum reuse and zero + Liquibase awareness in the wait machinery, but the helper must round-trip + through `fromYaml`, and the resulting indirection makes the Job template + harder to read. Rejected in favour of a standalone template plus extracted + partials, which reaches the same de-duplication more legibly. +- **Preset without ConfigMap generation.** Framework composes only the + connection and the Job; the consuming chart keeps its own changelog and + migrations ConfigMaps. Smaller surface, but leaves most of the reference + chart's boilerplate in place. +- **Tool-neutral `dbMigration` abstraction.** Deferred until a second + migration tool is actually needed. +- **Documentation-only recipe.** Every consuming chart would repeat the + boilerplate. diff --git a/helm/helm-framework-test-template/liquibase/changelog.xml b/helm/helm-framework-test-template/liquibase/changelog.xml new file mode 100644 index 0000000..af93c46 --- /dev/null +++ b/helm/helm-framework-test-template/liquibase/changelog.xml @@ -0,0 +1,8 @@ + + + + diff --git a/helm/helm-framework-test-template/liquibase/migrations/001_init.sql b/helm/helm-framework-test-template/liquibase/migrations/001_init.sql new file mode 100644 index 0000000..6c8175d --- /dev/null +++ b/helm/helm-framework-test-template/liquibase/migrations/001_init.sql @@ -0,0 +1,3 @@ +--liquibase formatted sql +--changeset helm-framework:001-init +CREATE TABLE helm_framework_smoke (id INT NOT NULL); diff --git a/helm/helm-framework-test-template/values.yaml b/helm/helm-framework-test-template/values.yaml index cf62050..14543cf 100644 --- a/helm/helm-framework-test-template/values.yaml +++ b/helm/helm-framework-test-template/values.yaml @@ -409,6 +409,28 @@ jobs: - name: JOB_MODE value: migrate +# ============================================================================= +# LIQUIBASE DATABASE MIGRATIONS +# ============================================================================= + +# Native Liquibase migration — enabled so `helm template` renders the Job, the +# changelog/migrations ConfigMaps, the connection Secret, and the wait-for-job +# init container in the Deployment. +liquibase: + enabled: true + database: + urlTemplate: "jdbc:sqlserver://{host}:{port};database={name};" + host: sql-server + port: 1433 + name: SmokeTestStore + userName: sa + password: smoke-test-password + changelog: + file: liquibase/changelog.xml + migrations: + paths: + - liquibase/migrations/*.sql + # ============================================================================= # SIDECARS # ============================================================================= diff --git a/helm/helm-framework/README.md b/helm/helm-framework/README.md index 92e5280..c72c2a2 100644 --- a/helm/helm-framework/README.md +++ b/helm/helm-framework/README.md @@ -32,6 +32,24 @@ Base Helm framework library | initContainers | list | `[]` | Extra custom init containers (list; raw Kubernetes container specs, rendered through `tpl`). Appended after the built-in CA-bundle / wait-for-job init containers (the singular `initContainer` above). Empty by default. | | jobs | list | `[]` | Pre-install/pre-upgrade hook Jobs (list; one Job per enabled entry). Empty by default. | | keda | object | `{"enabled":false,"scaledObject":{"advanced":{},"annotations":{},"cooldownPeriod":300,"fallback":{},"idleReplicaCount":0,"maxReplicaCount":10,"minReplicaCount":1,"pollingInterval":30,"scaleTargetRef":{"apiVersion":"apps/v1","envSourceContainerName":"","kind":"Deployment","name":""},"triggers":[]},"triggerAuthentication":{"env":[],"name":"","podIdentity":{},"secretTargetRef":[]}}` | KEDA ScaledObject and TriggerAuthentication (event-driven autoscaling). | +| liquibase | object | `{"args":["update","--changeLogFile=changelog.xml"],"backoffLimit":6,"changelog":{"content":"","file":"","mountPath":"/liquibase/changelog.xml"},"command":["liquibase"],"database":{"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"host":"","name":"","password":"","port":0,"url":"","urlTemplate":"","userName":""},"enabled":false,"extraEnvFrom":[],"extraEnvVars":[],"image":{"pullPolicy":"IfNotPresent","repository":"liquibase/liquibase","tag":"4.33"},"log":{"level":"INFO"},"migrations":{"files":{},"mountPath":"/liquibase/migrations","paths":[]},"name":"liquibase","resources":{},"restartPolicy":"OnFailure","volumeMounts":[],"volumes":[],"waitForIt":true}` | Native Liquibase migration support. Renders a pre-install/pre-upgrade hook Job that runs Liquibase against your database, plus ConfigMaps holding your changelog and migration SQL and a Secret holding the database credentials. With `waitForIt` (the default) the Deployment's pods get a wait-for-job init container and will not start until the migration Job completes. Changelog and migration files are read from YOUR chart's directory via `.Files`, so the SQL lives as real files beside your chart rather than inside values.yaml. | +| liquibase.changelog.content | string | `""` | Inline changelog alternative. `file` wins when both are set. | +| liquibase.changelog.file | string | `""` | Path to the changelog file within YOUR chart, e.g. "liquibase/changelog.xml". Read via `.Files.Get`. | +| liquibase.changelog.mountPath | string | `"/liquibase/changelog.xml"` | Where the changelog is mounted in the Job container. Its basename is both the ConfigMap key and the mount subPath, and its directory becomes LIQUIBASE_SEARCH_PATH — so the default `--changeLogFile=changelog.xml` keeps resolving if you change this. | +| liquibase.database.existingSecret | object | `{"name":"","passwordKey":"password","usernameKey":"username"}` | Source credentials from an existing Secret instead of `userName` and `password`. When `name` is set, no connection Secret is generated and the username/password come from secretKeyRef — use this with ExternalSecret rather than putting a production password in values.yaml. | +| liquibase.database.host | string | `""` | Database host. Required when `urlTemplate` uses `{host}`. | +| liquibase.database.name | string | `""` | Database name. Required when `urlTemplate` uses `{name}`. | +| liquibase.database.port | int | `0` | Database port. Required when `urlTemplate` uses `{port}`; there is no default, since the framework does not know your driver. | +| liquibase.database.url | string | `""` | Literal JDBC URL, rendered through `tpl` so it can reference other values. The most direct option and the one to reach for when the connection needs vendor-specific parameters: the framework never parses or rewrites it. Wins outright when `urlTemplate` is also set. Example: "jdbc:sqlserver://sql-server:1433;database=MyStore;encrypt=false;" | +| liquibase.database.urlTemplate | string | `""` | Alternative to `url`: a URL template using the NAMED placeholders `{host}`, `{port}` and `{name}`. Use it to keep host/port/name as separate values, so a per-environment overlay can change just the host. Each placeholder is optional — use only the ones your driver's URL needs — but any you do use must have a value set below. The framework deliberately ships NO per-driver defaults, so a new or renamed driver can never turn into a breaking change here. Examples: SQL Server: "jdbc:sqlserver://{host}:{port};database={name};" PostgreSQL: "jdbc:postgresql://{host}:{port}/{name}" MySQL: "jdbc:mysql://{host}:{port}/{name}" Oracle: "jdbc:oracle:thin:@{host}:{port}/{name}" Placeholders are named, not positional: printf-style `%s` verbs are rejected, because a template whose verb order differed from host, port, name silently produced a valid-looking but wrong URL. For anything these three cannot express, use `url` instead. | +| liquibase.extraEnvFrom | list | `[]` | Extra raw envFrom sources (secretRef/configMapRef). Rendered through `tpl`, so entries may reference chart helpers. | +| liquibase.extraEnvVars | list | `[]` | Extra plain env vars (list of {name, value}), appended after the composed LIQUIBASE_* variables. Rendered through `tpl`. | +| liquibase.log.level | string | `"INFO"` | Liquibase log level, passed as LIQUIBASE_LOG_LEVEL: SEVERE, WARNING, INFO, FINE, or OFF. | +| liquibase.migrations.files | object | `{}` | Inline alternative, filename -> content. Merged with `paths`; globbed files win on key collision. Note the 1 MiB ConfigMap limit, and that ConfigMap data must be valid UTF-8 text. | +| liquibase.migrations.paths | list | `[]` | Globs within YOUR chart, e.g. ["liquibase/migrations/*.sql"]. Read via `.Files.Glob`; each file's basename becomes its ConfigMap key. | +| liquibase.name | string | `"liquibase"` | Suffix for every generated resource name: Job `-`, plus `-changelog` / `-migrations` / `-env`. Must not collide with a `jobs[]` name. | +| liquibase.resources | object | `{}` | Job container resources. Falls back to the top-level `resources` when empty, and is VPA-aware like `jobs[].resources`. | +| liquibase.waitForIt | bool | `true` | Block Deployment pod startup until the migration Job completes. Reuses the same wait-for-job init container and auto-provisioned RBAC as `jobs[].waitForIt` — see `initContainer.waitFor` to tune image and timeout. | | nameOverride | string | `""` | Override the chart name used in resource names and labels. | | nodeSelector | object | `{}` | Node selector for pod scheduling. | | podAnnotations | object | `{}` | Extra annotations added to the pod template. | diff --git a/helm/helm-framework/templates/_configmap-liquibase.tpl b/helm/helm-framework/templates/_configmap-liquibase.tpl new file mode 100644 index 0000000..85aa19b --- /dev/null +++ b/helm/helm-framework/templates/_configmap-liquibase.tpl @@ -0,0 +1,60 @@ +{{/* +ConfigMaps holding the Liquibase changelog and migration SQL. Both are +pre-install/pre-upgrade hooks at weight -20, below the migration Job at -10, +so they exist before the Job pod starts. + +Content comes from the CONSUMING chart's files: the chart that includes +helm-framework.deployment.global passes its own root context, so `.Files` +resolves against that chart's directory, not the library's. Inline +`changelog.content` / `migrations.files` are the fallback for charts that +would rather keep everything in values.yaml. +*/}} +{{- define "helm-framework.deployment.liquibase-configmaps" -}} +{{- if (.Values.liquibase).enabled -}} +{{- $changelog := "" -}} +{{- with ((.Values.liquibase).changelog).file -}} +{{- $changelog = $.Files.Get . -}} +{{- end -}} +{{- if not $changelog -}} +{{- $changelog = (((.Values.liquibase).changelog).content | default "") -}} +{{- end -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "helm-framework.liquibase.changelog-configmap-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-weight: "-20" + labels: + {{- include "helm-framework.labels" . | nindent 4 }} +data: + {{ include "helm-framework.liquibase.changelog-key" . }}: | +{{ $changelog | trim | indent 4 }} +{{- if eq (include "helm-framework.liquibase.has-migrations" .) "true" }} +{{- $migrations := dict -}} +{{- range $name, $content := ((.Values.liquibase).migrations).files -}} +{{- $_ := set $migrations $name (toString $content) -}} +{{- end -}} +{{- range $pattern := ((.Values.liquibase).migrations).paths -}} +{{- range $path, $bytes := $.Files.Glob $pattern -}} +{{- $_ := set $migrations (base $path) (toString $bytes) -}} +{{- end -}} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "helm-framework.liquibase.migrations-configmap-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-weight: "-20" + labels: + {{- include "helm-framework.labels" . | nindent 4 }} +data: + {{- range $name, $content := $migrations }} + {{ $name }}: | +{{ $content | trim | indent 4 }} + {{- end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/helm/helm-framework/templates/_deployment-global.tpl b/helm/helm-framework/templates/_deployment-global.tpl index b112837..7d02a7d 100644 --- a/helm/helm-framework/templates/_deployment-global.tpl +++ b/helm/helm-framework/templates/_deployment-global.tpl @@ -7,6 +7,9 @@ (include "helm-framework.deployment.pdb" .) (include "helm-framework.deployment.secret-tls" .) (include "helm-framework.deployment.job" .) + (include "helm-framework.deployment.liquibase-configmaps" .) + (include "helm-framework.deployment.liquibase-secret" .) + (include "helm-framework.deployment.liquibase" .) (include "helm-framework.deployment.secret-scripts" .) (include "helm-framework.deployment.secret-authorities" .) (include "helm-framework.deployment.secret-env" .) diff --git a/helm/helm-framework/templates/_deployment.yaml b/helm/helm-framework/templates/_deployment.yaml index 3e9275b..4544166 100644 --- a/helm/helm-framework/templates/_deployment.yaml +++ b/helm/helm-framework/templates/_deployment.yaml @@ -64,8 +64,13 @@ spec: hostAliases: {{- toYaml . | nindent 8 }} {{- end }} - {{- /* Collect names of enabled jobs flagged with waitForIt: true */ -}} + {{- /* Collect names of enabled jobs flagged with waitForIt: true, plus + the native Liquibase migration Job (which waits by default) */ -}} {{- $waitForJobs := list -}} + {{- $lb := (.Values.liquibase | default dict) -}} + {{- if and $lb.enabled (or (not (hasKey $lb "waitForIt")) $lb.waitForIt) -}} + {{- $waitForJobs = append $waitForJobs (printf "job/%s" (include "helm-framework.liquibase.job-name" $)) -}} + {{- end -}} {{- range $index, $job := .Values.jobs -}} {{- if and $job.enabled $job.waitForIt -}} {{- $jobName := $job.name | default (printf "job-%d" $index) -}} diff --git a/helm/helm-framework/templates/_helpers.tpl b/helm/helm-framework/templates/_helpers.tpl index ff4ef81..3210171 100644 --- a/helm/helm-framework/templates/_helpers.tpl +++ b/helm/helm-framework/templates/_helpers.tpl @@ -70,8 +70,13 @@ Create the name of the service account to use {{- end -}} {{/* -Returns "true" when at least one enabled job is flagged with waitForIt. +Returns "true" when at least one enabled job is flagged with waitForIt, or +when the native Liquibase migration is enabled and not opted out of waiting. Used to auto-provision RBAC so the wait-for-job init container can read jobs. + +liquibase.waitForIt defaults to TRUE, so the check is "key absent OR truthy" — +`and $lb.enabled $lb.waitForIt` would wrongly skip the wait whenever a chart +enables liquibase without restating waitForIt. */}} {{- define "helm-framework.waitFor.active" -}} {{- $active := false -}} @@ -80,6 +85,10 @@ Used to auto-provision RBAC so the wait-for-job init container can read jobs. {{- $active = true -}} {{- end -}} {{- end -}} +{{- $lb := (.Values.liquibase | default dict) -}} +{{- if and $lb.enabled (or (not (hasKey $lb "waitForIt")) $lb.waitForIt) -}} +{{- $active = true -}} +{{- end -}} {{- if $active }}true{{- end -}} {{- end -}} @@ -253,3 +262,150 @@ Consumers that want a hardened posture set the fields themselves, e.g. {{- printf "false" }} {{- end -}} {{- end -}} + +{{/* +Liquibase resource names. Everything derives from +- so the Job, its ConfigMaps, and its Secret stay +grouped and predictable. +*/}} +{{- define "helm-framework.liquibase.name" -}} +{{- (.Values.liquibase).name | default "liquibase" }} +{{- end }} + +{{- define "helm-framework.liquibase.job-name" -}} +{{- include "helm-framework.fullname" . }}-{{ include "helm-framework.liquibase.name" . }} +{{- end }} + +{{- define "helm-framework.liquibase.changelog-configmap-name" -}} +{{- include "helm-framework.liquibase.job-name" . }}-changelog +{{- end }} + +{{- define "helm-framework.liquibase.migrations-configmap-name" -}} +{{- include "helm-framework.liquibase.job-name" . }}-migrations +{{- end }} + +{{- define "helm-framework.liquibase.env-secret-name" -}} +{{- include "helm-framework.liquibase.job-name" . }}-env +{{- end }} + +{{/* +The changelog's ConfigMap key, which is also the volume subPath. Derived from +changelog.mountPath's basename so the ConfigMap, the mount, and +LIQUIBASE_SEARCH_PATH can never disagree. +*/}} +{{- define "helm-framework.liquibase.changelog-key" -}} +{{- base (((.Values.liquibase).changelog).mountPath | default "/liquibase/changelog.xml") }} +{{- end }} + +{{/* +Returns "true" when at least one migration file resolves, from either +migrations.paths globs or the inline migrations.files map. Used to decide +whether the migrations ConfigMap, volume, and mount are rendered at all — a +self-contained changelog needs none of them. +*/}} +{{- define "helm-framework.liquibase.has-migrations" -}} +{{- $found := false -}} +{{- if ((.Values.liquibase).migrations).files -}} +{{- $found = true -}} +{{- end -}} +{{- range $pattern := ((.Values.liquibase).migrations).paths -}} +{{- if $.Files.Glob $pattern -}} +{{- $found = true -}} +{{- end -}} +{{- end -}} +{{- if $found }}true{{- end -}} +{{- end }} + +{{/* +The JDBC URL. Two mutually exclusive ways to supply it, both fully in the +chart author's hands — the framework ships no per-engine defaults, so adding +or renaming a driver is never a breaking change here: + + database.url a literal JDBC URL, rendered through `tpl` so it can + reference other values. Total control: any driver, any + vendor-specific parameter, credentials embedded if the + driver demands it. + database.urlTemplate a template with NAMED placeholders `{host}`, `{port}` + and `{name}`. Keeps host/port/name as separate values + so a per-environment overlay can change just the host. + +Placeholders are named rather than positional on purpose. An earlier revision +used printf `%s` verbs, which are filled in argument order: a template written +`jdbc://%s/%s:%s` meaning name/host/port silently received host/port/name, +producing a syntactically valid URL that only failed when the migration tried +to connect. Named placeholders cannot be mis-ordered, and each is optional — +use only the ones your driver's URL actually needs. + +`url` wins when both are set. +*/}} +{{- define "helm-framework.liquibase.url" -}} +{{- $db := ((.Values.liquibase).database | default dict) -}} +{{- if $db.url -}} +{{- tpl $db.url . -}} +{{- else -}} +{{- $url := $db.urlTemplate | toString -}} +{{- $url = $url | replace "{host}" ($db.host | toString) -}} +{{- $url = $url | replace "{port}" ($db.port | toString) -}} +{{- $url = $url | replace "{name}" ($db.name | toString) -}} +{{- $url -}} +{{- end -}} +{{- end }} + +{{/* +The placeholder names `database.urlTemplate` understands. Single source of +truth shared by the URL composer above and the validation in +_values-validation.tpl, so an added placeholder cannot be accepted by one and +rejected by the other. +*/}} +{{- define "helm-framework.liquibase.urlTemplate.placeholders" -}} +host port name +{{- end }} + +{{/* +The Liquibase Job container's env list, at relative indent 0. + +The URL is a plain value: a connection target is not a credential, and keeping +it in the pod spec makes `kubectl describe job` diagnostic. Only the username +and password are Secret-sourced — from the generated Secret, or from +database.existingSecret when that is set. + +LIQUIBASE_SEARCH_PATH is the changelog mount's directory, so the default +`--changeLogFile=changelog.xml` keeps resolving even if mountPath is changed. +*/}} +{{- define "helm-framework.liquibase.env" -}} +{{- $db := ((.Values.liquibase).database | default dict) -}} +{{- $existing := ($db.existingSecret | default dict) -}} +{{- $changelogMount := (((.Values.liquibase).changelog).mountPath | default "/liquibase/changelog.xml") -}} +- name: LIQUIBASE_COMMAND_URL + value: {{ include "helm-framework.liquibase.url" . | quote }} +{{- if $existing.name }} +- name: LIQUIBASE_COMMAND_USERNAME + valueFrom: + secretKeyRef: + name: {{ tpl $existing.name . | quote }} + key: {{ $existing.usernameKey | default "username" | quote }} +- name: LIQUIBASE_COMMAND_PASSWORD + valueFrom: + secretKeyRef: + name: {{ tpl $existing.name . | quote }} + key: {{ $existing.passwordKey | default "password" | quote }} +{{- else }} +- name: LIQUIBASE_COMMAND_USERNAME + valueFrom: + secretKeyRef: + name: {{ include "helm-framework.liquibase.env-secret-name" . | quote }} + key: LIQUIBASE_COMMAND_USERNAME +- name: LIQUIBASE_COMMAND_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "helm-framework.liquibase.env-secret-name" . | quote }} + key: LIQUIBASE_COMMAND_PASSWORD +{{- end }} +- name: LIQUIBASE_LOG_LEVEL + value: {{ (((.Values.liquibase).log).level | default "INFO") | quote }} +- name: LIQUIBASE_SEARCH_PATH + value: {{ dir $changelogMount | quote }} +{{- with (.Values.liquibase).extraEnvVars }} +{{ tpl (toYaml .) $ | trim }} +{{- end }} +{{- end }} diff --git a/helm/helm-framework/templates/_job-partials.tpl b/helm/helm-framework/templates/_job-partials.tpl new file mode 100644 index 0000000..1ab73fb --- /dev/null +++ b/helm/helm-framework/templates/_job-partials.tpl @@ -0,0 +1,129 @@ +{{/* +Shared Job pod-spec fragments, included by both _job.yaml (the `jobs[]` list) +and _liquibase.tpl (the native Liquibase migration Job). Extracted so the two +Job kinds cannot drift on CA-bundle wiring, VPA-aware resources, appSettings +mounting, or scheduling. + +Every partial emits at relative indent 0; callers apply `nindent`. Always pipe +through `trim` first: several partials open with a conditional that leaves a +leading newline, and `nindent` would turn that into a line of bare +indentation. Partials that can emit nothing are additionally wrapped by +callers in `with (include ... | trim)` so an empty result contributes no +whitespace at all. + +Context is the chart root ($ / $root) except helm-framework.job.resources, +which takes a dict — see its own comment. +*/}} + +{{- define "helm-framework.job.podAnnotations" -}} +checksum/appSettings: {{ .Values.appSettings | default dict | toYaml | sha256sum }} +checksum/application: {{ .Values.helmFrameworkSettings | default dict | toYaml | sha256sum }} +{{- with .Values.podAnnotations }} +{{ toYaml . | trim }} +{{- end }} +{{- end }} + +{{- define "helm-framework.job.podLabels" -}} +{{ include "helm-framework.labels" . }} +{{- with .Values.podLabels }} +{{ toYaml . | trim }} +{{- end }} +{{- end }} + +{{- define "helm-framework.job.caBundleInit" -}} +- name: ca-bundle-init + image: "{{ required "Image repository is required!" (.Values.image).repository }}:{{ (.Values.image).tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ (.Values.image).pullPolicy | default "IfNotPresent" }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 4 }} + {{- end }} + command: + - /bin/sh + args: + - -c + - /mnt/scripts/combine-certs.sh + volumeMounts: + - name: scripts + mountPath: /mnt/scripts + - name: cert-combine + mountPath: /mnt/ca-certs + - name: authorities + mountPath: /mnt/authorities +{{- end }} + +{{/* +VPA-aware resources block. Under an enabled verticalPodAutoscaler only +requests are rendered (limits are the VPA's to manage); otherwise the +resources map is emitted verbatim. Expects a dict: + { root: , resources: } +*/}} +{{- define "helm-framework.job.resources" -}} +{{- $root := .root -}} +{{- $res := .resources -}} +{{- if ($root.Values.verticalPodAutoscaler).enabled }} +{{- with ($res).requests }} +resources: + requests: + {{- toYaml . | nindent 4 }} +{{- else }} +resources: {} +{{- end }} +{{- else if $res }} +resources: + {{- toYaml $res | nindent 2 }} +{{- else }} +resources: {} +{{- end }} +{{- end }} + +{{- define "helm-framework.job.commonVolumes" -}} +{{- if .Values.appSettings }} +- name: app-settings + secret: + secretName: {{ include "helm-framework.secret-app-settings" . }} + optional: false +{{- end }} +{{- if ((.Values.initContainer).caBundle).enabled }} +- name: cert-combine + emptyDir: {} +- name: scripts + secret: + secretName: {{ include "helm-framework.secret-scripts" . }} + optional: false + defaultMode: 0555 +- name: authorities + secret: + secretName: {{ include "helm-framework.secret-authorities" . }} + optional: false +{{- end }} +{{- end }} + +{{- define "helm-framework.job.commonVolumeMounts" -}} +{{- if .Values.appSettings }} +- name: app-settings + mountPath: "{{ include "helm-framework.values.application.configPath" . }}/{{ include "helm-framework.values.configFileName" . }}" + subPath: {{ include "helm-framework.values.configFileName" . | quote }} + readOnly: true +{{- end }} +{{- if ((.Values.initContainer).caBundle).enabled }} +- name: cert-combine + mountPath: {{ include "helm-framework.values.ca-bundle-path" . }} + readOnly: true +{{- end }} +{{- end }} + +{{- define "helm-framework.job.scheduling" -}} +{{- with .Values.nodeSelector }} +nodeSelector: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.affinity }} +affinity: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- with .Values.tolerations }} +tolerations: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} diff --git a/helm/helm-framework/templates/_job.yaml b/helm/helm-framework/templates/_job.yaml index de799eb..ecc0ed6 100644 --- a/helm/helm-framework/templates/_job.yaml +++ b/helm/helm-framework/templates/_job.yaml @@ -24,16 +24,9 @@ spec: template: metadata: annotations: - checksum/appSettings: {{ $root.Values.appSettings | default dict | toYaml | sha256sum }} - checksum/application: {{ $root.Values.helmFrameworkSettings | default dict | toYaml | sha256sum }} - {{- with $root.Values.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} + {{- include "helm-framework.job.podAnnotations" $root | nindent 8 }} labels: - {{- include "helm-framework.labels" $root | nindent 8 }} - {{- with $root.Values.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} + {{- include "helm-framework.job.podLabels" $root | nindent 8 }} spec: {{- if $root.Values.imagePullSecrets }} {{- with $root.Values.imagePullSecrets }} @@ -49,25 +42,7 @@ spec: restartPolicy: {{ $job.restartPolicy | default "OnFailure" }} {{- if (($root.Values.initContainer).caBundle).enabled }} initContainers: - - name: ca-bundle-init - image: "{{ required "Image repository is required!" ($root.Values.image).repository }}:{{ ($root.Values.image).tag | default $root.Chart.AppVersion }}" - imagePullPolicy: {{ ($root.Values.image).pullPolicy | default "IfNotPresent" }} - {{- with $root.Values.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - command: - - /bin/sh - args: - - -c - - /mnt/scripts/combine-certs.sh - volumeMounts: - - name: scripts - mountPath: /mnt/scripts - - name: cert-combine - mountPath: /mnt/ca-certs - - name: authorities - mountPath: /mnt/authorities + {{- include "helm-framework.job.caBundleInit" $root | nindent 8 }} {{- end }} containers: - name: {{ $root.Chart.Name }} @@ -107,31 +82,10 @@ spec: {{- end }} {{- end }} {{- $res := $job.resources | default $root.Values.resources }} - {{- if ($root.Values.verticalPodAutoscaler).enabled }} - {{- with ($res).requests }} - resources: - requests: - {{- toYaml . | nindent 14 }} - {{- else }} - resources: {} - {{- end }} - {{- else if $res }} - resources: - {{- toYaml $res | nindent 12 }} - {{- else }} - resources: {} - {{- end }} + {{- include "helm-framework.job.resources" (dict "root" $root "resources" $res) | trim | nindent 10 }} volumeMounts: - {{- if $root.Values.appSettings }} - - name: app-settings - mountPath: "{{ include "helm-framework.values.application.configPath" $root }}/{{ include "helm-framework.values.configFileName" $root }}" - subPath: {{ include "helm-framework.values.configFileName" $root | quote }} - readOnly: true - {{- end }} - {{- if (($root.Values.initContainer).caBundle).enabled }} - - name: cert-combine - mountPath: {{ include "helm-framework.values.ca-bundle-path" $root }} - readOnly: true + {{- with (include "helm-framework.job.commonVolumeMounts" $root | trim) }} + {{- . | nindent 12 }} {{- end }} {{- if $root.Values.volumeMounts }} {{- tpl (toYaml $root.Values.volumeMounts) $root | nindent 12 }} @@ -140,24 +94,8 @@ spec: {{- tpl (toYaml $job.volumeMounts) $root | nindent 12 }} {{- end }} volumes: - {{- if $root.Values.appSettings }} - - name: app-settings - secret: - secretName: {{ include "helm-framework.secret-app-settings" $root }} - optional: false - {{- end }} - {{- if (($root.Values.initContainer).caBundle).enabled }} - - name: cert-combine - emptyDir: {} - - name: scripts - secret: - secretName: {{ include "helm-framework.secret-scripts" $root }} - optional: false - defaultMode: 0555 - - name: authorities - secret: - secretName: {{ include "helm-framework.secret-authorities" $root }} - optional: false + {{- with (include "helm-framework.job.commonVolumes" $root | trim) }} + {{- . | nindent 8 }} {{- end }} {{- if $root.Values.volumes }} {{- tpl (toYaml $root.Values.volumes) $root | nindent 8 }} @@ -165,17 +103,8 @@ spec: {{- if $job.volumes }} {{- tpl (toYaml $job.volumes) $root | nindent 8 }} {{- end }} - {{- with $root.Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with $root.Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with $root.Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} + {{- with (include "helm-framework.job.scheduling" $root | trim) }} + {{- . | nindent 6 }} {{- end }} {{- end }} {{- end }} diff --git a/helm/helm-framework/templates/_liquibase.tpl b/helm/helm-framework/templates/_liquibase.tpl new file mode 100644 index 0000000..35720ca --- /dev/null +++ b/helm/helm-framework/templates/_liquibase.tpl @@ -0,0 +1,114 @@ +{{/* +The native Liquibase migration Job. A pre-install/pre-upgrade hook at weight +-10 — after the changelog/migrations ConfigMaps and the credentials Secret at +-20, and before the release's own manifests — so the migration runs ahead of +the Deployment. `before-hook-creation` deletion means a re-run replaces the +previous Job rather than colliding with it. + +Pod-spec fragments are shared with the `jobs[]` Job via _job-partials.tpl; see +the spec's "Refactor: shared Job partials" section for why this template is +standalone rather than a synthetic jobs[] entry. +*/}} +{{- define "helm-framework.deployment.liquibase" -}} +{{- if (.Values.liquibase).enabled -}} +{{- $lb := .Values.liquibase -}} +{{- $res := ($lb.resources | default .Values.resources) -}} +{{- $changelogMount := ($lb.changelog).mountPath | default "/liquibase/changelog.xml" -}} +{{- $changelogKey := include "helm-framework.liquibase.changelog-key" . -}} +{{- $hasMigrations := eq (include "helm-framework.liquibase.has-migrations" .) "true" -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "helm-framework.liquibase.job-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-delete-policy: before-hook-creation + helm.sh/hook-weight: "-10" + labels: + job: {{ include "helm-framework.liquibase.job-name" . }} + {{- include "helm-framework.labels" . | nindent 4 }} +spec: + backoffLimit: {{ $lb.backoffLimit | default 6 | int }} + template: + metadata: + annotations: + {{- include "helm-framework.job.podAnnotations" . | trim | nindent 8 }} + labels: + {{- include "helm-framework.job.podLabels" . | trim | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "helm-framework.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ $lb.restartPolicy | default "OnFailure" }} + {{- if ((.Values.initContainer).caBundle).enabled }} + initContainers: + {{- include "helm-framework.job.caBundleInit" . | trim | nindent 8 }} + {{- end }} + containers: + - name: liquibase + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ ($lb.image).repository | default "liquibase/liquibase" }}:{{ ($lb.image).tag | default "4.33" }}" + imagePullPolicy: {{ ($lb.image).pullPolicy | default "IfNotPresent" }} + {{- with $lb.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with $lb.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- include "helm-framework.liquibase.env" . | trim | nindent 12 }} + {{- with $lb.extraEnvFrom }} + envFrom: + {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} + {{- include "helm-framework.job.resources" (dict "root" . "resources" $res) | trim | nindent 10 }} + volumeMounts: + - name: liquibase-changelog + mountPath: {{ $changelogMount | quote }} + subPath: {{ $changelogKey | quote }} + readOnly: true + {{- if $hasMigrations }} + - name: liquibase-migrations + mountPath: {{ ($lb.migrations).mountPath | default "/liquibase/migrations" | quote }} + readOnly: true + {{- end }} + {{- with (include "helm-framework.job.commonVolumeMounts" . | trim) }} + {{- . | nindent 12 }} + {{- end }} + {{- with $lb.volumeMounts }} + {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} + volumes: + - name: liquibase-changelog + configMap: + name: {{ include "helm-framework.liquibase.changelog-configmap-name" . }} + items: + - key: {{ $changelogKey | quote }} + path: {{ $changelogKey | quote }} + {{- if $hasMigrations }} + - name: liquibase-migrations + configMap: + name: {{ include "helm-framework.liquibase.migrations-configmap-name" . }} + {{- end }} + {{- with (include "helm-framework.job.commonVolumes" . | trim) }} + {{- . | nindent 8 }} + {{- end }} + {{- with $lb.volumes }} + {{- tpl (toYaml .) $ | nindent 8 }} + {{- end }} + {{- with (include "helm-framework.job.scheduling" . | trim) }} + {{- . | nindent 6 }} + {{- end }} +{{- end }} +{{- end }} diff --git a/helm/helm-framework/templates/_secret-liquibase.tpl b/helm/helm-framework/templates/_secret-liquibase.tpl new file mode 100644 index 0000000..cf0a7fb --- /dev/null +++ b/helm/helm-framework/templates/_secret-liquibase.tpl @@ -0,0 +1,29 @@ +{{/* +The Liquibase database credentials Secret. A pre-install/pre-upgrade hook at +weight -20, below the migration Job at -10, so it exists before the Job pod +starts. + +Skipped entirely when database.existingSecret.name is set — that is the +supported path for production credentials (point it at an ExternalSecret) +instead of putting a password in values.yaml. Holds only the username and +password; the JDBC URL is a plain env var on the Job container. +*/}} +{{- define "helm-framework.deployment.liquibase-secret" -}} +{{- if (.Values.liquibase).enabled -}} +{{- $db := ((.Values.liquibase).database | default dict) -}} +{{- if not ($db.existingSecret | default dict).name -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "helm-framework.liquibase.env-secret-name" . }} + annotations: + helm.sh/hook: pre-upgrade, pre-install + helm.sh/hook-weight: "-20" + labels: + {{- include "helm-framework.labels" . | nindent 4 }} +data: + LIQUIBASE_COMMAND_USERNAME: {{ $db.userName | default "" | toString | b64enc | quote }} + LIQUIBASE_COMMAND_PASSWORD: {{ $db.password | default "" | toString | b64enc | quote }} +{{- end }} +{{- end }} +{{- end }} diff --git a/helm/helm-framework/templates/_values-validation.tpl b/helm/helm-framework/templates/_values-validation.tpl index f4547af..b7f80f8 100644 --- a/helm/helm-framework/templates/_values-validation.tpl +++ b/helm/helm-framework/templates/_values-validation.tpl @@ -133,4 +133,62 @@ virtualService, authorizationPolicy, and podDisruptionBudget. {{- end }} {{- end }} +{{- if (.Values.liquibase).enabled }} +{{- $lb := .Values.liquibase -}} +{{- $db := ($lb.database | default dict) -}} +{{- $existing := ($db.existingSecret | default dict) -}} + {{- if not $db.url }} + {{- if not $db.urlTemplate }} +{{- fail "liquibase.enabled is true but no JDBC URL is configured: set liquibase.database.url to a literal JDBC URL, or set liquibase.database.urlTemplate using the named placeholders {host}, {port} and {name} together with the matching liquibase.database values. The framework ships no per-driver defaults, so one of the two is always required." }} + {{- end }} + {{- $known := splitList " " (include "helm-framework.liquibase.urlTemplate.placeholders" .) -}} + {{- $tmpl := $db.urlTemplate | toString -}} + {{- if regexMatch "%[sdvq]" $tmpl }} +{{- fail (printf "liquibase.database.urlTemplate %q uses a printf verb such as %%s: urlTemplate takes NAMED placeholders instead — {%s}. Positional verbs were removed because a template whose order differed from host, port, name silently produced a valid-looking but wrong URL. Rewrite it, e.g. \"jdbc:sqlserver://{host}:{port};database={name};\"." $tmpl (join "}, {" $known)) }} + {{- end }} + {{- $used := list -}} + {{- range $ph := (regexFindAll "\\{[^}]*\\}" $tmpl -1) }} + {{- $bare := $ph | trimPrefix "{" | trimSuffix "}" -}} + {{- if not (has $bare $known) }} +{{- fail (printf "liquibase.database.urlTemplate %q contains unknown placeholder %s: the supported placeholders are {%s}. For anything else, use liquibase.database.url and write the whole JDBC URL yourself (it is rendered through `tpl`, so it can reference any value)." $tmpl $ph (join "}, {" $known)) }} + {{- end }} + {{- $used = append $used $bare -}} + {{- end }} + {{- if not $used }} +{{- fail (printf "liquibase.database.urlTemplate %q contains no placeholders: it would render as a constant, so use liquibase.database.url instead. To substitute values, use {%s}." $tmpl (join "}, {" $known)) }} + {{- end }} + {{- $missing := list -}} + {{- range $ph := $used }} + {{- if not (get $db $ph) }}{{- $missing = append $missing (printf "%s (for {%s})" $ph $ph) }}{{- end }} + {{- end }} + {{- if $missing }} +{{- fail (printf "liquibase.database.urlTemplate references placeholders whose values are unset: %s. Set them under liquibase.database, or drop the placeholder from the template." (join ", " (uniq $missing))) }} + {{- end }} + {{- end }} + {{- if and (not ($lb.changelog | default dict).file) (not ($lb.changelog | default dict).content) }} +{{- fail "liquibase.enabled is true but no changelog is configured: set liquibase.changelog.file to a path inside your chart (e.g. \"liquibase/changelog.xml\"), or liquibase.changelog.content to an inline changelog." }} + {{- end }} + {{- if and $existing.name $db.password }} +{{- fail "liquibase.database.existingSecret.name and liquibase.database.password are both set: it is ambiguous which credential wins. Unset password to source it from the existing Secret, or unset existingSecret.name to use the generated one." }} + {{- end }} + {{- with ($lb.changelog | default dict).file }} + {{- if not ($.Files.Get .) }} +{{- fail (printf "liquibase.changelog.file %q resolves to nothing in this chart: .Files.Get returned empty, which would ship an empty changelog ConfigMap and a migration that silently does nothing. Check the path is relative to your chart root and that the file is not excluded by .helmignore." .) }} + {{- end }} + {{- end }} + {{- range $pattern := ($lb.migrations | default dict).paths }} + {{- if not ($.Files.Glob $pattern) }} +{{- fail (printf "liquibase.migrations.paths pattern %q matches no files in this chart: this would ship an empty migrations ConfigMap. Check the glob is relative to your chart root and that the files are not excluded by .helmignore." $pattern) }} + {{- end }} + {{- end }} + {{- $lbName := include "helm-framework.liquibase.name" . -}} + {{- range $index, $job := .Values.jobs }} + {{- if $job.enabled }} + {{- if eq ($job.name | default (printf "job-%d" $index)) $lbName }} +{{- fail (printf "jobs[%d]'s name %q collides with liquibase.name: both would render a Job named \"-%s\". Rename one of them." $index $lbName $lbName) }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + {{- end -}} diff --git a/helm/helm-framework/values.yaml b/helm/helm-framework/values.yaml index 90b4161..ccb4536 100644 --- a/helm/helm-framework/values.yaml +++ b/helm/helm-framework/values.yaml @@ -772,6 +772,113 @@ jobs: [] # envVars: [] # plain env vars (list of {name, value}) # envFrom: [] # raw envFrom sources (secretRef/configMapRef) +# ============================================================================= +# LIQUIBASE DATABASE MIGRATIONS +# ============================================================================= + +# -- Native Liquibase migration support. Renders a pre-install/pre-upgrade hook +# Job that runs Liquibase against your database, plus ConfigMaps holding your +# changelog and migration SQL and a Secret holding the database credentials. +# With `waitForIt` (the default) the Deployment's pods get a wait-for-job init +# container and will not start until the migration Job completes. Changelog and +# migration files are read from YOUR chart's directory via `.Files`, so the SQL +# lives as real files beside your chart rather than inside values.yaml. +liquibase: + enabled: false + # -- Suffix for every generated resource name: Job `-`, plus + # `-changelog` / `-migrations` / `-env`. Must not collide with a `jobs[]` name. + name: liquibase + # -- Block Deployment pod startup until the migration Job completes. Reuses + # the same wait-for-job init container and auto-provisioned RBAC as + # `jobs[].waitForIt` — see `initContainer.waitFor` to tune image and timeout. + waitForIt: true + restartPolicy: OnFailure + backoffLimit: 6 + image: + repository: liquibase/liquibase + tag: "4.33" + pullPolicy: IfNotPresent + command: + - liquibase + args: + - update + - "--changeLogFile=changelog.xml" + log: + # -- Liquibase log level, passed as LIQUIBASE_LOG_LEVEL: SEVERE, WARNING, + # INFO, FINE, or OFF. + level: INFO + database: + # -- Literal JDBC URL, rendered through `tpl` so it can reference other + # values. The most direct option and the one to reach for when the + # connection needs vendor-specific parameters: the framework never parses + # or rewrites it. Wins outright when `urlTemplate` is also set. Example: + # "jdbc:sqlserver://sql-server:1433;database=MyStore;encrypt=false;" + url: "" + # -- Alternative to `url`: a URL template using the NAMED placeholders + # `{host}`, `{port}` and `{name}`. Use it to keep host/port/name as separate + # values, so a per-environment overlay can change just the host. Each + # placeholder is optional — use only the ones your driver's URL needs — but + # any you do use must have a value set below. The framework deliberately + # ships NO per-driver defaults, so a new or renamed driver can never turn + # into a breaking change here. Examples: + # SQL Server: "jdbc:sqlserver://{host}:{port};database={name};" + # PostgreSQL: "jdbc:postgresql://{host}:{port}/{name}" + # MySQL: "jdbc:mysql://{host}:{port}/{name}" + # Oracle: "jdbc:oracle:thin:@{host}:{port}/{name}" + # Placeholders are named, not positional: printf-style `%s` verbs are + # rejected, because a template whose verb order differed from host, port, + # name silently produced a valid-looking but wrong URL. For anything these + # three cannot express, use `url` instead. + urlTemplate: "" + # -- Database host. Required when `urlTemplate` uses `{host}`. + host: "" + # -- Database port. Required when `urlTemplate` uses `{port}`; there is no + # default, since the framework does not know your driver. + port: 0 + # -- Database name. Required when `urlTemplate` uses `{name}`. + name: "" + userName: "" + password: "" + # -- Source credentials from an existing Secret instead of `userName` and + # `password`. When `name` is set, no connection Secret is generated and the + # username/password come from secretKeyRef — use this with ExternalSecret + # rather than putting a production password in values.yaml. + existingSecret: + name: "" + usernameKey: username + passwordKey: password + changelog: + # -- Path to the changelog file within YOUR chart, e.g. + # "liquibase/changelog.xml". Read via `.Files.Get`. + file: "" + # -- Inline changelog alternative. `file` wins when both are set. + content: "" + # -- Where the changelog is mounted in the Job container. Its basename is + # both the ConfigMap key and the mount subPath, and its directory becomes + # LIQUIBASE_SEARCH_PATH — so the default `--changeLogFile=changelog.xml` + # keeps resolving if you change this. + mountPath: /liquibase/changelog.xml + migrations: + # -- Globs within YOUR chart, e.g. ["liquibase/migrations/*.sql"]. Read via + # `.Files.Glob`; each file's basename becomes its ConfigMap key. + paths: [] + # -- Inline alternative, filename -> content. Merged with `paths`; globbed + # files win on key collision. Note the 1 MiB ConfigMap limit, and that + # ConfigMap data must be valid UTF-8 text. + files: {} + mountPath: /liquibase/migrations + # -- Job container resources. Falls back to the top-level `resources` when + # empty, and is VPA-aware like `jobs[].resources`. + resources: {} + # -- Extra plain env vars (list of {name, value}), appended after the + # composed LIQUIBASE_* variables. Rendered through `tpl`. + extraEnvVars: [] + # -- Extra raw envFrom sources (secretRef/configMapRef). Rendered through + # `tpl`, so entries may reference chart helpers. + extraEnvFrom: [] + volumes: [] + volumeMounts: [] + # ============================================================================= # SIDECARS # ============================================================================= diff --git a/plugins/helm-framework/skills/helm-framework-migration/resources/values-contract.md b/plugins/helm-framework/skills/helm-framework-migration/resources/values-contract.md index f9752c9..c477bd8 100644 --- a/plugins/helm-framework/skills/helm-framework-migration/resources/values-contract.md +++ b/plugins/helm-framework/skills/helm-framework-migration/resources/values-contract.md @@ -36,6 +36,7 @@ is **not** in this list is not read by the library, full stop. - `initContainers` - `jobs` - `keda` +- `liquibase` - `nameOverride` - `nodeSelector` - `podAnnotations` @@ -183,6 +184,45 @@ not for authorizing deletion. - `keda.triggerAuthentication.name` - `keda.triggerAuthentication.podIdentity` - `keda.triggerAuthentication.secretTargetRef` +- `liquibase` +- `liquibase.args` +- `liquibase.backoffLimit` +- `liquibase.changelog` +- `liquibase.changelog.content` +- `liquibase.changelog.file` +- `liquibase.changelog.mountPath` +- `liquibase.command` +- `liquibase.database` +- `liquibase.database.existingSecret` +- `liquibase.database.existingSecret.name` +- `liquibase.database.existingSecret.passwordKey` +- `liquibase.database.existingSecret.usernameKey` +- `liquibase.database.host` +- `liquibase.database.name` +- `liquibase.database.password` +- `liquibase.database.port` +- `liquibase.database.url` +- `liquibase.database.urlTemplate` +- `liquibase.database.userName` +- `liquibase.enabled` +- `liquibase.extraEnvFrom` +- `liquibase.extraEnvVars` +- `liquibase.image` +- `liquibase.image.pullPolicy` +- `liquibase.image.repository` +- `liquibase.image.tag` +- `liquibase.log` +- `liquibase.log.level` +- `liquibase.migrations` +- `liquibase.migrations.files` +- `liquibase.migrations.mountPath` +- `liquibase.migrations.paths` +- `liquibase.name` +- `liquibase.resources` +- `liquibase.restartPolicy` +- `liquibase.volumeMounts` +- `liquibase.volumes` +- `liquibase.waitForIt` - `nameOverride` - `nodeSelector` - `podAnnotations` diff --git a/plugins/helm-framework/skills/helm-framework/SKILL.md b/plugins/helm-framework/skills/helm-framework/SKILL.md index 6acd918..4d04f53 100644 --- a/plugins/helm-framework/skills/helm-framework/SKILL.md +++ b/plugins/helm-framework/skills/helm-framework/SKILL.md @@ -60,6 +60,7 @@ every configurable value, grouped into these sections: - STORAGE - SCHEDULING - JOBS +- LIQUIBASE DATABASE MIGRATIONS - SIDECARS - APPLICATION CONFIGURATION diff --git a/plugins/helm-framework/skills/helm-framework/resources/example-chart.md b/plugins/helm-framework/skills/helm-framework/resources/example-chart.md index 724ae17..f5c3ff8 100644 --- a/plugins/helm-framework/skills/helm-framework/resources/example-chart.md +++ b/plugins/helm-framework/skills/helm-framework/resources/example-chart.md @@ -440,6 +440,28 @@ jobs: - name: JOB_MODE value: migrate +# ============================================================================= +# LIQUIBASE DATABASE MIGRATIONS +# ============================================================================= + +# Native Liquibase migration — enabled so `helm template` renders the Job, the +# changelog/migrations ConfigMaps, the connection Secret, and the wait-for-job +# init container in the Deployment. +liquibase: + enabled: true + database: + urlTemplate: "jdbc:sqlserver://{host}:{port};database={name};" + host: sql-server + port: 1433 + name: SmokeTestStore + userName: sa + password: smoke-test-password + changelog: + file: liquibase/changelog.xml + migrations: + paths: + - liquibase/migrations/*.sql + # ============================================================================= # SIDECARS # ============================================================================= diff --git a/plugins/helm-framework/skills/helm-framework/resources/values-reference.md b/plugins/helm-framework/skills/helm-framework/resources/values-reference.md index a2f299c..4c1bce9 100644 --- a/plugins/helm-framework/skills/helm-framework/resources/values-reference.md +++ b/plugins/helm-framework/skills/helm-framework/resources/values-reference.md @@ -30,6 +30,24 @@ Generated from `helm/helm-framework/README.md` (itself auto-generated by helm-do | initContainers | list | `[]` | Extra custom init containers (list; raw Kubernetes container specs, rendered through `tpl`). Appended after the built-in CA-bundle / wait-for-job init containers (the singular `initContainer` above). Empty by default. | | jobs | list | `[]` | Pre-install/pre-upgrade hook Jobs (list; one Job per enabled entry). Empty by default. | | keda | object | `{"enabled":false,"scaledObject":{"advanced":{},"annotations":{},"cooldownPeriod":300,"fallback":{},"idleReplicaCount":0,"maxReplicaCount":10,"minReplicaCount":1,"pollingInterval":30,"scaleTargetRef":{"apiVersion":"apps/v1","envSourceContainerName":"","kind":"Deployment","name":""},"triggers":[]},"triggerAuthentication":{"env":[],"name":"","podIdentity":{},"secretTargetRef":[]}}` | KEDA ScaledObject and TriggerAuthentication (event-driven autoscaling). | +| liquibase | object | `{"args":["update","--changeLogFile=changelog.xml"],"backoffLimit":6,"changelog":{"content":"","file":"","mountPath":"/liquibase/changelog.xml"},"command":["liquibase"],"database":{"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"host":"","name":"","password":"","port":0,"url":"","urlTemplate":"","userName":""},"enabled":false,"extraEnvFrom":[],"extraEnvVars":[],"image":{"pullPolicy":"IfNotPresent","repository":"liquibase/liquibase","tag":"4.33"},"log":{"level":"INFO"},"migrations":{"files":{},"mountPath":"/liquibase/migrations","paths":[]},"name":"liquibase","resources":{},"restartPolicy":"OnFailure","volumeMounts":[],"volumes":[],"waitForIt":true}` | Native Liquibase migration support. Renders a pre-install/pre-upgrade hook Job that runs Liquibase against your database, plus ConfigMaps holding your changelog and migration SQL and a Secret holding the database credentials. With `waitForIt` (the default) the Deployment's pods get a wait-for-job init container and will not start until the migration Job completes. Changelog and migration files are read from YOUR chart's directory via `.Files`, so the SQL lives as real files beside your chart rather than inside values.yaml. | +| liquibase.changelog.content | string | `""` | Inline changelog alternative. `file` wins when both are set. | +| liquibase.changelog.file | string | `""` | Path to the changelog file within YOUR chart, e.g. "liquibase/changelog.xml". Read via `.Files.Get`. | +| liquibase.changelog.mountPath | string | `"/liquibase/changelog.xml"` | Where the changelog is mounted in the Job container. Its basename is both the ConfigMap key and the mount subPath, and its directory becomes LIQUIBASE_SEARCH_PATH — so the default `--changeLogFile=changelog.xml` keeps resolving if you change this. | +| liquibase.database.existingSecret | object | `{"name":"","passwordKey":"password","usernameKey":"username"}` | Source credentials from an existing Secret instead of `userName` and `password`. When `name` is set, no connection Secret is generated and the username/password come from secretKeyRef — use this with ExternalSecret rather than putting a production password in values.yaml. | +| liquibase.database.host | string | `""` | Database host. Required when `urlTemplate` uses `{host}`. | +| liquibase.database.name | string | `""` | Database name. Required when `urlTemplate` uses `{name}`. | +| liquibase.database.port | int | `0` | Database port. Required when `urlTemplate` uses `{port}`; there is no default, since the framework does not know your driver. | +| liquibase.database.url | string | `""` | Literal JDBC URL, rendered through `tpl` so it can reference other values. The most direct option and the one to reach for when the connection needs vendor-specific parameters: the framework never parses or rewrites it. Wins outright when `urlTemplate` is also set. Example: "jdbc:sqlserver://sql-server:1433;database=MyStore;encrypt=false;" | +| liquibase.database.urlTemplate | string | `""` | Alternative to `url`: a URL template using the NAMED placeholders `{host}`, `{port}` and `{name}`. Use it to keep host/port/name as separate values, so a per-environment overlay can change just the host. Each placeholder is optional — use only the ones your driver's URL needs — but any you do use must have a value set below. The framework deliberately ships NO per-driver defaults, so a new or renamed driver can never turn into a breaking change here. Examples: SQL Server: "jdbc:sqlserver://{host}:{port};database={name};" PostgreSQL: "jdbc:postgresql://{host}:{port}/{name}" MySQL: "jdbc:mysql://{host}:{port}/{name}" Oracle: "jdbc:oracle:thin:@{host}:{port}/{name}" Placeholders are named, not positional: printf-style `%s` verbs are rejected, because a template whose verb order differed from host, port, name silently produced a valid-looking but wrong URL. For anything these three cannot express, use `url` instead. | +| liquibase.extraEnvFrom | list | `[]` | Extra raw envFrom sources (secretRef/configMapRef). Rendered through `tpl`, so entries may reference chart helpers. | +| liquibase.extraEnvVars | list | `[]` | Extra plain env vars (list of {name, value}), appended after the composed LIQUIBASE_* variables. Rendered through `tpl`. | +| liquibase.log.level | string | `"INFO"` | Liquibase log level, passed as LIQUIBASE_LOG_LEVEL: SEVERE, WARNING, INFO, FINE, or OFF. | +| liquibase.migrations.files | object | `{}` | Inline alternative, filename -> content. Merged with `paths`; globbed files win on key collision. Note the 1 MiB ConfigMap limit, and that ConfigMap data must be valid UTF-8 text. | +| liquibase.migrations.paths | list | `[]` | Globs within YOUR chart, e.g. ["liquibase/migrations/*.sql"]. Read via `.Files.Glob`; each file's basename becomes its ConfigMap key. | +| liquibase.name | string | `"liquibase"` | Suffix for every generated resource name: Job `-`, plus `-changelog` / `-migrations` / `-env`. Must not collide with a `jobs[]` name. | +| liquibase.resources | object | `{}` | Job container resources. Falls back to the top-level `resources` when empty, and is VPA-aware like `jobs[].resources`. | +| liquibase.waitForIt | bool | `true` | Block Deployment pod startup until the migration Job completes. Reuses the same wait-for-job init container and auto-provisioned RBAC as `jobs[].waitForIt` — see `initContainer.waitFor` to tune image and timeout. | | nameOverride | string | `""` | Override the chart name used in resource names and labels. | | nodeSelector | object | `{}` | Node selector for pod scheduling. | | podAnnotations | object | `{}` | Extra annotations added to the pod template. |