Skip to content

acceptance: schema-driven invariant fuzzing for bundle configs#5686

Open
radakam wants to merge 54 commits into
mainfrom
deco-25361-fuzz-create-payload
Open

acceptance: schema-driven invariant fuzzing for bundle configs#5686
radakam wants to merge 54 commits into
mainfrom
deco-25361-fuzz-create-payload

Conversation

@radakam

@radakam radakam commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Adds an acceptance-test fuzzing harness that stresses the bundle engine by generating random configs and asserting deploy invariants. Because the schema comes from the CLI under test, a failure is a real bug: panic, internal error, or drift.

What it does

  • Generate mode (acceptance/bin/gen_fuzz_config.py): builds a random resource by walking the live databricks bundle schema. Pins reference/typed fields (catalog, schema, parent_path, file/notebook paths, durations) and skips output-only/computed fields to keep configs deployable, and ~15% of free-form scalars get a dangerous / near-range-end value (empty, whitespace, over-long, control chars, non-ASCII, path traversal, int32/int64 boundaries).
  • Mutate mode (acceptance/bin/mutate_fuzz_config.py): starts from a curated invariant config and applies 1-3 seeded mutations — destructive (delete a field, set a fuzz token, set a dangerous value, or empty a container) plus, with the schema, additive (inject a valid optional field the base omits). Dispatched by acceptance/bin/emit_fuzz_config.py via FUZZ_MODE.
  • Invariants run each config through the real invariant scripts, which share acceptance/bundle/invariant/prologue.sh (config render, destroy-on-exit trap, and deploy-capture helper):
    • no_drift — deploy, then assert the post-deploy plan has no actions. In fuzz mode, where fake-server fidelity makes exact drift unreliable, it instead asserts plan determinism (two consecutive plans byte-identical).
    • migrate — Terraform deploy → migrate to direct → assert no drift. In fuzz mode it asserts only that no command panics.
  • Runner (acceptance/bundle/invariant/fuzz/script): classifies each seed as deployed / rejected / gap / bug / hang, separating genuine bugs from config rejections and testserver coverage gaps. Adds a per-seed cap (FUZZ_SEED_TIMEOUT, SIGQUIT→SIGKILL so a hang dumps goroutines), an overall budget with clean truncation (FUZZ_TIME_BUDGET), an optional replayable corpus (FUZZ_CORPUS_DIR), and a per-seed/per-variant tally in LOG.summary.

The matrix is {no_drift, migrate} × {generate, mutate} — 4 leaf variants.

Supporting changes

  • Taskfile.yml: task test-fuzz (wider window, drift on) and task test-fuzz-cover (runs the corpus under -cover, reports per-package CLI coverage).
  • .github/workflows/push.yml: nightly (cron) fuzz job over a rotating, non-overlapping seed window (from GITHUB_RUN_NUMBER) that comments failures on the PR that introduced the commit, with a single-seed repro.
  • libs/testserver: new IgnoreUnhandledRequests option (a test.toml field, plumbed through startLocalServer) that returns 501 and logs unmodeled routes as coverage gaps instead of failing the run. Curated tests leave it off so real missing-handler bugs stay loud.
  • Guards/selftests: check_schema_types.py (fails loudly on a schema type the generator can't produce), gen_fuzz_config_check.py / mutate_fuzz_config_check.py (+ acceptance selftests), a shared util.load_plan, and verify_no_drift.py now failing cleanly on empty/unparseable plan output.

Reproducing a failure

FUZZ_SEED_START=<seed> FUZZ_SEED_COUNT=1 FUZZ_TARGET=<no_drift|migrate> FUZZ_MODE=<generate|mutate> task test-fuzz

Bugs found by the fuzzer

Bug Short description Invariant Via mutate? PR
Direct-engine reconcile misclassification (catalogs, registered_models browse_only, serving burst_scaling_enabled + *_plaintext) Fields the backend computes or never echoes on GET were unclassified → permanent update after deploy; redeploy could 400 no_drift No #5816 (merged)
Loosely-typed required UC fields not validated (sql_warehouses..name, grants[].principal) Modeled optional but backend-required; deploy died on a late 400 → now caught at validate no_drift No #5818 (merged)
SQL warehouse redeploy hang DoUpdate reconciled from stale pre-plan state, so redeploy with lifecycle.started=false hung waiting for STOPPED no_drift No #5864 (merged)
registered_models alias drift GET uses IncludeAliases=false, so aliases never echo back → perpetual update; reclassified input-only no_drift No #5864 (merged)
UC-managed catalog/schema property defaults UC fills managed defaults after create → plans keep retrying empty updates UC rejects; fixed via a backend-default rule no_drift No #5877 (merged)
Pipeline parameters dropped on read-back (test server) Fake decoded into PipelineSpec (no parameters) → re-read lost it → phantom update no_drift No #5942 (merged)
App deploy-only fields with no active deployment (source_code_path, config, git_source) DoRead reads these only from an active deployment; a stopped app has none → phantom update no_drift No #5943 (merged)
Experiment TraceLocation dropped by test server Fake ExperimentCreate dropped immutable TraceLocation → spurious recreate every plan no_drift No #5993 (merged)
model_serving ai_gateway invalid_keywords empty-string → provider panic An empty string in invalid_keywords crashes the Terraform provider on create; validate passes it through migrate No Delegated to TF provider (upstream)
jobs.webhook_notifications.on_* reordered after migrate → perpetual update Jobs API returns webhook lists as unordered sets; the direct engine diffed them positionally → phantom update. Fixed by keying the on_* lists by id, like depends_on migrate No #6060
Empty grants node planned as create after migrate (schemas.*.grants: []) migrate never seeds an empty grants sub-resource into direct state → the first post-migrate plan wants to create it migrate Yes #6039
grants with empty principal only warned, not rejected → partial deploy + backend 400 A registered_models grant with no principal passes validate, deploys the model, then the grant PATCH 400s no_drift Yes #6041 (merged)
grants with empty privileges → perpetual update (catalogs..grants[].privileges: []) A principal with an empty privileges list assigns nothing on the backend, so DoRead returns no assignment → the entry never matches remote → phantom update that never converges. Shared grants planner, so it affects any securable with grants no_drift Yes #6062

@radakam
radakam temporarily deployed to test-trigger-is June 23, 2026 07:44 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 23, 2026 07:44 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 23, 2026 08:06 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 23, 2026 08:06 — with GitHub Actions Inactive
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: c66e0ef

Run: 30083209115

Env 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 4 4 322 1062 11:14
💚​ aws windows 4 4 324 1060 7:38
💚​ azure linux 4 4 322 1061 9:13
💚​ azure windows 4 4 324 1059 9:19
💚​ gcp linux 1 5 321 1063 12:02
💚​ gcp windows 1 5 323 1061 7:39
8 interesting tests: 4 RECOVERED, 4 SKIP
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo 💚​R 💚​R 💚​R 💚​R 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo/root 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/subdir 💚​R 💚​R 💚​R 💚​R
Top 11 slowest tests (at least 2 minutes):
duration env testname
5:49 azure windows TestFilerWorkspaceFilesExtensionsReadDir
5:02 gcp windows TestAccept
3:18 azure windows TestAccept
3:14 aws windows TestAccept
2:41 gcp linux TestImportDirDoesNotOverwrite
2:29 aws linux TestFilerWorkspaceFilesExtensionsRead
2:13 aws windows TestAccept/bundle/deploy/mlops-stacks/DATABRICKS_BUNDLE_ENGINE=terraform
2:07 gcp linux TestFilerWorkspaceFilesExtensionsReadDir
2:07 azure windows TestAccept/bundle/deploy/mlops-stacks/DATABRICKS_BUNDLE_ENGINE=terraform
2:05 azure linux TestAccept/bundle/deploy/mlops-stacks/DATABRICKS_BUNDLE_ENGINE=direct
2:02 aws linux TestAccept/bundle/deploy/mlops-stacks/DATABRICKS_BUNDLE_ENGINE=direct

@radakam
radakam temporarily deployed to test-trigger-is June 23, 2026 09:47 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 23, 2026 09:47 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 08:29 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 08:29 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 12:05 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 12:05 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 13:27 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 13:27 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 13:35 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 24, 2026 13:35 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 25, 2026 11:42 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 25, 2026 11:42 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 25, 2026 11:54 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 25, 2026 11:54 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 25, 2026 17:47 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 25, 2026 17:47 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 26, 2026 07:42 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 26, 2026 07:42 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 26, 2026 08:22 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 26, 2026 08:22 — with GitHub Actions Inactive
@radakam
radakam marked this pull request as ready for review June 26, 2026 08:34
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Approval status: pending

/acceptance/bundle/ - needs approval

8 files changed
Suggested: @denik
Also eligible: @pietern, @janniklasrose, @andrewnester, @shreyas-goenka, @anton-107, @lennartkats-db

General files (require maintainer)

20 files changed
Based on git history:

  • @denik -- recent work in acceptance/bin/, acceptance/bundle/invariant/, ./

Any maintainer (@andrewnester, @anton-107, @denik, @pietern, @shreyas-goenka, @simonfaltum, @renaudhartert-db, @janniklasrose, @lennartkats-db) can approve all areas.
See OWNERS for ownership rules.

Comment thread bundle/config/mutator/resourcemutator/cluster_fixups.go Outdated
Comment thread bundle/fuzz/generate_test.go Outdated
@radakam
radakam temporarily deployed to test-trigger-is June 26, 2026 15:45 — with GitHub Actions Inactive
@radakam
radakam temporarily deployed to test-trigger-is June 26, 2026 15:45 — with GitHub Actions Inactive
radakam added 30 commits July 23, 2026 09:29
Add common.sh with invariant_deploy and _invariant_cleanup so the
deploy-based bodies (no_drift, redeploy, update, destroy_recreate) and
migrate no longer duplicate the validate/deploy/cleanup prologue. Unify
the panic check on '!panic:' across all invariant bodies and fuzz/script
so a random generated token containing "panic" can't be a false positive.

Also fold in the supporting helpers: check_schema_types.py (fail loud on a
schema type the generator can't produce), gen_fuzz_config_check.py plus its
selftest (to_yaml contract), util.load_plan shared by the verify_* scripts,
and a pass to shorten comments across the harness.
The comment describes connection_name, managed_encryption_settings and
custom_max_retention_hours, which are the fields the fix added. It sat
above StorageRoot/ProviderName/ShareName, which were already round-tripped.
Move it directly above the three added fields so it annotates them.
Replace the shared `<name>.sh` invariant bodies (sourced via FUZZ_INVARIANT and
common.sh) with self-contained invariant test directories that double as fuzz
targets, selected by FUZZ_TARGET. Each target runs over the curated INPUT_CONFIG
matrix and, when FUZZ_SEED is set, against a schema-generated random config.

- Inline the deploy/drift logic into no_drift/script and migrate/script
- Re-add redeploy, canonical, update, and destroy_recreate as invariant dirs
- Drop common.sh and the standalone *.sh bodies
- Point fuzz/script at ../$FUZZ_TARGET/script and refresh the README
The origin/main merge added the volume_path_job_ref.yml.tmpl fuzz template to
the INPUT_CONFIG matrix, but the canonical/destroy_recreate/redeploy/update
out.test.toml goldens were not regenerated, failing the "changed or new files"
CI guard.
The generator produced random catalog_name/schema_name values and random or
empty grants. The fake test server accepts them, but real UC rejects them
(CATALOG_DOES_NOT_EXIST, invalid principal/privilege), so such configs deploy
locally yet drift or fail on cloud, masking real invariant coverage.

Pin catalog_name to "main" and schema_name to "default" (the seeded objects
used by the curated invariant configs), and emit one known-good grant per
grant-bearing securable type ("account users" plus a privilege valid for that
type). This removes the spurious grants drift and the reference-rejection class.
Extend the invariant fuzzer to emit more than one resource per config
(--resource-count, matrixed as FUZZ_RESOURCE_COUNT) and link two of them
with a ${resources.*} reference so the interpolation and deploy-ordering
paths are exercised. The reference targets an input identity field
(name/display_name) so it resolves for every resource type and converges
without drift.

Also improve generated-config validity: skip output-only/computed fields
(x-databricks-field-behaviors OUTPUT_ONLY, readOnly, and a name list) to
avoid false drift after migrate, emit valid permissions per resource type,
force prevent_destroy=false so destroy_recreate can run, and add required
fields the schema omits for registered_models.
Gate the resource key/name suffix on the resource index, not the total count,
so a given seed produces the same first resource whether --resource-count is 1
or greater. Later resources stay index-suffixed to remain unique.
inject_cross_ref stopped after a single ${resources.*} edge, so a config
with N resources still exercised only one reference. Link each resource to
an earlier one instead, keeping the graph acyclic so deploy can order it,
and add resource-count 3 to the matrix so the multi-link path runs in CI.
test.toml already lists resource count 3; regenerate the recorded matrix
to match so the acceptance framework does not flag a mismatch.
Point file_path/source_code_path fields at the staged data/. fixtures and
pin typed-string fields so generated configs deploy instead of getting
rejected. Also ignore the local .fuzztmp/ driver scratch directory.
Add a mutate engine (mutate_fuzz_config.py) that applies seeded
delete/set/dangerous-value mutations to a curated base config, and a
fuzz_gen_config.py dispatcher selected by FUZZ_MODE (generate|mutate).
Wire the dispatcher into the six invariant target scripts, matrix
FUZZ_MODE in fuzz/test.toml (excluding redundant mutate runs at
resource-count 2/3), and add a selftest covering the YAML loader
round-trip and mutation determinism.
The schema-walk generator pinned every scalar to a known-good value, so
dangerous / near-range-end input was only exercised by mutate mode. Inject
DANGEROUS_STRINGS/DANGEROUS_INTS into free-form scalars (~15% of the time) so
generate mode probes empty/whitespace/over-long/control-char strings and
int32/int64 boundaries while pinned fields keep values that still deploy.
Centralize the two lists in gen_fuzz_config.py so mutate_fuzz_config.py reuses
them unchanged.
The update invariant edits a comment/description and asserts an in-place
update, but some resources classify that field as recreate_on_changes
(e.g. model_serving_endpoints.description). A fuzz-generated config that
set such a field made the correct recreate look like an invariant bug.

edit_fuzz_config.py now reads recreate_on_changes from resources.yml,
tracks the enclosing resource type while scanning, and skips immutable
comment/description fields (picking a mutable one or reporting none).
Adds a contract self-check (edit_fuzz_config_check.py) and a selftest.
The schema fuzzer generates resource types the testserver may not model.
Hitting an unregistered route made the testserver call t.Errorf, failing
the whole fuzz run for a coverage gap rather than a CLI bug.

Add a testserver IgnoreUnhandledRequests option (exposed as a test.toml
field, plumbed through startLocalServer) that returns 501 and logs the
gap instead of failing. The invariant fuzz script already treats a
non-zero result before INPUT_CONFIG_OK as a rejection, so such configs
are now skipped cleanly. Curated tests leave the flag false so genuine
missing-handler bugs stay loud. Covered by a testserver unit test.
Fuzz variants had two time failure modes that both read as bugs: a single
seed could hang indefinitely, and a slow-but-progressing variant (mutate +
drift at ~30s/seed) could exceed the per-test timeout across a large seed
count. Both surfaced only as an opaque "test timed out".

Add a per-seed cap (FUZZ_SEED_TIMEOUT, default 180s) enforced via GNU
timeout: a seed past the cap is SIGQUIT'd (Go dumps goroutines, so a real
deadlock is diagnosable) then SIGKILL'd, and reported as a hang with a
reproduce hint, distinct from a drift bug. Where timeout is unavailable
(macOS/Windows) seeds run uncapped as before, and FUZZ_SEED_TIMEOUT=0
disables the cap for live inspection.

Add an optional overall budget (FUZZ_TIME_BUDGET): the loop stops launching
new seeds past it and exits cleanly, so a slow variant tests as many seeds
as fit instead of being force-killed. The nightly task sets it under a
raised per-variant Timeout; the committed run keeps its defaults and empty
output.
Triaging a fuzz run meant hand-parsing every seed's LOG.* across preserved
temp dirs to sort deployed / rejected / testserver-gap / hang / invariant
bug. Reuse the classification the loop already computes and append one
machine-readable line per seed to LOG.summary, plus a per-variant totals
block on a clean run. A 501 "No stub found" is tracked as a coverage gap
distinct from a genuine config rejection. LOG.summary is a file, not stdout,
so the committed run's empty-output assertion is unaffected.
Trim the multi-line explanations added across the fuzz harness changes to
short, why-focused notes; no behavior change.
…n config

The parent test.toml sets GOOSOnPR.windows/darwin=false and adds
job_run.yml.tmpl, but the per-directory out.test.toml snapshots were stale,
failing the CI "detected changed or new files" check.
Four improvements to the bundle schema fuzzer:

- Oracle: in fuzz mode (SKIP_DRIFT_CHECK) no_drift/redeploy now assert plan
  determinism (two consecutive `plan` reads must be byte-identical) instead of
  only no-panic. This is independent of fake-server fidelity, unlike no-drift or
  no-destructive-drift (an unchanged config's recreate is just a local-vs-remote
  representation mismatch on an immutable field). redeploy also panic-scans its
  second deploy while tolerating fidelity-driven redeploy failures.

- Coverage: add `task test-fuzz-cover` to run the corpus under -cover and report
  per-package coverage (0.0% = never exercised), plus opt-in FUZZ_CORPUS_DIR to
  persist deployable configs as a generator-independent regression corpus.

- Rejection waste: emit non-ASCII scalars as literal UTF-8 (ensure_ascii=False)
  so hostile values reach bundle logic instead of dying at the YAML parser as an
  invalid surrogate-pair escape (was the single largest rejection bucket).

- Truncation: FUZZ_TIME_BUDGET now defaults on (900s, under the 20m Timeout) so a
  slow variant stops cleanly and passes rather than being force-killed into a
  false failure; FUZZ_TIME_BUDGET=0 restores an uncapped run.
Add a schema-aware additive op to mutate_fuzz_config: alongside deleting and
perturbing existing fields, it now injects a valid optional field the curated
base omits, valued by the schema generator. Destructive mutations stay within
the base's field set (only reject/panic bugs); adding a valid optional field to
a still-deploying config is what reaches reconcile/drift bugs. The no-schema
path is unchanged (RNG stream and selftest golden preserved).
… fix, gitignore

- Remove the orphaned edit_fuzz_config.py chain (edit script, its check, and
  selftest); the update invariant it served was removed earlier in this branch.
  Decouple gen_fuzz_config_check.py from it (the one-line scalar contract is now
  attributed to mutate_fuzz_config's loader, which actually relies on it).
- Revert the libs/testserver/catalogs.go create-payload round-trip; it is a
  standalone fake-server fidelity change unrelated to the fuzz harness.
- Drop the .gitignore /build/ and .fuzztmp/ entries (stale/misleading paths).
- Regenerate fuzz/out.test.toml (drops stale GOOSOnPR lines).
no_drift's post-deploy plan already dry-runs what a redeploy would apply, so
it catches the same field-level non-idempotency without the cost of a second
deploy; the only surface redeploy adds (plan/apply divergence) never surfaced
a bug across the nightly runs. Multi-resource generation only added
cross-resource reference injection, which likewise found no distinct defect
(every bug reproduces at a single resource) while lowering acceptance.

Remove both, shrinking the fuzz matrix from 12 leaf variants to 4
({no_drift, migrate} x {generate, mutate}). Regenerate out.test.toml and
update the README accordingly.
# Conflicts:
#	acceptance/bundle/invariant/no_drift/script
…inism diff

The deploy_rc capture in no_drift/script and migrate/script was dead under
`bash -euo pipefail`: a failing deploy aborted at the `trace` line before the
capture, so the panic check never ran on a failed deploy. Wrap the deploy in
`set +e`/`set -e` so the code is captured, the panic check runs even on failure
(a panicking-but-rejected config is a bug, not a rejection), and only then do we
exit with the deploy's code.

Gate the pre-deploy `bundle validate` on FUZZ_SEED: deploy runs the same validate
pipeline, so it is redundant for curated configs and only added value as an
isolated panic surface for fuzzed configs. This also restores the curated tests
to their prior behavior.

Guard the fuzz-mode plan-determinism oracle to diff only when both plans succeed:
a plan that fails on an unstubbed read is a coverage gap, and its partial output
can differ run-to-run, which would false-positive.

Reword the load_plan comment to drop the inaccurate fuzzer reference (only the
curated drift check reaches it).
Rename fuzz_gen_config.py to emit_fuzz_config.py so it no longer collides with the
schema-walk generator gen_fuzz_config.py. Extract the shared config-render, cleanup,
and deploy-capture prologue out of the no_drift and migrate scripts into prologue.sh.
Shorten the comments added in this PR.
The rename to emit_fuzz_config sorts before envsubst, so ruff I001 flagged the
import block in mutate_fuzz_config_check.py.
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.

3 participants