Skip to content

Chart: Reconcile metadata DB bidirectionally on helm upgrade#68074

Open
jykae wants to merge 12 commits into
apache:mainfrom
jykae:feature/chart-bidirectional-migrations
Open

Chart: Reconcile metadata DB bidirectionally on helm upgrade#68074
jykae wants to merge 12 commits into
apache:mainfrom
jykae:feature/chart-bidirectional-migrations

Conversation

@jykae
Copy link
Copy Markdown
Contributor

@jykae jykae commented Jun 5, 2026

Extends the existing migrate-database-job so a single helm upgrade reconciles the Airflow metadata DB in both directions — forward migrate, no-op, or downgrade — based on the chart's airflowVersion.

closes: #68072

What the chart does now

  1. The job runs as a pre-install,pre-upgrade hook (was post-install,post-upgrade) so the schema is reconciled before helm rolls in the target image.

  2. The default args invoke a pure-Python reconciler (chart/files/db_migrate.py) embedded via python3 -c "<file contents>". It picks one of four actions:

    Detected state Action
    DB has no alembic_version row airflow db migrate (fresh install)
    target == current early-exit no-op
    target > current (forward) airflow db migrate in the job container (TARGET image)
    target < current (downgrade) (a) exec airflow db downgrade --to-version <target> --yes inside the still-running api-server pod, then (b) scale every DB-touching workload of this release to 0 and wait for drain
  3. New namespace-scoped Role + RoleBinding for the existing migrate-database-job ServiceAccount, gated on rbac.create && migrateDatabaseJob.enabled:

    • pods get, list
    • pods/exec create, get
    • deployments, statefulsets get, list
    • deployments/scale, statefulsets/scale get, patch
  4. User-supplied migrateDatabaseJob.command / migrateDatabaseJob.args still take precedence — only the default changed.

Why the downgrade branch looks the way it does

Why exec into the api-server pod? Reverse alembic scripts only exist in the image of the version that introduced them. The target image cannot run a downgrade because it doesn't carry the scripts that need to be reversed. The still-running api-server pod does carry them.

Why scale every DB-touching workload to 0 right after? A rolling helm upgrade in the downgrade direction would leave OLD api-server / scheduler / triggerer / dag-processor / worker pods talking to the now-downgraded schema (reverse alembic scripts can drop columns the OLD code still queries). Draining those workloads before the hook returns means helm comes back and patches the same Deployments/StatefulSets to TARGET image + their configured replicas, so the cluster comes up cleanly on the target version with no OLD pod ever running against the downgraded schema.

This makes a downgrade a brief outage rather than a zero-downtime rolling update — which is unavoidable when the schema goes backwards.

Forward migrate and no-op paths are unchanged for users.

Deviations from the design sketch in #68072

The issue described the high-level shape; a few details changed during implementation. None of them alter the user-visible contract (same job, same value keys, same ServiceAccount).

Issue sketch What this PR ships Why
Bash reconciler with a case over current/target Pure-Python reconciler (chart/files/db_migrate.py), embedded via python3 -c "<file>" Lets the script use airflow.utils.db._REVISION_HEADS_MAP, MigrationContext.configure(conn).get_current_revision(), and ScriptDirectory.walk_revisions(...) directly — no string-parsing of airflow db check-migrations output, and the direction decision walks the actual revision graph instead of comparing version strings
kubectl exec ... -- airflow db downgrade ... CoreV1Api.connect_get_namespaced_pod_exec via the kubernetes Python client Keeps the migrations image dependency-free — no need to bundle a kubectl binary or pin its version
Downgrade path = exec into old api-server pod, then return Downgrade path = exec, then scale every DB-touching workload of the release to 0 and wait for drain Not covered by the sketch. Without the drain, helm's rolling upgrade leaves OLD api-server/scheduler/triggerer/dag-processor/worker pods talking to the now-downgraded schema; the reverse alembic scripts can drop columns the OLD code still queries. Draining before the hook returns lets helm patch the same workloads back to TARGET image + their configured replicas, so the cluster comes up cleanly
RBAC: pods, pods/exec only pods get/list, pods/exec create/get, deployments/statefulsets get/list, deployments/scale/statefulsets/scale get/patch Direct consequence of the drain step above
"Ship a small alembic-rev → Airflow-version map alongside appVersion bumps" Reuse the existing airflow.utils.db._REVISION_HEADS_MAP from the target image Same data, already maintained in-tree, no new map to keep in sync. The _ prefix is acknowledged as private — exposing a public accessor is a follow-up

Alternatives considered

  • Spawn an ephemeral downgrade pod from the api-server PodSpec, then scale-down, then downgrade. Strictly cleaner ordering but ~80 more lines and an extra RBAC rule. Happy to follow up with this in a separate PR if reviewers prefer it.
  • Two hooks (downgrade pre-upgrade, migrate post-upgrade). Introduces a race and doubles the templated resources. Single reconcile job avoids both.
  • Out-of-band tooling. Every operator (GHA, ArgoCD, Flux, manual helm) hits the same problem; the chart is the right owner of the contract.

Discovery of current

Queries alembic_version and maps to a airflow.utils.db._REVISION_HEADS_MAP revision. _REVISION_HEADS_MAP is currently private — exposing a public accessor is tracked in #68072 and would let this script drop the underscore import.

Tests

66 helm + runtime tests in chart/tests/helm_tests/airflow_aux/:

Helm template (52 tests)test_migrate_database_job.py, test_migrate_database_job_rbac.py:

  • hook annotations are pre-install,pre-upgrade
  • default args embed the bidirectional reconciler (python3 -c "..." with AIRFLOW_TARGET_VERSION, airflow db migrate, airflow db downgrade all present)
  • migrateDatabaseJob.command / args overrides still win
  • envs include AIRFLOW_TARGET_VERSION, POD_NAMESPACE (downward API), RELEASE_NAME
  • Role + RoleBinding render by default, gated on rbac.create
  • Role grants pods + pods/exec and deployments / statefulsets + */scale
  • RoleBinding subject is the existing migrate-database-job ServiceAccount

Runtime unit tests (14 tests)test_db_migrate_script.py, loads chart/files/db_migrate.py as a module and mocks alembic + kubernetes:

  • decide_action: unknown target → forward; OperationalError → fresh; no alembic row → fresh; current==target → noop; current is ancestor of target → forward; current NOT in target's ancestors → downgrade
  • discover_api_server_pod: prefers Ready, falls back to non-Ready, raises when none
  • run_downgrade_in_api_server: passes through returncode 0 / 2; treats None returncode as failure
  • scale_release_workloads_to_zero: patches Deployments + StatefulSets to replicas=0 with the right selector; raises TimeoutError on stuck drain

All pass: uv run --project chart pytest chart/tests/helm_tests/airflow_aux/test_migrate_database_job.py chart/tests/helm_tests/airflow_aux/test_migrate_database_job_rbac.py chart/tests/helm_tests/airflow_aux/test_db_migrate_script.py -q66 passed.

A kind-based end-to-end scenario (install 3.0.x → upgrade 3.1.x → downgrade 3.0.x) listed in #68072 is still a follow-up.

Backward compatibility

  • Same job name, same value keys, same ServiceAccount name — no values-file changes required.
  • Hook moves from post-install,post-upgrade to pre-install,pre-upgrade. Functionally equivalent for forward migrations; removes the race with the waitForMigrations initContainer on new pods.
  • Upgrade and same-version paths are behaviourally identical for existing users.
  • Downgrade is always permitted — no opt-in flag. Today a downgrade helm upgrade half-succeeds and leaves the cluster broken; after this change it completes cleanly (with a brief outage).

Was generative AI tooling used to co-author this PR?
  • Yes — Claude Opus 4.7

Generated-by: Claude Opus 4.7 following the guidelines

jykae added 5 commits June 5, 2026 13:46
…e-job

Embed a bash entrypoint that picks the right action at runtime — noop,
forward migrate, or downgrade into the still-running api-server pod —
based on the alembic revision in the metadata DB versus the target
airflow_version declared in the chart.

This is the building block consumed in the next commit, which switches
the migrate-database-job to use it by default.

Related: apache#68072
…ally

A helm upgrade across an Airflow version now does the right thing in
both directions automatically:

* On upgrade, the job runs pre-install/pre-upgrade and forward-migrates
  the metadata DB before the new images start rolling out.
* On rollback (helm upgrade to an older airflowVersion), the job
  detects the version regression and executes 'airflow db downgrade'
  inside the still-running api-server pod, which still ships the
  reverse alembic scripts for its own version.

The new behaviour is wired up by switching migrateDatabaseJob.args from
a baked-in 'airflow db migrate' command to the embedded reconciler
script. Users who already supply their own command/args are unaffected:
their override still wins.

Two new envs feed the script: AIRFLOW_TARGET_VERSION (from .airflowVersion)
and POD_NAMESPACE (downward API).

Related: apache#68072
The bidirectional reconciler shells into the running api-server pod
via the Kubernetes API to run 'airflow db downgrade' there (where the
reverse alembic scripts still exist). Without these RBAC rules the
downgrade branch would fail with a 403 on the in-cluster ServiceAccount
the job already uses.

Gated on .Values.rbac.create and .Values.migrateDatabaseJob.enabled so
no extra resources are rendered for clusters that opt out of either.

Related: apache#68072
* New tests verify pre-install/pre-upgrade hook annotations, embedded
  default script presence, AIRFLOW_TARGET_VERSION + POD_NAMESPACE envs,
  and that user-supplied command/args still win over the script default.
* New RBAC test file covers the Role + RoleBinding rendering, rule
  contents (pods get/list, pods/exec create/get), namespace scoping,
  rbac.create / migrateDatabaseJob.enabled gating, and the ServiceAccount
  subject wired in by the existing helper.
* Pre-existing tests that asserted the previous fixed default args were
  relaxed to acknowledge the new bidirectional default; explicit-override
  semantics are still covered.

Related: apache#68072
jykae added 5 commits June 5, 2026 13:49
Replaces files/db_migrate.sh (bash wrapper around three python3 heredocs)
with files/db_migrate.py - a single Python entrypoint invoked as
'python3 -c ...'. Same decision logic and same external behaviour:
noop / forward (airflow db migrate) / downgrade (kubectl-style exec into
the still-running api-server pod).

Tracked in apache#68072.
A rolling helm upgrade in the downgrade direction would leave the OLD
api-server / scheduler / triggerer / dag-processor / worker pods talking
to a now-downgraded schema (the reverse alembic scripts can drop columns
the OLD code still queries). To avoid that broken window, the downgrade
branch of the migrate-database-job now:

  1. execs 'airflow db downgrade' inside the still-running api-server pod
     (unchanged), then
  2. scales every DB-touching Deployment and StatefulSet of this release
     to 0 and waits for the pods to terminate.

Helm then patches the same workloads back to their configured replicas
with the TARGET image as the upgrade proceeds, so the cluster comes back
up cleanly on the target version. This trades the otherwise-broken
rolling update for a brief outage, which is unavoidable when the schema
goes backwards.

Required additions:
- env: RELEASE_NAME to scope the scale-to-zero to this release.
- RBAC: deployments + statefulsets (get, list) and
  deployments/scale + statefulsets/scale (get, patch) on the existing
  migrate-database-job Role.

Tracked in apache#68072.
* Walk target image's revision graph from base to target_rev instead of
  from base to current_rev. On a downgrade the TARGET image's
  ScriptDirectory does not contain current_rev, so the previous code
  raised alembic RevisionError before ever deciding 'downgrade'. Check
  membership of current_rev in target's ancestors instead.
* Narrow the broad 'except Exception' around engine.connect() to
  sqlalchemy.exc.OperationalError so config / programming errors are not
  silently reported as a fresh install.
* Move airflow / alembic / kubernetes / sqlalchemy imports to module
  top per Airflow code-style rules.
* Treat None exit code from the kubernetes exec stream as failure
  instead of silently reporting success.
* Use subprocess.run(..., check=False).returncode in place of
  subprocess.call for cleaner signal propagation.
The existing helm tests only verify that the script is embedded with the
right env vars / args / RBAC. They never exercise decide_action,
discover_api_server_pod, run_downgrade_in_api_server, or
scale_release_workloads_to_zero.

Adds:

* decide_action: unknown target -> forward, OperationalError -> fresh,
  no alembic row -> fresh, current==target -> noop,
  current is ancestor of target -> forward,
  current is NOT in target's ancestors -> downgrade (regression test
  for the original blocker where the script walked from base to an
  unknown current_rev and raised alembic RevisionError).
* discover_api_server_pod: prefers Ready, falls back to non-Ready,
  raises when no api-server pod is Running.
* run_downgrade_in_api_server: zero / non-zero pass through, None
  returncode is treated as failure (regression for the silent-success
  bug from `return returncode or 0`).
* scale_release_workloads_to_zero: patches every matched Deployment
  and StatefulSet to replicas=0, waits for drain, raises TimeoutError
  when pods never terminate.
@jykae jykae marked this pull request as ready for review June 5, 2026 11:57
jykae added 2 commits June 5, 2026 15:21
…ed set

The new Role and RoleBinding rendered by templates/rbac/migrate-database-job-role.yaml
and templates/rbac/migrate-database-job-rolebinding.yaml weren't listed in the
expected set used by test_deployments_with_rbac_{no_sa,with_sa}, so the assertion
'rendered manifests == expected list' fired in the K8s 1.30 helm-tests job.
Copy link
Copy Markdown
Contributor

@jscheffl jscheffl left a comment

Choose a reason for hiding this comment

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

Took me a moment to read and igest the idea. First I was a bit reluctant but then reading more and having some hours of thought I really like the idea.

Some comments besides CI is not getting green - probably you find the bug.

I see this as a very cool improvement the more I think about this and would like to vote for having this. Downgrade with Helm was always a pain. This PR addresses this.

What do other reviewers think about it? @bugraoz93 @jedcunningham @Miretpl @potiuk ?

Comment thread chart/files/db_migrate.py
* ``RELEASE_NAME`` - the helm release name, used to scope the scale-down to
only the workloads owned by this release.

Reference: https://github.com/apache/airflow/issues/68072
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think reference to a past issue is not really needed. Context from above is sufficient.

Comment thread chart/files/db_migrate.py
Comment on lines +64 to +66
# NOTE: _REVISION_HEADS_MAP is a private symbol in airflow.utils.db. Tracked in
# #68072 to expose a public accessor; using the private name is the only way
# today to map a target version string to an alembic revision.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would be great to open a PR that exposes the needed information on a public interface in parallel such that this can land in Airflow 3.3. Then this script could attempt first to take the public method and fall-back on private member until (somewhen in future) support for Airflow 3.3 is cleaned.

Comment thread chart/files/db_migrate.py
Comment on lines +101 to +102
ancestors_of_target = {rev.revision for rev in script.walk_revisions("base", target_rev)}
if current_rev in ancestors_of_target:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are you not looking into _REVISION_HEADS_MAP like above? Assuming that only "real" versions are listed I think this is sufficient, if somebody has a version in between or build manually from main, I think would be OK not to support all side-cases. Would reduce dependability in internal Airflow details.

Comment thread chart/files/db_migrate.py
Comment on lines +122 to +125
ready = [
p for p in pods if any(c.type == "Ready" and c.status == "True" for c in (p.status.conditions or []))
]
return (ready or pods)[0].metadata.name
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Handling of case that no pod is ready is missing, gets an array-out-of-bounds.

Comment thread chart/files/db_migrate.py
return (ready or pods)[0].metadata.name


def run_downgrade_in_api_server(pod_name: str, namespace: str, target_version: str) -> int:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As API Server might be "rolled-over" can you add a @retry() decorator to this for resiliency?

)


def _patch_script_dir(db_migrate, monkeypatch, ancestors_by_head):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not making this a fixture?

# --------------------------------------------------------------------------


def _pod(name, ready=True):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not making this a fixture?

# --------------------------------------------------------------------------


def _fake_stream(returncode):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not making this a fixture?

# --------------------------------------------------------------------------


def _wl(name):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not making this a fixture?

Comment thread chart/values.yaml
# When unset, the chart runs a bidirectional reconciliation script
# (forward migrate, no-op, or exec downgrade against the running api-server)
# depending on whether the chart is being upgraded or downgraded.
# See https://github.com/apache/airflow/issues/68072.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also here... ticket ref not needed.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the Airflow Helm chart’s existing migrate-database-job so a single helm upgrade can reconcile the metadata DB schema in either direction (forward migrate, no-op, or downgrade) based on the chart’s target airflowVersion, running as a pre-install,pre-upgrade hook and adding the RBAC needed to exec into the running api-server and scale DB-touching workloads to zero on downgrades.

Changes:

  • Switch the migrations Job hook to pre-install,pre-upgrade and change the default container args to run an embedded Python reconciler (chart/files/db_migrate.py) via python3 -c.
  • Add a namespace-scoped Role/RoleBinding granting pods, pods/exec, and deployments|statefulsets/* scale permissions to support downgrade+drain.
  • Add Helm template tests and unit tests for the embedded reconciler; update RBAC/security expectations accordingly.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
chart/values.yaml Removes the hard-coded default migration args and documents the new “unset => embedded reconciler” behavior.
chart/values.schema.json Aligns schema docs/defaults with args: null so the chart can inject its default script.
chart/templates/jobs/migrate-database-job.yaml Moves hook earlier and embeds db_migrate.py as default args; injects env vars used by the script.
chart/files/db_migrate.py New runtime reconciler that decides forward/no-op/downgrade and executes downgrade via api-server pod + drains DB-touching workloads.
chart/templates/rbac/migrate-database-job-role.yaml New Role granting pod exec + workload scale permissions needed for downgrade/drain.
chart/templates/rbac/migrate-database-job-rolebinding.yaml New RoleBinding attaching the Role to the existing migrations ServiceAccount.
chart/tests/helm_tests/airflow_aux/test_migrate_database_job.py Updates/extends template assertions for new hooks, default args, and env vars.
chart/tests/helm_tests/airflow_aux/test_migrate_database_job_rbac.py New Helm tests validating the new Role/RoleBinding and their gating.
chart/tests/helm_tests/airflow_aux/test_db_migrate_script.py New unit tests for the embedded reconciler logic and Kubernetes/Alembic interactions.
chart/tests/helm_tests/security/test_rbac.py Updates the security RBAC inventory to include the new migrations Role/RoleBinding.
chart/tests/helm_tests/airflow_aux/test_basic_helm_chart.py Updates expected rendered resource list to include the new RBAC objects.
chart/newsfragments/68074.significant.rst Adds release notes describing hook change, new reconciler behavior, and RBAC additions.

Comment thread chart/files/db_migrate.py
Comment on lines +70 to +75
def decide_action(target: str) -> str:
"""Return one of ``noop``, ``forward``, ``downgrade``, ``fresh``."""
target_rev = _REVISION_HEADS_MAP.get(target)
if target_rev is None:
# Unknown target version (e.g. dev build). Be conservative: forward only.
return "forward"
Comment on lines 106 to +108
{{- if .Values.migrateDatabaseJob.args }}
args: {{- tpl (toYaml .Values.migrateDatabaseJob.args) . | nindent 12 }}
{{- else }}
Comment on lines +108 to +112
def test_decide_action_unknown_target_falls_back_to_forward(db_migrate, monkeypatch):
# Empty map: any target is unknown -> conservative forward migrate.
monkeypatch.setattr(db_migrate, "_REVISION_HEADS_MAP", {})
assert db_migrate.decide_action("9.9.9") == "forward"

Comment on lines +172 to +179
def test_discover_api_server_pod_prefers_ready(db_migrate, monkeypatch):
fake_api = mock.MagicMock()
fake_api.list_namespaced_pod.return_value.items = [
_pod("api-server-old", ready=False),
_pod("api-server-new", ready=True),
]
monkeypatch.setattr(db_migrate.k8s_config, "load_incluster_config", lambda: None)
monkeypatch.setattr(db_migrate.client, "CoreV1Api", lambda: fake_api)
Comment on lines +5 to +10
script that picks one of three actions depending on the relationship between
the chart's ``airflowVersion`` and the alembic head currently in the database:

* ``target == current`` — no-op.
* ``target > current`` — ``airflow db migrate`` (existing behaviour).
* ``target < current`` — ``airflow db downgrade --to-version <target>``
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:helm-chart Airflow Helm Chart

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Helm chart: support bidirectional Airflow metadata DB reconciliation on helm upgrade (downgrade as well as upgrade)

3 participants