Skip to content

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

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

acceptance: schema-driven invariant fuzzing for bundle configs#5686
radakam wants to merge 70 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. Since 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. Reference and typed fields (catalog, schema, parent_path, file/notebook paths, durations) are pinned to deployable values, 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, empty a container) and, with the schema, additive (inject a valid optional field the base omits). acceptance/bin/emit_fuzz_config.py dispatches on FUZZ_MODE.
  • Invariants are the curated scripts, run as-is. acceptance/bundle/invariant/fuzz/script.prepare overrides two helpers, so fuzzing stays invisible to the targets:
    • invariant_render emits the generated config and probes bundle validate for panics before the deploy.
    • invariant_verify_no_drift asserts the fidelity-independent property: planning is deterministic, so two consecutive plans of the same state are byte-identical. FUZZ_CHECK_DRIFT selects the exact drift check instead, which the nightly job sets.
  • Runner (acceptance/bundle/invariant/fuzz/script) classifies each seed as deployed / rejected / gap / bug / hang, so genuine bugs stand apart from config rejections and testserver coverage gaps. It caps each seed (FUZZ_SEED_TIMEOUT, SIGQUIT→SIGKILL so a hang dumps goroutines) where GNU timeout is available and runs the seed uncapped where it is not; both paths set errexit explicitly, so a rejected config stops before the INPUT_CONFIG_OK marker and classification does not depend on the platform. It bounds the whole variant with clean truncation (FUZZ_TIME_BUDGET), and tallies seeds per variant in LOG.summary. A run where no seed deploys fails: that means the schema, generator or fixtures are broken, which otherwise looks identical to the CLI correctly rejecting random input.

Targets and modes. 4 targets over 2 modes, less 3 excluded combinations, gives 5 leaf variants:

FUZZ_TARGET Invariant Modes
no_drift deploy, then no drift generate, mutate
migrate Terraform deploy, migrate to direct, then no drift generate
delete_idempotent deploy, delete by emptying the config, then re-run the delete on restored state mutate
destroy_idempotent deploy, destroy, then destroy again on restored state mutate

The idempotency targets keep a real oracle under fuzzing — a delete or destroy re-run against state that still references the removed resources must succeed, however faithfully the fake server round-trips fields. Mutate mode is the one that varies what they depend on: the identifier the delete call uses, and the shape of grants and permissions.

Where it runs. Locally against the test server, where a seed's full deploy/plan/destroy round trip takes seconds, so a wide seed window fits in one job. The committed run is 5 seeds; the nightly task test-fuzz job walks a rotating window with the exact drift check on.

Supporting changes

  • Taskfile.yml: task test-fuzz (wide seed window, drift check on) and task test-fuzz-cover (runs the fuzzer under -cover with drift off and reports per-package CLI coverage, so a package at 0.0% is one the fuzzer never reaches).
  • .github/workflows/push.yml: nightly cron 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: IgnoreUnhandledRequests, a test.toml field plumbed through startLocalServer, returns 501 for an unmodeled route so the runner records the seed as a coverage gap. Scoped to the fuzz test, so missing-handler bugs stay loud everywhere else.
  • libs/testserver fidelity, both needed to stop the fake server reporting its own gaps as CLI bugs: CatalogsCreate echoes back connection_name, custom_max_retention_hours and managed_encryption_settings instead of dropping them into a phantom change on the next plan, and catalogs and registered models reject an empty name with a 400 as the backend does — accepting one stored a resource the CLI could not find again, surfacing as internal error: missing entry in state after deploy.
  • Guards and selftests: gen_fuzz_config_check.py and mutate_fuzz_config_check.py pin the generators' contracts (the one-line scalar shape the mutate loader relies on, loader round-trip over every curated base, mutation determinism, no output-only field injection) via acceptance selftests, and verify_no_drift.py reports empty plan output as such instead of raising.

Reproducing a failure

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

FUZZ_SEED_TIMEOUT=0 selects the uncapped seed path, which is also what a platform without GNU timeout (macOS) takes.

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 (open)
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 (open)
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 (merged)
pipeline allow_duplicate_names → perpetual update (pipelines.*.allow_duplicate_names: true) Request-only: never returned by GET, so remote reads back false → perpetual update that survives redeploy; declared input_only no_drift Yes #6076 (open)

@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: af4172f

Run: 30446554022

Env 🔄​flaky 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 4 4 348 1067 13:49
💚​ aws windows 4 4 350 1065 15:29
💚​ azure linux 4 4 348 1066 14:21
💚​ azure windows 4 4 350 1064 18:24
🔄​ gcp linux 1 1 5 346 1068 15:24
💚​ gcp windows 1 5 349 1066 16:16
9 interesting tests: 4 RECOVERED, 4 SKIP, 1 flaky
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
🔄​ TestSyncFullFileSync ✅​p ✅​p ✅​p ✅​p 🔄​f ✅​p
💚​ TestFetchRepositoryInfoAPI_FromRepo 💚​R 💚​R 💚​R 💚​R 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo/root 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/subdir 💚​R 💚​R 💚​R 💚​R
Top 38 slowest tests (at least 2 minutes):
duration env testname
3:51 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_pydabs_10_tasks.yml.tmpl
3:46 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=app.yml.tmpl
3:33 azure windows TestFilerWorkspaceFilesExtensionsReadDir
3:32 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=pipeline_apply_policy_default_values.yml.tmpl
3:31 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_with_depends_on.yml.tmpl
3:22 gcp windows TestAccept
3:21 azure windows TestAccept
3:19 azure linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_with_task.yml.tmpl
3:17 aws windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=model_with_permissions.yml.tmpl
3:16 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=pipeline_config_dots.yml.tmpl
3:16 aws windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=secret_scope.yml.tmpl
3:14 aws windows TestAccept
3:12 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=cluster_apply_policy_default_values.yml.tmpl
3:06 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_apply_policy_default_values_for_each_task.yml.tmpl
3:04 gcp linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_apply_policy_default_values_for_each_task.yml.tmpl
2:57 azure linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=volume.yml.tmpl
2:54 gcp linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=pipeline_apply_policy_default_values.yml.tmpl
2:51 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=pipeline.yml.tmpl
2:46 aws windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_apply_policy_default_values_for_each_task.yml.tmpl
2:33 aws windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=volume.yml.tmpl
2:32 azure linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=secret_scope_default_backend_type.yml.tmpl
2:29 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=volume_path_job_ref.yml.tmpl
2:28 azure windows TestImportDir
2:27 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=schema_uppercase_name.yml.tmpl
2:25 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=model_serving_endpoint.yml.tmpl
2:22 aws windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=app.yml.tmpl
2:22 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=schema.yml.tmpl
2:18 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_apply_policy_default_values_task_cluster.yml.tmpl
2:18 aws linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=app.yml.tmpl
2:16 gcp windows TestFilerWorkspaceNotebook/scalaNb.scala
2:16 azure windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=experiment.yml.tmpl
2:10 aws windows TestFilerWorkspaceFilesExtensionsDelete
2:07 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=secret_scope.yml.tmpl
2:06 aws linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=cluster.yml.tmpl
2:05 gcp windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=app.yml.tmpl
2:03 aws windows TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=job_pydabs_10_tasks.yml.tmpl
2:01 gcp linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=schema_uppercase_name.yml.tmpl
2:01 gcp linux TestAccept/bundle/invariant/migrate/DATABRICKS_BUNDLE_ENGINE=direct/INPUT_CONFIG=pipeline.yml.tmpl

@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, @shreyas-goenka, @andrewnester, @anton-107, @lennartkats-db

General files (require maintainer)

21 files changed
Based on git history:

  • @denik -- recent work in acceptance/bin/, ./, libs/testserver/

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 16 commits July 23, 2026 09:29
…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.
The invariant suite runs on cloud, and there each seed's deploy/migrate/plan/
destroy round trip against a real workspace takes minutes, tripping the script's
180s per-seed SEED_TIMEOUT and reporting a false hang. The cloud run also adds no
signal: drift checking is off unless FUZZ_CHECK_DRIFT is set, which it does not
set, so it only re-asserts the no-panic property the local run already covers over
the same seeds in seconds.
mutate mode rendered the curated base template with the harness-wide
UNIQUE_NAME, so every seed in a run deployed the same bundle root and the
same securable names into one shared fake workspace. Anything a seed left
behind then read back as remote state for a later seed: the testserver
never drops grants on delete, so seeds 34 and 51 of no_drift/mutate failed
on a grant an earlier seed had created and destroyed.

generate mode already scopes names per seed; do the same here. no_drift/
mutate now runs to seed 71, the real allow_duplicate_names bug.
radakam added 13 commits July 28, 2026 07:04
…gets

no_drift, migrate, continue_293, delete_idempotent and destroy_idempotent each
carried their own copy of the config render (data copy, init script, envsubst),
the destroy-on-exit trap, and the deploy + panic-scan + INPUT_CONFIG_OK
sequence. Move all of it into acceptance/bundle/invariant/script.prepare, which
the harness picks up for the whole subtree, so each script holds only the
invariant it asserts.

The helpers are functions rather than prepare-time side effects because the
harness concatenates script.prepare outside the subshell it wraps the script in,
so a trap installed at prepare time would belong to the outer shell.

invariant_deploy takes the deploy log name as its first argument, since the
targets log to LOG.deploy, LOG.deploy_initial and LOG.deploy.293.

Incidental deltas, all confined to LOG* files (logged, not compared):
continue_293 gains LOG.config and a traced cleanup destroy, and
destroy_idempotent's trap logs to LOG.destroy rather than LOG.destroy_final.
Drop the comments that restated the line below them and condense the helper
docs, keeping only the non-obvious parts: why invariant_setup must be called
from the target rather than at prepare time, and what INPUT_CONFIG_OK signals
to the fuzzer.
The fuzzer could pass while every seed was rejected, so a broken generator,
schema or fixture looked identical to the CLI correctly rejecting random
input. Assert that at least one seed deploys, and treat a generator crash as
a bug rather than a rejected config.

Also fixes classification and reporting the review turned up:

- an unstubbed testserver route hit during plan or destroy was reported as a
  CLI drift bug; check for it before the INPUT_CONFIG_OK marker
- repro hints were mangled by the harness env-var replacements; write them to
  LOG.repro, which is logged verbatim
- cleanup skipped destroy unless deploy fully succeeded, leaking resources
  from a part-way deploy on a real workspace
- to_yaml flattened nested lists ([[1,2]] -> [1,2]) and the loader could not
  read the empty containers it emits
- gen_grants/gen_permissions emitted [] for types with no valid privilege,
  manufacturing the known empty-grants drift bugs instead of new ones
- CatalogsCreate dropped connection_name, custom_max_retention_hours and
  managed_encryption_settings, so the next plan saw a phantom change
- mutate + migrate only rediscovers what migrate/test.toml already excludes

Drops the schema-type guard (gen_scalar already exits on an unknown type),
the write-only FUZZ_CORPUS_DIR, the never-set FUZZ_RESOURCES, both unused
argparse entry points and single-caller util.load_plan, whose inlining also
restores the raw-plan dump on malformed JSON.
The name is the identity of both resources, so a fake that accepts an empty
one stores a resource the CLI cannot find again: deploy reports success and
then fails with "internal error: missing entry in state after deploy", and
destroy fails with "internal error, missing in state". Verified against a
real workspace that both APIs reject it with 400 INVALID_PARAMETER_VALUE, so
the fuzz seeds that hit this were reporting a fake-server gap as a CLI bug.

Also two harness fixes found while replaying seeds:

- pass -count=1 to the fuzz tasks. Only the script reads FUZZ_*, so Go's test
  cache happily served one seed window's result as another's.
- skip the no-seed-deployed assertion for a single-seed replay, where one
  rejected config is the expected outcome rather than systemic breakage.
Guard INPUT_CONFIG in invariant_cleanup: scripts run under set -u, and a caller
that builds its own config leaves the variable unset.

Split invariant_render out of invariant_setup so such a caller can override the
render alone from its own script.prepare, which the harness concatenates after
this file.

Lift set -e around the deploy in invariant_deploy so the panic scan still runs
when the deploy fails: a config the CLI rejects must not panic on the way out.
A clean failure exits with the deploy's own code and prints no INPUT_CONFIG_OK.
no_drift, migrate and continue_293 each ran the same plan + panic-scan +
verify_no_drift.py sequence. Move it into invariant_verify_no_drift so a caller
whose config the server does not round-trip exactly can override the check
alone. migrate now logs the plan to LOG.planjson like the other two.
#6075 replaces the sourced prologue.sh with acceptance/bundle/invariant/script.prepare,
which the harness concatenates for the whole subtree, and turns the shared setup into
helpers a child script.prepare can override.

- Drop prologue.sh along with its FUZZ_SEED branch; no target script sources anything now.
- Add fuzz/script.prepare, overriding invariant_render to emit the generated config and run
  the validate panic probe, and -- unless FUZZ_CHECK_DRIFT is set -- invariant_verify_no_drift
  with the plan-determinism oracle.
- Drop SKIP_DRIFT_CHECK: no_drift and migrate no longer branch on fuzz mode. migrate now runs
  the determinism oracle in fuzz mode rather than skipping the drift check entirely.
- Guard INPUT_CONFIG in migrate/script, which the fuzzer leaves unset.
The fuzzer clears the INPUT_CONFIG matrix, and scripts run under set -u, so the
depends_on special case must not dereference it unguarded.
delete_idempotent and destroy_idempotent assert that a delete or destroy re-run
against state that still references the removed resources succeeds. That holds
regardless of how faithfully the fake server round-trips fields, so unlike
no_drift and migrate these targets keep their real oracle under fuzzing, and
they exercise the already-gone paths across generated resource types.

Both derive workspace paths from test-bundle-$UNIQUE_NAME, which the fuzzer has
to render the config with. Scope the per-seed unique name in the seed loop rather
than inside emit_fuzz_config.py, where the rewrite never reached the shell, and
name generated bundles after the same pattern as the curated configs.

200 seeds per target per mode run clean locally: 143/200 deploy in generate mode
(14 testserver gaps) and 169/200 in mutate mode, with no bugs or hangs.
Generate mode adds no axis the curated delete_idempotent and destroy_idempotent
tests lack. The already-gone delete path is selected by resource type, and those
tests already run every type in the INPUT_CONFIG matrix; generate pins name and
display_name, so it cannot vary the identifier the delete call uses, and its
dangerous values only reach free-form strings no delete path reads.

Mutate can land a dangerous value on the identifier and can reshape grants and
permissions, so it keeps both targets. Leaves 5 leaf variants and drops the
testserver coverage gaps generate produced for these two.
# Conflicts:
#	acceptance/bundle/invariant/script.prepare

#6075 landed as a pure extraction, so main's script.prepare no longer carries the two
deltas this branch had added to it. Keep main's version and re-add only the INPUT_CONFIG
guard in invariant_cleanup, which this branch does need: the fuzzer clears the
INPUT_CONFIG matrix and scripts run under set -u.

The set -e lift around the deploy is not re-added. It is redundant here -- when a seed
fails, fuzz/script already scans every LOG.* for panics before classifying it, so a config
that panics on the way out is still reported as a bug rather than a rejection.
The seed loop wraps run_seed in `set +e` to capture the seed's exit code. The
capped branch spawns a fresh `bash -euo pipefail`, but the uncapped fallback is
a plain subshell that inherits `set +e`, so the seed ran without errexit: a
config the CLI rejects continued past the failing validate and deploy to
`echo INPUT_CONFIG_OK`, and that marker is what tells "deployed, then broke the
invariant" from "config rejected". Every seed the CLI legitimately rejected was
reported as a bug.

macOS has no GNU timeout, so it always takes the fallback and was the only
platform failing; FUZZ_SEED_TIMEOUT=0 selects the same branch and reproduces it
anywhere. Seed 3 of generate mode (pipelines.*.root_path pointing at a path
that does not exist) failed as a bug with the flag and passes without it.
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