Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3b758a6
bundle/fuzz: add create-payload parity fuzz test for terraform vs direct
radakam Jun 23, 2026
bc7ca0a
bundle/fuzz: fix lint (intrange, perfsprint) and correct num_workers …
radakam Jun 23, 2026
5dbe3bb
bundle/fuzz: wire parity tests into CI and harden harness
radakam Jun 23, 2026
df7d02a
bundle/fuzz: rotate nightly seeds and add single-seed reproduction
radakam Jun 24, 2026
ac28e22
bundle: force-send num_workers for single-node task clusters
radakam Jun 24, 2026
512d964
bundle/fuzz: report nightly parity failures and fix create-path comment
radakam Jun 24, 2026
ef347bd
bundle/fuzz: make harness files test-only and add num_workers regress…
radakam Jun 24, 2026
56bea31
bundle/fuzz: make the whole package test-only and harden parity repor…
radakam Jun 24, 2026
cb8c7f0
bundle/fuzz: fix lint (stringsseq, testifylint) in paritySeeds
radakam Jun 25, 2026
da99706
bundle/fuzz: fix nightly issue dedup and document paritySeeds test
radakam Jun 25, 2026
4ce40c9
bundle/fuzz: document divergences instead of fixing them
radakam Jun 25, 2026
663e9f0
bundle/fuzz: narrow num_workers ignore and tidy parity harness
radakam Jun 26, 2026
64d55ae
bundle: force-send num_workers for single-node task clusters
radakam Jun 26, 2026
8972aff
bundle/fuzz: replace terraform/direct parity with invariant testing
radakam Jun 26, 2026
cd3dd3e
acceptance: replace bundle/fuzz parity with schema-driven invariant f…
radakam Jun 29, 2026
6641605
acceptance/fuzz: clarify comments and tidy schema fuzz harness
radakam Jun 29, 2026
6128060
acceptance/fuzz: shorten and tighten comments
radakam Jun 29, 2026
82ca8af
acceptance/fuzz: report nightly failures on the PR instead of an issue
radakam Jun 29, 2026
331a6b6
acceptance/fuzz: reuse the no_drift invariant check instead of duplic…
radakam Jun 29, 2026
694aea1
acceptance/fuzz: skip INPUT_CONFIG_OK marker when deploy is rejected
radakam Jun 29, 2026
22ff21d
acceptance/fuzz: propagate drift failures so the fuzzer detects them
radakam Jun 30, 2026
7495afb
acceptance/fuzz: make the fuzzer run any invariant, not just no_drift
radakam Jun 30, 2026
e697df1
testserver: round-trip catalog create payload fields
radakam Jun 30, 2026
ad424bd
acceptance/fuzz: add redeploy, canonical, update, destroy_recreate in…
radakam Jun 30, 2026
dfb0532
acceptance/fuzz: extract shared invariant prologue and tighten comments
radakam Jul 2, 2026
8b243fc
testserver: move catalog round-trip comment above the added fields
radakam Jul 2, 2026
ff2fbae
Restructure invariant fuzz tests into per-target scripts
radakam Jul 6, 2026
c95fb80
invariant: regenerate out.test.toml for volume_path_job_ref
radakam Jul 7, 2026
2f3b785
fuzz: pin catalog/schema references and emit valid grants
radakam Jul 7, 2026
819be4e
fuzz: generate multiple resources with cross-references
radakam Jul 7, 2026
0dbc23a
fuzz: keep first resource stable across --resource-count
radakam Jul 7, 2026
cc474e6
fuzz: link every resource to an earlier one, not just one pair
radakam Jul 8, 2026
605ec08
invariant: regenerate fuzz out.test.toml for FUZZ_RESOURCE_COUNT=3
radakam Jul 8, 2026
1c2149d
fuzz: stage data fixtures and pin typed fields to cut rejections
radakam Jul 10, 2026
6273404
fuzz: add mutate mode that perturbs curated invariant configs
radakam Jul 10, 2026
3f8bedf
fuzz: probe dangerous and near-range-end values in generate mode
radakam Jul 13, 2026
63d9eec
acc/fuzz: skip immutable comment/description in update invariant
radakam Jul 13, 2026
ac4dcc9
acc/fuzz: treat unmodeled testserver routes as rejections, not failures
radakam Jul 13, 2026
d9f07af
acc/fuzz: bound seed execution by time to separate hangs from slow runs
radakam Jul 13, 2026
4be4295
acc/fuzz: record per-seed classification and a per-variant tally
radakam Jul 13, 2026
0a44046
acc/fuzz: tighten comments added in this PR
radakam Jul 13, 2026
36576dc
invariant: regenerate out.test.toml for inherited GOOSOnPR and job_ru…
radakam Jul 14, 2026
48169b3
merge origin/main into fuzz-create-payload for latest-main fuzz run
radakam Jul 15, 2026
51388f2
acc/fuzz: real oracle, coverage, less rejection waste, clean truncation
radakam Jul 15, 2026
f6f733a
Merge remote-tracking branch 'origin/main' into fuzz-create-payload
radakam Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,78 @@ jobs:
run: |
go tool -modfile=tools/task/go.mod task test-sandbox

test-fuzz:
needs:
- cleanups

# Wide rotating seed window with drift checking on: too slow for every PR, so
# nightly only and not part of test-result. The committed acceptance test still
# checks the no-panic invariant on a small fixed window per PR.
if: ${{ github.event_name == 'schedule' }}
name: "task test-fuzz"
runs-on:
group: databricks-protected-runner-group-large
labels: linux-ubuntu-latest-large

defaults:
run:
shell: bash

permissions:
id-token: write
contents: read
# Failure-reporting step comments on the PR that introduced the failing commit.
pull-requests: write

steps:
- name: Checkout repository and submodules
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup build environment
uses: ./.github/actions/setup-build-environment
with:
cache-key: test-fuzz

- name: Run tests
env:
FUZZ_SEED_COUNT: "25"
run: |
# start = monotonic GITHUB_RUN_NUMBER * COUNT keeps each nightly window
# non-overlapping, so CI explores new configs every run.
export FUZZ_SEED_START=$(( GITHUB_RUN_NUMBER * FUZZ_SEED_COUNT ))
go tool -modfile=tools/task/go.mod task test-fuzz

# Not in test-result, so surface failures by commenting on the PR that
# introduced the commit under test.
- name: Report failure
if: ${{ failure() }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
COMMIT: ${{ github.sha }}
run: |
body=$(cat <<EOF
The nightly schema fuzz invariant job (\`task test-fuzz\`) failed on commit \`$COMMIT\`.

Run: $RUN_URL

The failing seed is printed in the job log as \`reproduce with: ...\`.
Reproduce locally with:

\`\`\`
FUZZ_SEED_START=<seed> FUZZ_SEED_COUNT=1 task test-fuzz
\`\`\`
EOF
)

# The commit's pulls endpoint returns the merged PR that introduced it.
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$COMMIT/pulls" --jq '.[0].number // empty')
if [ -n "$pr" ]; then
gh pr comment "$pr" --body "$body"
else
echo "No PR found for commit $COMMIT; skipping failure comment" >&2
fi

# This job groups the result of all the above test jobs.
# It is a required check, so it blocks auto-merge and the merge queue.
#
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ dist/
# Per-module golangci-lint TMPDIR (configured in Taskfile.yml)
/.tmp/

# Local fuzz driver scratch (see .fuzztmp/run_fuzz.sh)
.fuzztmp/

# Go workspace file
go.work
go.work.sum
Expand Down
50 changes: 50 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,56 @@ tasks:
--packages ./acceptance/... \
-- -timeout=${LOCAL_TIMEOUT:-60m} -run "TestAccept/cmd/sandbox"

test-fuzz:
desc: Run schema fuzz invariant tests (random configs, direct engine)
# No `sources:` fingerprint: the window depends on FUZZ_* env vars Task can't
# see, so always run rather than no-op a repro or shifted nightly window.
cmds:
- |
# Wider window than the committed run, with drift checking on; a repro can
# narrow it via FUZZ_SEED_START/COUNT. Slow variants can't finish 200 seeds inside
# the per-variant Timeout, but the fuzz script's default FUZZ_TIME_BUDGET stops each
# variant cleanly (as many seeds as fit, then pass) rather than being force-killed.
export FUZZ_SEED_COUNT="${FUZZ_SEED_COUNT:-200}"
export FUZZ_CHECK_DRIFT="${FUZZ_CHECK_DRIFT:-1}"
{{.GO_TOOL}} gotestsum \
--format ${GOTESTSUM_FORMAT:-pkgname-and-test-fails} \
--no-summary=skipped \
--packages ./acceptance/... \
-- -timeout=${LOCAL_TIMEOUT:-30m} -run "TestAccept/bundle/invariant/fuzz"

test-fuzz-cover:
desc: Run the schema fuzzer under coverage and report which CLI packages it exercises
# No `sources:` fingerprint: like test-fuzz, the window depends on FUZZ_* env vars.
cmds:
- rm -fr ./acceptance/build/cover-fuzz/ ./acceptance/build/cover-fuzz-merged/
- mkdir -p ./acceptance/build/cover-fuzz-merged/
- |
# CLI_GOCOVERDIR makes the harness build a -cover CLI and set GOCOVERDIR per run,
# so every fuzzed `bundle` invocation drops counter files we can aggregate. Drift
# off (no FUZZ_CHECK_DRIFT) so the run exercises the full deploy/plan path for as
# many seeds as possible instead of stopping on the first fake-server drift.
export CLI_GOCOVERDIR=build/cover-fuzz
export FUZZ_SEED_COUNT="${FUZZ_SEED_COUNT:-100}"
export FUZZ_TIME_BUDGET="${FUZZ_TIME_BUDGET:-900}"
unset FUZZ_CHECK_DRIFT || true
{{.GO_TOOL}} gotestsum \
--format ${GOTESTSUM_FORMAT:-pkgname-and-test-fails} \
--no-summary=skipped \
--packages ./acceptance/... \
-- -timeout=${LOCAL_TIMEOUT:-40m} -run "TestAccept/bundle/invariant/fuzz" || true
- "go tool covdata merge -i $(printf '%s,' acceptance/build/cover-fuzz/* | sed 's/,$//') -o acceptance/build/cover-fuzz-merged/"
- go tool covdata textfmt -i acceptance/build/cover-fuzz-merged -o coverage-fuzz.txt
- |
echo "== total CLI coverage exercised by the fuzz corpus =="
go tool cover -func=coverage-fuzz.txt | awk '/^total:/{print $NF}'
echo
echo "== bundle/cmd packages by coverage (ascending; 0.0% = never exercised) =="
go tool covdata percent -i=acceptance/build/cover-fuzz-merged \
| awk '/coverage:/{p=$3; gsub(/%/,"",p); sub("github.com/databricks/cli/","",$1); printf "%6.1f%% %s\n", p, $1}' \
| grep -E ' (bundle|cmd/bundle)/' \
| sort -n

# --- Integration tests ---

integration:
Expand Down
48 changes: 48 additions & 0 deletions acceptance/bin/check_schema_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
Assert every `type` in the bundle schema is one gen_fuzz_config.py can generate, so a new
libs/jsonschema.Type fails loudly here instead of being silently skipped by the fuzz loop.
"""

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from gen_fuzz_config import HANDLED_TYPES


def collect_types(node, found):
if isinstance(node, dict):
t = node.get("type")
if isinstance(t, str):
found.add(t)
elif isinstance(t, list):
found.update(x for x in t if isinstance(x, str))
for v in node.values():
collect_types(v, found)
elif isinstance(node, list):
for v in node:
collect_types(v, found)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--schema", required=True)
args = parser.parse_args()

with open(args.schema) as f:
schema = json.load(f)

found = set()
collect_types(schema, found)

unhandled = sorted(found - HANDLED_TYPES)
if unhandled:
sys.exit(f"check_schema_types: gen_fuzz_config.py cannot generate schema types {unhandled}")


if __name__ == "__main__":
main()
109 changes: 109 additions & 0 deletions acceptance/bin/edit_fuzz_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Edit a `comment`/`description` scalar in a generated databricks.yml so a redeploy is an
in-place update, not a recreate. Used by the `update` invariant.

Some resources classify `description` as immutable (recreate on change) in the direct
engine spec (e.g. model_serving_endpoints); editing that replans as a recreate the update
invariant would wrongly flag, so skip it and pick a mutable field (or report none).

gen_fuzz_config.py emits one scalar per line as `key: <json>`, so a regex match suffices
(no YAML dependency for the edit itself); the immutable set is read from resources.yml.

edit_fuzz_config.py PATH edit in place; exit 1 if no editable field
edit_fuzz_config.py PATH --detect exit 0 if an editable field exists, else 1
"""

import argparse
import re
import sys
from pathlib import Path

# Allow an optional "- " so a comment/description that is the first key of a list-item
# dict still matches; the captured prefix is preserved verbatim on rewrite.
FIELD_RE = re.compile(r'^(\s*(?:- )?)(comment|description): (".*")\s*$')

# A resource type header directly under `resources:` (two-space indent, as emitted by
# gen_fuzz_config.py and the curated templates).
TYPE_RE = re.compile(r"^ ([\w-]+):\s*$")

NEW_VALUE = '"fuzz_edited_value"'

# resources.yml is the source of truth for field mutability. acceptance/bin is on PATH as
# the real dir (not a copy), so __file__ resolves two levels below the repo root.
RESOURCES_YML = Path(__file__).resolve().parents[2] / "bundle" / "direct" / "dresources" / "resources.yml"


def immutable_fields():
"""Map resource type -> set of fields that recreate on change (immutable).

Hand-rolled line parser over resources.yml's fixed two-space layout, avoiding a YAML
dependency the harness's Python lacks.
"""
result = {}
current_type = None
in_recreate = False
for raw in RESOURCES_YML.read_text().splitlines():
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip())
stripped = raw.strip()
if indent == 2 and stripped.endswith(":"):
current_type = stripped[:-1]
in_recreate = False
elif indent == 4 and stripped.endswith(":"):
in_recreate = stripped == "recreate_on_changes:"
elif in_recreate and current_type:
m = re.match(r"-\s*field:\s*(\S+)", stripped)
if m:
result.setdefault(current_type, set()).add(m.group(1))
return result


def find_line(lines, immutable):
current_type = None
in_resources = False
for i, line in enumerate(lines):
stripped = line.rstrip("\n")
# Track the enclosing resource type so an immutable comment/description is skipped.
if stripped == "resources:":
in_resources = True
current_type = None
continue
if in_resources:
m_type = TYPE_RE.match(stripped)
if m_type:
current_type = m_type.group(1)
elif stripped and not stripped[0].isspace():
in_resources = False
current_type = None
m = FIELD_RE.match(line)
if m and m.group(2) not in immutable.get(current_type, ()):
return i, m
return -1, None


def main():
parser = argparse.ArgumentParser()
parser.add_argument("path")
parser.add_argument("--detect", action="store_true", help="only check, don't edit")
args = parser.parse_args()

with open(args.path) as f:
lines = f.readlines()

i, m = find_line(lines, immutable_fields())
if m is None:
sys.exit(1)
if args.detect:
return

prefix, key, _ = m.groups()
lines[i] = f"{prefix}{key}: {NEW_VALUE}\n"
with open(args.path, "w") as f:
f.writelines(lines)
sys.stderr.write(f"edited {key} at line {i + 1}\n")


if __name__ == "__main__":
main()
84 changes: 84 additions & 0 deletions acceptance/bin/edit_fuzz_config_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Contract check for edit_fuzz_config's field selection (the harness diffs stdout; a
non-zero exit marks a violation on stderr):

- A comment/description that recreates on change for its resource type (per
resources.yml, e.g. model_serving_endpoints.description) is never chosen, so the
update invariant does not assert an in-place update the backend cannot perform.
- A mutable comment/description is still chosen, even when an immutable one appears
first.
- The immutable map actually loads and reflects resources.yml.
"""

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from edit_fuzz_config import find_line, immutable_fields

IMMUTABLE_ONLY = """\
resources:
model_serving_endpoints:
foo:
name: "test-endpoint"
description: "old"
"""

IMMUTABLE_THEN_MUTABLE = """\
resources:
model_serving_endpoints:
foo:
description: "immutable"
jobs:
bar:
description: "mutable"
"""

MUTABLE_ONLY = """\
resources:
jobs:
bar:
description: "mutable"
"""


def choose(text, immutable):
i, m = find_line(text.splitlines(keepends=True), immutable)
return None if m is None else i


def main():
immutable = immutable_fields()
failed = False

# Guards the loader and the classification the update invariant relies on.
serving_immutable = "description" in immutable.get("model_serving_endpoints", set())
if not serving_immutable:
sys.stderr.write("expected model_serving_endpoints.description to be immutable in resources.yml\n")
failed = True

if choose(IMMUTABLE_ONLY, immutable) is not None:
sys.stderr.write("picked an immutable description\n")
failed = True

if choose(IMMUTABLE_THEN_MUTABLE, immutable) != 6:
sys.stderr.write("expected to skip the immutable description and pick the mutable one\n")
failed = True

if choose(MUTABLE_ONLY, immutable) != 3:
sys.stderr.write("expected to pick the mutable description\n")
failed = True

print(f"model_serving_endpoints.description immutable: {serving_immutable}")
print(f"IMMUTABLE_ONLY: {choose(IMMUTABLE_ONLY, immutable)}")
print(f"IMMUTABLE_THEN_MUTABLE: {choose(IMMUTABLE_THEN_MUTABLE, immutable)}")
print(f"MUTABLE_ONLY: {choose(MUTABLE_ONLY, immutable)}")

if failed:
sys.exit(1)


if __name__ == "__main__":
main()
Loading
Loading