feat: fail-fast preflight for extension update paths - #450
feat: fail-fast preflight for extension update paths#450WentingWu666666 wants to merge 3 commits into
Conversation
The operator upgrades the documentdb extension with a blanket
`ALTER EXTENSION documentdb UPDATE`, with no check that PostgreSQL can
actually resolve an update path. When the extension image is missing a
`documentdb--<from>--<to>.sql` script anywhere in the requested range,
the ALTER fails at execution time with a raw PostgreSQL error that
re-fires on every reconcile, leaving the user with no actionable signal.
Add a read-only preflight against `pg_extension_update_paths` that runs
immediately before the ALTER. When the absence of a path is positively
proven, the operator skips the ALTER, emits a Warning event, and reports
`SchemaUpgradeBlocked=True` / `NoUpdatePath` with a message naming both
versions and the remediation. Any error, unparseable output, or
unexpected version format fails open, preserving the pre-existing
behavior of letting PostgreSQL decide.
The query wraps the lookup in `COALESCE(..., 'NO_UPDATE_PATH')` so that
both "no rows" (version not advertised) and "NULL path" (known but
unreachable) collapse into a single, always-present row, which makes the
psql output unambiguous to parse.
Also adds `status.conditions` to the DocumentDB CRD, reconciled across
every migration-planning path so a stale block cannot survive a user
correcting their spec.
Test coverage:
- 18 unit specs covering the SQL shape, parser, fail-open paths, the
blocked/unblocked transitions, and recovery.
- Three e2e specs closing the gaps deferred from documentdb#439: jumps of more
than one minor, a sequential chain through every published version,
and a deterministic migration failure built from a never-released
patch version (reachable with stock images now that the preflight
turns it into a clean, observable stop).
Closes documentdb#448
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
f359f18 to
9ffca01
Compare
There was a problem hiding this comment.
Pull request overview
Adds a fail-fast (but fail-open on inconclusive) preflight for DocumentDB extension schema upgrades so the controller can prove when PostgreSQL has no ALTER EXTENSION ... UPDATE path, surface an actionable status signal, and avoid repeated reconcile-time ALTER failures. This also introduces standard status.conditions on the DocumentDB CRD and adds e2e coverage for previously deferred multi-version upgrade scenarios and the “no update path” failure mode.
Changes:
- Add a
pg_extension_update_pathspreflight before runningALTER EXTENSION documentdb UPDATE, and reportSchemaUpgradeBlocked=True(reasonNoUpdatePath) when no path exists. - Introduce
DocumentDB.status.conditions(withSchemaUpgradeBlockedcondition + reasons) and reconcile it across schema-upgrade planning paths. - Add new e2e upgrade specs for multi-minor jumps, sequential version chains, and blocked schema migrations; document the new env var used to drive version chains.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
operator/src/internal/controller/documentdb_controller.go |
Adds update-path preflight + status condition reconciliation around schema upgrades. |
operator/src/internal/controller/documentdb_controller_test.go |
Extends unit coverage for the preflight, parsing, fail-open behavior, and condition transitions. |
operator/src/api/preview/documentdb_types.go |
Adds status.conditions and defines SchemaUpgradeBlocked + reason constants. |
operator/src/api/preview/zz_generated.deepcopy.go |
Updates deepcopy generation for the new Conditions field. |
operator/src/config/crd/bases/documentdb.io_dbs.yaml |
CRD schema update to include status.conditions. |
operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml |
Helm-packaged CRD update to include status.conditions. |
test/e2e/tests/upgrade/upgrade_schema_preflight_test.go |
Adds e2e coverage for blocked schema migration (no update path) and recovery after retargeting. |
test/e2e/tests/upgrade/upgrade_schema_multiversion_test.go |
Adds Lowest-tier e2e specs for multi-minor jump + sequential upgrade chain. |
test/e2e/tests/upgrade/helpers_test.go |
Adds helpers for version-chain config and reading SchemaUpgradeBlocked condition. |
test/e2e/README.md |
Documents E2E_UPGRADE_DOCUMENTDB_VERSION_CHAIN. |
docs/operator-public-documentation/preview/operations/upgrades.md |
Documents skipping versions + how SchemaUpgradeBlocked is surfaced. |
docs/operator-public-documentation/preview/api-reference.md |
Regenerated API reference output (includes unrelated drift fixes per PR description). |
CHANGELOG.md |
Records the preflight/condition behavior under Unreleased. |
Files not reviewed (1)
- operator/src/api/preview/zz_generated.deepcopy.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if err := r.setSchemaUpgradeBlockedCondition(ctx, documentdb, metav1.ConditionFalse, | ||
| dbpreview.ReasonUpdatePathAvailable, | ||
| fmt.Sprintf("Update path from %s to %s is resolvable.", | ||
| util.ExtensionVersionToSemver(installedVersion), | ||
| util.ExtensionVersionToSemver(schemaTarget)), | ||
| ); err != nil { | ||
| logger.Error(err, "Failed to clear SchemaUpgradeBlocked condition") | ||
| } |
| // e.g. old "0.109.0" → unreachable "0.109.7": above the installed | ||
| // schema, below the new binary, and never released — so no | ||
| // documentdb--0.109-0--0.109-7.sql exists. |
| // documentDBVersionChain returns the ascending list of published DocumentDB | ||
| // versions used by the multi-version upgrade specs, read from | ||
| // envDocumentDBVersionChain (comma-separated) or falling back to | ||
| // defaultDocumentDBVersionChain. Specs that need more entries than are | ||
| // configured should Skip rather than fabricate versions. | ||
| func documentDBVersionChain() []string { | ||
| raw := envOr(envDocumentDBVersionChain, defaultDocumentDBVersionChain) | ||
| var out []string | ||
| for _, part := range strings.Split(raw, ",") { | ||
| if v := strings.TrimSpace(part); v != "" { | ||
| out = append(out, v) | ||
| } | ||
| } | ||
| return out | ||
| } |
Relationship to #444Flagging an overlap I found after opening this: #444 (open since 2026-08-13) already implements the core preflight against
Two deliberate behavioral differences worth a maintainer opinion:
The Suggestion: land this one and close #444, rather than merging #444 first and rebasing this on top — they touch the same function and the merge would be mostly conflict resolution. Happy to do it the other way round if reviewers prefer; say the word. |
`setup-test-environment` verified the loaded images with
`docker images ... | grep -q "$IMAGE"`. Under the step's `set -o pipefail`,
`grep -q` exits as soon as it matches, docker takes SIGPIPE, and the
pipeline reports 141 — so a *successful* match fails the check. The race
depends on how early the match appears in docker's output and how much
output remains, which is why it fires intermittently and then fails every
shard at once.
Capture the image list into a variable and match against that, which
removes the pipe entirely. Also switch to `grep -qxF` so the comparison is
an exact literal line rather than a substring regex. The cert-manager
`helm list | grep -q` check had the same shape and is fixed the same way.
Repro of the old behavior:
$ set -o pipefail
$ seq 1 10000 | grep -q 1; echo $?
141
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
Note on the second commit (
|
`TEST_DEPTH` accepts Highest|High|Medium|Low|Lowest (see test/e2e/levels.go), but the workflow_dispatch `depth` choice only offered Low|Medium|High. Specs declared at `level:lowest` — including the multi-version schema-upgrade specs added in this branch — were therefore unreachable from CI: they compile and are label-selected, but the runtime depth gate skips them at every tier the dispatch could request. List all five tiers. The default stays Medium, so scheduled and PR runs are unchanged; this only widens what a manual dispatch can ask for. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Wenting Wu <wentingwu@microsoft.com>
Note on the third commit (
|
|
🤖 Auto-triaged by documentdb-triage-tool. Applied: Reasoningcomponent from path globs (docs, test, api, controllers, ci, manifests); effort from diff stats (1663+65 LOC, 15 files); LLM: Multi-file change adding preflight logic, new status conditions, and reconcile-path handling to the extension upgrade controller — touches schema/status fields and multiple code paths. If a label is wrong, remove it manually and ping |
Closes #448. Follows up on #439 (deferred e2e gaps) and relates to #426 / #443.
Problem
handleExtensionUpgraderuns a blanketALTER EXTENSION documentdb UPDATEwith no check that PostgreSQL can resolve an update path. If the extension image is missing adocumentdb--<from>--<to>.sqlscript anywhere in the requested range, theALTERfails at execution time with a raw PostgreSQL error that re-fires every reconcile. The user gets a cryptic failure, no status signal, and no indication of what to do.What this does
Adds a read-only preflight against
pg_extension_update_pathsimmediately before theALTER:When the absence of a path is positively proven, the operator:
ALTERentirely (no partial migration,status.schemaVersiondoes not move),Warningevent, andstatus.conditions[SchemaUpgradeBlocked] = Truewith reasonNoUpdatePathand a message naming both versions plus the remediation,It fails open on anything inconclusive. A SQL error, unparseable psql output, or an unexpected version format all proceed with the
ALTERexactly as before. The preflight is an advisory improvement; an inconclusive check must never wedge an upgrade that would otherwise succeed (e.g. on a PostgreSQL build lacking the function).Why the
COALESCEsentinelpg_extension_update_pathsexpresses "no path" two different ways: aNULLpath when the pair is known-but-unreachable, and zero rows when either version isn't advertised at all. Those produce different psql output shapes (an empty field vs. a(0 rows)footer), which is fragile to parse. TheCOALESCEcollapses both into a single always-present, never-empty row, so the parse is unambiguous and can't misread a gap as a valid path.New status conditions
DocumentDBStatusgains a standardconditionslist.SchemaUpgradeBlockedis reconciled on every migration-planning path —SchemaUpToDate,UpdatePathAvailable,NoMigrationPlanned(rollback / two-phase mode),NoUpdatePath— so a staleTruecannot survive a user correcting their spec.meta.SetStatusCondition's return value suppresses redundant status writes, and the write isRetryOnConflict-wrapped because the informer read can lag a status update made earlier in the same reconcile.$ kubectl get documentdb my-db -o jsonpath='{.status.conditions[?(@.type=="SchemaUpgradeBlocked")]}'Test coverage
Unit (18 new specs, run in the PR gate): SQL shape, parser edge cases, both fail-open paths, the version-format guard, blocked → no
ALTERfires, preflight targets the pinned version rather than the binary version, and both recovery transitions (revert to two-phase, retarget to a reachable version).e2e — closes the three gaps deferred from #439:
upgrade_schema_multiversion_test.goupgrade_schema_multiversion_test.goALTER EXTENSION UPDATEfailsupgrade_schema_preflight_test.goThe migration-failure spec was deferred in #439 because reproducing it needed a doctored extension image. It's now reachable with stock images: request a patch version that was never released (e.g.
0.109.999while installed is0.109.0and the binary is0.110.0). That is<=the binary version so the validating webhook admits it, and>the installed version sodetermineSchemaTargetplans a migration — but no update script exists. The spec skips itself if the configured old/new versions share a major.minor, where the trick wouldn't hold.A new
E2E_UPGRADE_DOCUMENTDB_VERSION_CHAINenv var (documented intest/e2e/README.md) drives the multi-version specs, defaulting to the published tags0.109.0,0.110.0,0.113.0,0.114.0.Open: the three contract questions in #448 are still unratified
#448 asks maintainers to ratify three points, and there's been no answer. This implementation deliberately doesn't foreclose any of them:
specvalidation, not a change here.Happy to adjust if maintainers land somewhere different.
Notes for reviewers
docs/.../api-reference.mdcarries some unrelated drift (ComponentResources, thepostgresimage pin,disableTLS). That's pre-existing staleness thatmake api-docscorrected — not part of this change.make lintreports 8SA1019staticcheck findings. All 8 are present onmainunchanged; this PR adds none.E2E_UPGRADE=1to actually execute.Validation
make test— all packages pass (internal/controllercoverage 56.3% → 56.5%)make manifests generate fmt vet api-docs— no drifthelm unittest— 88/88 passgo vet ./...+gofmtintest/e2e— cleanTEST_DEPTH=4 ginkgo --dry-run ./tests/upgrade/...— 11/12 specs construct (1 pre-existing pending), all new specs present with correct labels