Skip to content

feat(liquibase): add native Liquibase migration Job with wait-for-job support - #9

Merged
piotrlaczykowski merged 11 commits into
mainfrom
feat/liquibase-native-support
Aug 28, 2026
Merged

feat(liquibase): add native Liquibase migration Job with wait-for-job support#9
piotrlaczykowski merged 11 commits into
mainfrom
feat/liquibase-native-support

Conversation

@piotrlaczykowski

@piotrlaczykowski piotrlaczykowski commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What

Adds native Liquibase migration support. A chart declares a liquibase: block and gets a complete pre-start database migration:

Resource Name Hook weight
ConfigMap (changelog) <fullname>-liquibase-changelog -20
ConfigMap (migrations) <fullname>-liquibase-migrations -20
Secret (credentials) <fullname>-liquibase-env -20
Job <fullname>-liquibase -10

waitForIt defaults to true, so the Deployment's pods get the existing kubectl wait init container and will not start until the migration completes. No new RBAC code — waitFor.active gained a second trigger, which transitively brings ServiceAccount creation, token automounting, and the <fullname>-wait-for-jobs Role/RoleBinding.

Minimal usage:

liquibase:
  enabled: true
  database:
    urlTemplate: "jdbc:sqlserver://{host}:{port};database={name};"
    host: sql-server
    port: 1433
    name: MyStore
    userName: sa
    existingSecret:
      name: my-external-secret
  changelog:
    file: liquibase/changelog.xml
  migrations:
    paths: ["liquibase/migrations/*.sql"]

Why

Charts needing a migration before pod start currently hand-roll all of it: their own singular job: block, their own pod-updater RBAC, their own waitForEnabled flag, two ConfigMap templates, and a connection Secret template. The framework already had the hard part (jobs[].waitForIt and its init container + RBAC); what was missing was Liquibase-shaped — JDBC URL composition, credential plumbing, and ConfigMaps for the changelog and SQL. There was no ConfigMap template in the framework at all.

Notable decisions

Changelog and migration SQL come from the consuming chart's own files. Because a consuming chart passes its own root context into the library include, .Files.Get / .Files.Glob inside a framework template resolve against that chart's directory. Verified with a throwaway two-chart probe before designing around it. SQL stays as real files with real diffs; changelog.content / migrations.files are inline escape hatches.

The framework ships no knowledge of any JDBC driver. An earlier revision had a database.engine enum selecting a default port and URL template; it was dropped during review. A closed enum in a library chart's public API is a liability — every unlisted driver needs an escape hatch, and correcting an entry later silently changes behaviour for charts pinned to it. Baked-in templates also hid the connection string, so vendor-specific parameters (encrypt, trustServerCertificate, oracle.net.ssl_server_dn_match, pool/timeout options) couldn't be expressed and failed at migration time rather than render time.

Configuration is explicitly one of two, neither with a default: database.url (a literal JDBC URL, tpl-rendered, never parsed by the framework) or database.urlTemplate (named placeholders {host}, {port}, {name}). Common templates are documented as examples in values.yaml, not implemented as code.

The JDBC URL is a plain env var; only username/password are Secret-sourced. A connection target is not a credential, and keeping it in the pod spec makes kubectl describe job diagnostic. database.existingSecret suppresses the generated Secret entirely — the supported path for production credentials via ExternalSecret.

_job.yaml was refactored into seven shared partials (_job-partials.tpl) rather than duplicating ~120 lines of pod spec into the new Job. Rendered output of the test template chart is byte-identical before and after across five value combinations: default, CA-bundle enabled, VPA enabled, no-resources, and scheduling (nodeSelector/tolerations/podAnnotations/podLabels).

Validation

Eight fail-fast rules in _values-validation.tpl, each verified to fire. Three are worth calling out:

  • A changelog.file path or a migrations.paths glob that resolves to nothing now fails rather than shipping an empty ConfigMap and a migration that silently does nothing. Both messages name .helmignore as the non-obvious cause.
  • urlTemplate placeholders are named, not positional. Positional printf verbs were dropped during review: verb count is checkable, verb meaning is not, so jdbc:custom:%s@%s:%s written intending name/host/port silently produced a valid-looking wrong URL. A printf verb is now rejected outright with the rewrite shown; so are unknown placeholders ({database}) and templates with no placeholders at all.
  • Every placeholder actually used must have its value set, and the message names which are missing. Unused placeholders impose no requirement.

Testing

  • Test template chart exercises the feature with real liquibase/changelog.xml and liquibase/migrations/001_init.sql fixtures, so existing helm lint / ct lint / kubeconform CI covers the new templates.
  • Two new CI regression steps: the migration Job must be referenced by the wait init container with its RBAC present and its ConfigMaps/Secret weighted ahead of it; and existingSecret must suppress the generated Secret.
  • npm test passes (40/40) — the bidirectional values-contract guard required the values.yaml declaration and the first .Values.liquibase read to land in one commit.
  • Chart README and plugin skill regenerated. Fixed one real bug found there: the engine comment's raw | characters broke the generated markdown table.

Not verified locally: kubeconformcurl is blocked in my environment. All 29 rendered documents were checked to parse as YAML with structural spot-checks (every volumeMount has a backing volume, env sources correct, ConfigMap keys correct). CI runs the real schema validation.

Not breaking

New opt-in key, enabled: false by default. The _job.yaml refactor is provably output-identical.

Design spec and implementation plan are included under docs/superpowers/.

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 across five value combinations (default,
CA-bundle, VPA, no-resources, scheduling).
…ns 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.
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.
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.
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.
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.
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 chart README and plugin skill so the values
reference includes the liquibase block; the engine comment no longer uses raw
pipe characters, which broke the generated markdown table.
@piotrlaczykowski
piotrlaczykowski force-pushed the feat/liquibase-native-support branch from c9633b4 to 61bf4ad Compare August 28, 2026 06:38
Comment thread helm/helm-framework/templates/_values.tpl Outdated
…onfig

The engine enum (sqlserver/postgresql/mysql/oracle) selected a default port
and a built-in JDBC URL template. Both are removed: the framework now ships no
knowledge of any driver. Raised in review on #9, and since liquibase support
is unreleased this changes no published API.

A closed enum in a library chart's public API is a liability — every unlisted
driver needs an escape hatch, and adding or correcting an entry later silently
changes behaviour for charts pinned to it. Baked-in templates also hid the
connection string from chart authors, so vendor-specific parameters (encrypt,
trustServerCertificate, oracle.net.ssl_server_dn_match, pool and timeout
options) could not be expressed and failed at migration time rather than
render time.

Configuration is now explicitly one of two, neither with a default:
  database.url          literal JDBC URL, tpl-rendered; wins when both set
  database.urlTemplate  printf with exactly three %s: host, port, name

Validation replaces the two engine rules with three sharper ones: one of
url/urlTemplate is required; urlTemplate requires host, port and name, naming
whichever are missing; and urlTemplate must contain exactly three %s verbs,
since printf would otherwise emit %!(EXTRA) or %!s(MISSING) into the URL and
fail confusingly at connect time.
Comment thread helm/helm-framework-test-template/values.yaml Outdated
urlTemplate took three printf %s verbs filled with host, port, name in that
order. Raised in review on #9: verb count is checkable, verb meaning is not. A
template written `jdbc:custom:%s@%s:%s` intending name/host/port received
host/port/name and produced a syntactically valid URL that rendered cleanly,
passed validation, and failed only when Liquibase tried to connect.

urlTemplate now uses named placeholders instead:

  jdbc:sqlserver://{host}:{port};database={name};

They cannot be mis-ordered, and each is independently optional, so a driver URL
needing only host and port simply omits {name}. Substitution is `replace`
rather than `printf`.

Validation covers the ways a template can be wrong: a printf verb is rejected
outright with the rewrite spelled out (charts migrating from a hand-rolled
printf connectionStringTemplate hit this first); an unknown placeholder such as
{database} is rejected listing the supported set; a template with no
placeholders is rejected as a constant that belongs in database.url; and every
placeholder actually used must have its value set, named in the message.

The legal placeholder names live in one helper read by both the URL composer
and the validation, so the two cannot disagree.

@lwasak lwasak left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good

@piotrlaczykowski
piotrlaczykowski merged commit a755050 into main Aug 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants