Skip to content

Auto test every field mutation - #6452

Draft
denik wants to merge 131 commits into
mainfrom
denik/extensive-test
Draft

Auto test every field mutation#6452
denik wants to merge 131 commits into
mainfrom
denik/extensive-test

Conversation

@denik

@denik denik commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Adds bundle/direct/autotest: a per-field catalog of how the direct engine handles every field a user
can set. For each field it walks every ordered pair of a small value set — absent included, so adding
and removing a field are just the pairs with absent on one side — and records three things per move:
did the plan propose a change, did the apply succeed, did the next plan converge.

The outcome is a verdict word in a committed golden. A bad outcome is recorded, not failed, so the
report is a map of the engine's behaviour rather than a wall of failures; what fails the test is a
verdict changing. All 34 resource types the engine supports, ~3900 observations.

Both a local run against libs/testserver and a real-workspace run are held to the same golden, so a
divergence means the fake server does not match the API. That is where most of this PR's findings come
from — 14 fake-server fidelity fixes, each carrying the API's own error text:

  • a genie space created with no title comes back named, not empty
  • a SQL warehouse create requires a name, a cluster_size, and max_num_clusters in 1..40, and the
    backend defaults enable_photon and spot_instance_policy
  • a pipeline with a storage location cannot have a catalog added — verified directly: a pipeline
    created with neither gets storage, and the later update is then refused
  • a serving endpoint's create does not apply email_notifications; an update does
  • a cluster's autotermination_minutes has a 10-minute minimum
  • target_qps on a vector search endpoint is omitempty, so the API drops a clear
  • a registered model needs all three name parts; an Azure KeyVault scope needs its metadata

Three of those exposed acceptance fixtures asserting behaviour the API does not have. All were
Cloud = false, so nothing had ever checked them: bind/pipelines/update bound a storage-defaulted
pipeline to a config asking for a catalog; create-scope sent the bundle schema's field name to a raw
API call, so the CLI dropped the metadata and the whole script aborted once the fake required it; and
the serving-endpoint fix sharpened a test whose own comment describes a TF bug the fake had been hiding
half of.

Engine findings the goldens record: 27 postgres_projects rows name a field in update_mask and then
omit it from the body (#6441 and #6443 fixed 13 of these while this branch was open);
pipelines.catalog is absent from recreate_on_changes, so the engine sends an update the API refuses.

Running it

./task test covers the local run. Two new tasks for the real-workspace runs:

  • ./task autotest-cloud — every field; nightly
  • ./task autotest-cloud-pr-sample 2, for PRs: two fields per type, picked from HEAD so
    successive commits cover different ground while one run's picks follow from its SHA

output/ holds the 34 committed goldens and nothing else; logs/ holds the full report per type and
is gitignored wholesale.

State of the cloud verification

In progress. 14 of the 34 types declare local_only with a reason — an external location needs
IAM-backed storage, an instance pool cannot be deleted again, a postgres project holds its name after
deletion, a job run would start a real job per transition. Of the 20 that can be driven, verification
against the current goldens is under way; each one that diverged so far has been a fixture value the
fake accepts and the API refuses, or a fake-server gap, and is fixed in this branch.

Test-only; no changelog fragment.

This pull request and its description were written by Isaac.

denik added 30 commits August 30, 2026 11:32
Recreating a resource re-keys the parent but not its permissions/grants child:
the child's state entry keeps the old id, DoRead echoes it back, and the planner
re-proposes the same update on every deploy. The ACL itself is applied to the
recreated object, so this is stale bookkeeping rather than lost access, and
terraform converges -- hence the per-engine plan files.

Co-authored-by: Isaac
model_with_permissions.yml.tmpl was model.yml.tmpl plus a permissions block, so
the two tested the same resource twice. Invariant configs carry permissions or
grants by default; a permissions-free variant is not a separate case.

continue_293 now excludes the model config outright, since model permissions did
not work until 0.297.0.

Co-authored-by: Isaac
Moves every field of every single-resource invariant config through each ordered
pair of a small value set -- absent included, so add and remove are just the
pairs with absent on one side -- and records whether the plan proposed anything,
the apply succeeded, and the next plan was clean.

Runs in-process against CalculatePlan/Apply rather than shelling out to bundle
deploy, so 5502 observations across 22 resource types take 35s instead of being
dominated by bundle file sync. Permissions and grants are stripped first; a bad
outcome does not fail the test, it lands in out.<resource_type>.fields.txt, and
the committed goldens are what turn an SDK bump into a visible diff.

Co-authored-by: Isaac
Every field of catalog.UpdateSchema is omitempty and DoUpdate forwards only the
config's own ForceSendFields, so removing the comment sends an empty PATCH body
and UC keeps the old value. The deploy reports success and every later plan
re-proposes the same update.

Co-authored-by: Isaac
The earlier approach: "# ACTION:" annotations in the invariant configs drive
apply_update and apply_remote_update, which plan, apply and re-plan one field at
a time through the full CLI.

apply_update is now covered far more thoroughly by bundle/direct/dresources_test.
apply_remote_update is not -- it drifts the remote by restoring a state snapshot,
which the Go catalog does not do yet -- so this is kept for that half. Drop the
commit if the remote case lands there instead.

Co-authored-by: Isaac
A harness rebuilt inside a field-level subtest goes on being used by later
fields, but it captured that subtest's t.Context(), which is cancelled the moment
the subtest returns -- 56 observations came back as "context canceled" from the
SDK rate limiter rather than a real verdict. Thread the resource-type context in
instead.

With those unmasked, model serving telemetry turned out to be rejected 36 times:
the block only validates against a catalog and schema that exist and are
writable. Skip the subtree, which needed skip keys to support a "<prefix>.*"
form.

Co-authored-by: Isaac
…text

cloud: true was the wrong default -- a resource type should be covered
everywhere, and the expensive ones still have to be covered. Replaced with
slow: true, which only drops a type under -short: the PR cloud leg skips them,
the nightly does not. Files that said nothing but cloud: true are gone.

t.Context() is cancelled just before Cleanup functions run, so every harness
destroy was issuing its API calls on a dead context and silently leaking the
resource; a harness rebuilt inside a field subtest hit the same thing once that
subtest returned. context.WithoutCancel keeps the values and drops the deadline.

Reports move to output/, so the package directory reads as seven entries instead
of thirty.

Co-authored-by: Isaac
It drives CalculatePlan and Apply, so it tests the engine rather than any one
resource implementation, and it belongs beside the engine instead of under
dresources. The old dresources_test directory name also misapplied Go's _test
package-name convention to a directory.

Value libraries move to testdata/fields, which the go tool ignores by name.
Goldens stay in output/. Every file in the package is a _test.go, so GoFiles is
empty and none of this reaches the binary.

Co-authored-by: Isaac
The committed output/<type>.txt now lists just the results worth looking at, one
line each, with from and to as their own columns. output/<type>.full.txt keeps
everything plus the summary and the not-covered list, and is gitignored since it
moves whenever a passing row does. 304 findings out of 5442 observations.

A SUPPRESSED whose reason is "empty" is not one of them: both sides were empty,
so nothing the user expressed was lost.

DRIFT becomes POST_DEPLOY_DRIFT, which is what it measures. Transition subtests
are named absent_to_true rather than with an arrow, so a single case re-runs
without shell quoting, and now -v prints the post-deploy plan behind any finding.

Dropped the slow flag: every resource type runs everywhere. Renaming an app is a
real operation, so it is no longer skipped for being slow. It records
DEPLOY_ERROR "timed out: app is deleting", which is the finding. A per-operation
deadline backs that up with a TIMEOUT verdict.

Main fixed clearing a schema comment, so that test becomes clear_field: it now
asserts comment converges and records that custom_max_retention_hours does not.

Co-authored-by: Isaac
They were committed by accident: output/<type>.txt is the file worth diffing.

Co-authored-by: Isaac
They were committed by accident: output/<type>.txt is the file worth diffing.

Co-authored-by: Isaac
Slices and maps are covered two ways. The container becomes a field whose values
are the config's own and that value minus its last entry, so with absent in the
set one field covers adding and removing an entry as well as the whole container
-- all with data the backend already accepted. Separately, a pattern like
tasks[*].description is expanded against the deployed config to the indices that
exist, so fields inside an element are tested like any other. A pattern with
nothing behind it is reported as not covered rather than silently passing.

base: in a value library seeds a block the invariant config does not declare, so
its fields become reachable. A job's git_source needs a provider, a url and
exactly one ref, and cannot be built up one field at a time from nothing; seeded,
its skip list drops from nine entries to the two that are genuinely a different
side of the same oneof.

Value pairs are now walked as a single chain instead of staged one at a time. The
values form a complete digraph, so one Eulerian circuit covers every ordered pair
exactly once and each move starts where the last ended -- one deploy per
transition instead of two, which took jobs from 381s to 233s. Walk order is
shuffled from a seed derived from the field path: fixed per field so goldens stay
byte-stable, different between fields so the suite is not always taking the same
path. TestTransitionsCoverEveryPairInOneChain is what guarantees nothing is left
for a second chain to pick up.

Configs of a resource type now run in parallel, so the report is guarded.
out.full.txt indents the evidence under each finding: the post-deploy plan for
drift, the whole API error for a rejection.

Co-authored-by: Isaac
One config per resource type -- the simplest one -- and whatever structure that
config lacks is seeded through testdata/fields instead. A slice or map is a field
in its own right, whose values are the config's own and that value minus its last
entry, so with absent in the set one field covers adding and removing an entry as
well as the whole container. Patterns like tasks[*].description expand against
the deployed config, so fields inside an element are tested like any other, and
what no config declares is named rather than passed over.

Findings are classified until each verdict names one mechanism: UPDATE_IGNORED
(write accepted, remote unmoved on two reads) separated from STALE_READ (unmoved
on the first read only), COLLATERAL_DRIFT (updating this field left another one
drifting -- 119 rows were one field wearing other fields' names), BASELINE_DRIFT
(drifts with nothing changed), and OK_INERT / INERT_NOT_HONOURED, which check the
ignore_local_changes declarations rather than trusting them.

Order comes from git HEAD so a finding cannot be retried away, and the pairs are
walked as one Eulerian circuit, halving the deploys.

Three bugs in the harness, each of which quietly under-reported coverage: map
wildcards never expanded, since splitPattern emitted the wildcard twice; the
not-covered list was a union across configs, so it named fields other configs
were testing; and neither a map key nor a slice element could be removed, since
dyn's path parser reads brackets as indices and deletePath only handled keys.

libs/testserver grows SettleAsyncImmediately, off by default so acceptance keeps
exercising the waiters. The SDK's poller has a hardcoded one-second backoff with
no exported way to shorten it, so one in-progress read per update cost serving
endpoints more wall time than every other resource type combined: 512s to 5s.

7052 observations over 22 resource types in 76s.

Co-authored-by: Isaac
The generic "x"/"y" defaults are rejected outright on a real workspace for a
field that names another object, and the retention period is validated in days,
so those fields reported nothing but BACKEND_ERROR on cloud.

Co-authored-by: Isaac
FindStructFieldByKeyType already recursed into embedded structs, but Get and Set
stopped after one level, so a field of resources.PostgresProject -- which embeds
a config struct that embeds the SDK spec -- was reported as not found.

Both now walk embedding recursively and track the struct that declares the field,
which is also the one whose ForceSendFields governs it: an outer struct that
shadows the name (PostgresProjectConfig) tracks only its own fields.

Co-authored-by: Isaac
The suite now writes values through libs/structs (structaccess over the resource's
own Go type, structpath for paths) and syncs the result into the dynamic tree the
way a mutator does, instead of manipulating dyn.Value directly. Nothing in the
package depends on libs/dyn any more.

That also removes every hand-written substitute for something dyn lacked: a path
parser that could hold a map key, a delete-by-path, a parent-creating walk. And it
makes "absent" the distinction the API actually sees -- the zero value with the
field out of ForceSendFields -- so a list element or map entry now becomes settable
where it previously reported UNSETTABLE: 91 of those are gone and the suite records
252 more observations, in a third of the wall time.

The order seed is a constant now rather than HEAD. Some verdicts depend on the
order -- a field the API cannot clear leaves the remote holding an old value -- and
committing a report changes HEAD, so a HEAD-derived seed could never validate at
the commit that carried the report.

Co-authored-by: Isaac
MLflow refuses to clear a description, and the update sends the field
unconditionally, so a config that drops it fails on a real workspace while the
fake server accepted it.

readplan-permissions changed the description instead of removing it, since
removing it can no longer converge.

Co-authored-by: Isaac
From the AWS comparison: a skip key naming a field inside a slice
("aliases[*].id") never matched the concrete "aliases[0].id" it expands to, so the
field was tested anyway and the backend rejected every value.

Keys are now matched the way the planner matches its own field rules, which also
makes naming a block skip everything beneath it -- so the ".*" suffix is gone.

Co-authored-by: Isaac
From the AWS comparison: an alias belongs to a model version and is managed through
its own API, so UC does not echo aliases on a plain GET -- which is why the direct
engine passes include_aliases=false and declares the field input_only. The fake
server returned them anyway, so the remote appeared to hold what the config asked
for and the field's real behaviour was hidden.

registered_models now matches AWS row for row.

Co-authored-by: Isaac
From the AWS comparison: an experiment name is an absolute workspace path and an
artifact location needs a scheme, so the generic "x" was rejected outright. The UC
trace location needs its catalog and schema together, like a job's git_source, so
it is seeded rather than built up one field at a time.

experiments matches AWS row for row.

Co-authored-by: Isaac
…on values

From the AWS comparison. A retention period is validated in days (0, or 7 to 30), so
the generic 1 and 2 were rejected. The rest need workspace state this suite does not
provision: a Delta Sharing provider and share, a connection, an external location, a
customer-managed key.

options is skipped for a different reason worth noting: UpdateCatalog rejects the
field outright ("UpdateCatalog options can not be provided"), and the engine sends it
on every update whenever the config declares it -- so seeding it made every other
field of the catalog fail its update too.

catalogs matches AWS row for row.

Co-authored-by: Isaac
From the AWS comparison: external_locations cannot deploy against a real workspace at
all -- it needs a storage credential with cloud IAM behind it -- so every field
reported one BASE_ERROR and the golden diverged for a reason that says nothing about
the engine. The invariant suite already excludes the same config from its cloud run.

A type now declares "local_only: <reason>" in its value library and is skipped on
cloud; its local golden stands, since a cloud run neither confirms nor contradicts it.

Co-authored-by: Isaac
Two fixes from the AWS comparison, both about the suite rather than the engine.

An alert or dashboard is created inside ${workspace.resource_path} and the backend
404s on a missing parent, which is why a real deploy runs deploy.ResourcePathMkdir
first. This suite plans and applies directly, so it now runs that step too --
otherwise the whole type reported one BASE_ERROR that said nothing about any field.

A seeded value naming tester@databricks.com only exists on the fake server, so the
base fragment is now expanded with the same $VARS the corpus configs use.

Also: a base error's evidence went through firstError, which truncates to 140
characters -- so the full report, whose whole purpose is to carry the backend's own
words, showed a cut-off message.

Co-authored-by: Isaac
…brary

From the AWS comparison. An enum field was getting the generic "x" and "y", which a
real backend rejects or silently ignores, so the field reported nothing about whether
the engine handles a change to it -- the alerts report was full of those. Every
generated SDK enum declares its own values through a Values() method, so they are read
from there instead, which fixes enum fields across every resource type.

The alerts library also skips what the suite cannot provision: a second warehouse or
workspace folder (and the backend refuses to move an alert between folders at all), and
a registered notification destination. A subscription and a run_as now name the
workspace's own user.

Co-authored-by: Isaac
…texts

Both from the adversarial review.

containerValues recorded a slice or map with its elements shallow-copied, so the
pointers inside an element were shared with the live resource: editing a field under
clusters.init_scripts[0].workspace then reached into the value saved for the
init_scripts container's own transitions, and that container was tested against
corrupted data.

A plan the caller never applies was dropping its pendingApply on the floor, leaking
the operation context and its ten-minute timer -- thousands of them on a cloud run.
Such a caller now goes through readPlan, and the two paths that plan and then return
early cancel explicitly.

Co-authored-by: Isaac
…_REACHED

From the adversarial review: a transition assumed its setup deploy established the
starting value, checking only that the API did not error. When the API accepts a write
and ignores it -- clearing a field it refuses to clear -- the field kept its old value
and the move was then recorded under a label describing something that never happened:
"absent to 168" while the remote still held 720.

The starting value is now confirmed before the move under test. A transition that
cannot be set up is retried once on a fresh resource, since a resource created without
the field genuinely starts absent, and only then recorded as START_NOT_REACHED -- its
own verdict, because unlike BASE_ERROR nothing was rejected.

Also from the review: isRequired compared a concrete path against the generated
required-field data, which is keyed by pattern, so a required field inside a slice
(tasks[*].task_key) looked optional and got an absent transition no user can deploy.

Co-authored-by: Isaac
Both from the adversarial review.

The committed report listed only findings, so a change in passing behaviour was
invisible: a field that starts being recreated instead of updated moved one OK to
OK_RECREATE and nothing in the compared file mentioned either. That matters most for
the cloud run, which is held to the same golden -- a real workspace behaving unlike the
fake server in a passing row could not fail the test. The counts of every verdict are
now part of the committed file.

A missing golden was also treated as an empty one, so adding a resource type without
generating its report, or deleting a report, silently passed.

Co-authored-by: Isaac
… lists

From the adversarial review: a field that ended up with no transitions disappeared
without a trace -- no verdict, no coverage gap. Two shapes did this. A type with no
generic value (an `any` field like serialized_dashboard) got none, and a required field
the library gives a single value has no second value to move to and no absent to move
from, so volumes catalog_name, schema_name and volume_type ran nothing while counting
as covered. Both are reported as not covered now.

While there: a list of scalars the config leaves empty had nothing to trim, so the
whole field and everything under it was a gap. Two elements of its own type need no
invention and cover adding, growing, shrinking and removing -- 75 gaps closed and 444
more observations.

Co-authored-by: Isaac
From the adversarial review, which found the verdict documented but never produced.

I first implemented it as "the path does not exist in the adapter's state type", and it
was unsound: secret_scopes renames its fields in PrepareState (name to scope,
backend_type to scope_backend_type), so three fields that deploy fine -- OK_RECREATE on
every transition -- were relabelled as never reaching the API. A name-identity check is
not a reachability check.

There is no sound way to derive this from the types: what makes an alert's file_path
invisible is that the engine reads the file during initialize and its contents become
other fields, so the path itself is genuinely absent from the state and NOT_OBSERVABLE
is accurate. Documented that case there and removed the verdict.

Co-authored-by: Isaac
From the AWS comparison: an AZURE_KEYVAULT scope is backed by a real vault, so the API
rejects one created without its metadata. The fake server accepted it, so the suite
reported the backend type as freely settable.

secret_scopes now matches AWS row for row.

Co-authored-by: Isaac
denik added 22 commits September 1, 2026 17:15
Three files moved under the branch. One needed a real decision:

structaccess/get.go -- main replaced .Interface().([]string) with reflect.TypeAssert in code this
branch had already deleted, so the branch's version stands and main's form is applied to the three
sites the branch still has. Consistent with the same change elsewhere in libs/structs.

Rebasing was the wrong shape here: the conflict lands in a function the branch rewrites several times
over, so it recurs commit by commit with no intermediate state worth resolving.

One golden moves, in the branch's favour. #6441 masks a Lakebase map field as a whole rather than per
entry, which is exactly the bug the catalog had recorded: four postgres_projects custom_tags rows go
from BASE_ERROR to OK.

Co-authored-by: Isaac
#6443 masks a Lakebase oneof member under its group name, and #6089 adds ./task doctest. The first
lands on rows this catalog was already recording as errors: nine of them, across postgres_projects,
postgres_branches and postgres_endpoints, become OK or SUPPRESSED.

Which is the point of the exercise -- those rows named the bug in the API's own words
("Unknown field path in update_mask") and now name its absence.

Co-authored-by: Isaac
email_notifications was in the serving endpoint's base for no reason: the leaves are reachable without
it, because the suite grows the container itself. Measured -- 32 field rows either way; the only
difference was one BASELINE_DRIFT row. Seeding it made the endpoint drift from the moment it existed,
and every other field for that type was then measured against a resource already pending.

The behaviour it exposed is real and stays recorded, in
acceptance/bundle/resources/model_serving_endpoints/update/email-notifications, where the create is
shown not applying the notifications and the update applying them.

Also fixes eleven comments across seven fixtures that still explained themselves in terms of "the
corpus config". The catalog stopped reading the invariant corpus when the fixtures were folded, so
those sentences described a mechanism that no longer exists -- including a skip whose stated reason
was that a corpus config "is not the simplest one", a rule that no longer exists either.

Co-authored-by: Isaac
…rest

output/ carried both the goldens and the full reports, with the latter gitignored by pattern -- so
34 tracked files sat among 91, and telling them apart meant knowing the pattern. Now output/ is
exactly the 34 goldens and logs/ is ignored wholesale, which is a rule rather than a pattern to
remember.

The full report is written straight to logs/<type>.full.txt, and its previous copy is kept as
.backup: reading what a change did to a report means diffing the two, and the full report is not in
git to diff against.

Co-authored-by: Isaac
…not testable

The cloud run could not create a secret at all: "Cannot create 1 Secret(s) in Schema ... (estimated
count: 100, limit: 100)". The limit is per schema and main.default on a shared workspace is full of
other runs' leftovers, the oldest from three weeks ago. The fixture now creates its own schema as a
dep, which is empty by construction.

That exposed the next layer: catalog_name and schema_name name objects that have to exist, so the
generic x and y are refused ("Catalog 'x' does not exist", "Schema 'main.x' does not exist"). The fake
server takes any string, so those four rows had only ever measured the fake. Skipped with the reason.

secrets now matches on cloud.

Co-authored-by: Isaac
… and a clear the API drops

budget_policy_id names an account-level policy ("Failed to validate budget policy x"), which a test
workspace does not have and cannot be given from here. Skipped, as on apps.

target_qps is omitempty, so a config that clears it drops it from the body and the backend keeps the
value it had. The fake server was assigning the zero value instead, which made the field look freely
clearable; two rows now record the clear being ignored, as the cloud run does. Setting and changing
the value still works on both.

Co-authored-by: Isaac
…suite

You were right that a poller was the cost. The fake server returns RUNNING on the first poll of a run
and the terminal state on the second, and the engine's waiter uses the SDK's backoff -- attempt times
a second, plus jitter -- so every run costs about a second of sleeping. A job run is immutable, so 109
of its 131 observations recreate it: roughly 110 runs, roughly 110 seconds. job_runs was 183s of the
suite's 185s.

SettleAsyncImmediately already existed for exactly this and job runs did not consult it. Now they do:
the run is terminated and reported terminal on the first poll. job_runs 183s -> 2s, the whole suite
185s -> 11s. Verdicts are unchanged, which is the point -- the wait was pure latency. The acceptance
suite leaves the simulation on, so the waiter and its state logging stay covered there.

My first attempt at this made it slower, 183s -> 261s, because it skipped the whole block including
the call that terminates the run: the run then stayed RUNNING forever and every create waited out the
cap instead of one backoff. Measured both ways before believing either.

Also adds a header to each committed report, since it is read in a diff with nothing around it to say
what the columns are or that a bad outcome here is recorded rather than failed. No counts, times or
dates in it: a header that moved when something unrelated changed would be noise in every review.

Co-authored-by: Isaac
… the backend keeps

Removing email_notifications from base cost more than the drift row it saved: with no value to clone,
the container's values came from the generic slice defaults, so the suite sent one- and two-element
lists of "x" and "y" and the API refused all 22 rows ("Invalid email format: x"). The list needed its
own declared values, not just the address inside it.

Declared alphabetically, because the backend returns the list sorted. The first attempt used
[notify, alerts] and every two-address list read back as [alerts, notify], so the endpoint drifted on
the ordering alone, for good -- a real property, and one that makes the field untestable if the
fixture fights it. Written down where the values are.

Co-authored-by: Isaac
…to be real

The clusters cloud run failed at the create: "Init scripts failed ... Tree node with path
/Users/<user>/init.sh does not exist". Three fixtures named that path -- clusters, jobs, pipelines --
and nothing ever created the file; the mock server accepts any path, so it had never mattered. They now
point at a real script the bundle uploads.

It lives in the suite's own testdata/data, not the shared invariant data directory: everything there is
uploaded by every invariant config too, so putting it there changed "Files: 2 uploaded" to 3 across
that whole suite. Found by running it.

policy_id has to name a cluster policy that exists ("'x' is not a valid cluster policy ID"), so its six
rows had only ever measured the fake. Skipped with the reason.

Also stops the previous commit's .backup files landing in output/, which is meant to hold only what is
committed -- and had duly committed 34 of them. They belong beside the logs they are backups of, where
git is not already the history.

Co-authored-by: Isaac
… be real

The clusters cloud run failed at the create even after the previous commit pointed the init script at
the file the bundle uploads. The reason is in the harness's own doc comment: this suite deliberately
uploads nothing, because a sync per permutation would dominate the run. So no destination a fixture
could name is ever there, and the mock server's acceptance of any path is the only thing that made the
field look testable. init_scripts is skipped in all three fixtures that seeded it, with that reason,
and the data directory the previous commit added is gone with it.

user_api_scopes needed real OAuth scopes: the API refuses anything else outright ("The specified scope
x is not a valid scope"), and the mock server takes any string, so eight rows had only measured the
fake. sql and dashboards.genie both verified against the API, declared for the list as well as the
element.

Co-authored-by: Isaac
…s reason

The cloud runs found 400 diverging rows across jobs, pipelines, apps and clusters, and every one is a
field the mock server accepts and a real workspace does not. Skipped, one entry per field or block,
with the API's own words in the comment above it. Two shapes:

Rejected outright -- a cross-field constraint (a job's continuous block against the seeded trigger, an
environment spec that needs exactly two of three, a shared job cluster needing more than one task), a
value format (metadata and notebook paths must be absolute, a cluster label must be default or
maintenance, a webhook id must be a UUID), or a field that does not apply to the resource this fixture
builds (is_single_node and use_ml_runtime need a cluster kind; an event log needs a UC pipeline).

Accepted and then ignored -- jobs' email_notifications, edit_mode, format, health.rules,
max_concurrent_runs, trigger.pause_status; pipelines' dry_run, maven libraries, some cluster
attributes. These are the interesting ones: the API takes the write and drops it while the mock server
applies it, so a local row said OK where a workspace says nothing happened. Modelling each in
libs/testserver is the real fix, and the skip reason records the finding until then.

vector_search_indexes keeps one field and skips the rest. Every field of an index is immutable, so each
transition recreates it behind an endpoint that has to be provisioned first: 102 rows took three hours
and then hit the test timeout. primary_key is the cheapest that still means something -- required, so
two transitions rather than six, both through create, recreate and converge.

genie_spaces needed no change. Its title rows diverged because the backend changed mid-sweep: the
failing run saw a space default-named New Agent and refused to rename it, and a probe an hour later
got New Agent with a timestamp and renamed it fine. It passes on a re-run.

Co-authored-by: Isaac
…ce said

jobs went from 199 diverging rows to none:
 - email_notifications was seeded in base and the create does not apply it, so the job drifted from the
   moment it existed and two rows recorded that rather than a field. Removed from base; the field was
   already skipped for transitions, so seeding it bought nothing else.
 - clearing the name does not converge. The CLI defaults a nameless job to Untitled, the remote holds
   that, and the plan keeps proposing an update; the mock server stores the cleared value and settles.
   The field's other five transitions are fine on both.

pipelines went from 161 to none: notification alerts and recipients are validated (Invalid ...
notifications: x), serverless cannot be set on a pipeline that declares clusters, the name cannot be
cleared, a parameter is accepted and not applied, and the autoscale mode's drift is attributed to
num_workers, which cannot move with it.

sql_warehouses had one row: a new warehouse is STARTING rather than RUNNING, so a config asking for
started: false is already satisfied on cloud and suppressed, where the mock server plans a stop.
Modelling it needs the mock server to move a warehouse from STARTING to RUNNING across reads, which the
engine's waiter depends on, so this one is timing rather than behaviour.

Co-authored-by: Isaac
The autotest package is already in the three integration tasks, with no -sample, so a PR's integration
run was attempting the full cloud sweep inside a 2h timeout -- and the full sweep takes longer than
that. Sampling by default fixes it; running everything has to stay possible, which is what this marker
is for.

A cloud run now tests two fields per type unless the commit title contains AUTOTEST_ALL, and every
field when it does. Locally nothing changes: a full run is 11 seconds, so there is nothing to save, and
./task test keeps comparing the committed goldens in full. -sample still overrides both, and the
nightly task passes -sample 0 explicitly now that the cloud default is not "all".

Co-authored-by: Isaac
A commit whose title carries the marker, so the gate is exercised the way a PR would use it: this
commit's own integration run should drive every field rather than a sample.

Co-authored-by: Isaac
Down from 51 diverging rows to none:
 - schema is refused on a storage-backed pipeline, which base declares; the API points at its
   migrate-to-dpm documentation.
 - both autoscale bounds have their drift attributed to num_workers, which base sets and no transition
   moves with them -- the same reason autoscale.mode was already skipped.
 - parameters was seeded in base and the create does not apply it, so the pipeline drifted from the
   moment it existed. Dropped from base; the field was already skipped, so seeding it bought only that
   drift row. Same shape as jobs' email_notifications.

jobs is clean on cloud now, from 199 diverging rows to none.

Co-authored-by: Isaac
CI has been red since the commit that taught libs/testserver the SQL warehouse create validations:
TestAll/sql_warehouses builds a resource from the zero value, and a warehouse with no name is now
refused, as the API refuses it. The fix belongs in the test, which has a per-type table for exactly
this: a name, a cluster_size and max_num_clusters, the three the API requires.

I should have caught this when the validations landed. I ran the field catalog and the acceptance suite
and not the package whose own unit test creates every resource type from nothing.

Co-authored-by: Isaac
The PR was CONFLICTING, and GitHub does not run pull_request workflows on a PR whose merge commit it
cannot create -- which is why no build had run since 428c5f0, and why the six red builds before that
were the last word from CI. Merging main is what unblocks it.

set.go conflicted because main made the same assign-before-ForceSendFields fix independently, with a
shorter comment and a different internal signature for updateForceSendFields. This branch keeps its own
version: it carries that fix plus the breadth-first embedded lookup main does not have, and no test
exists only on main. Verified the package builds against main's other files and every structaccess test
passes.

Co-authored-by: Isaac
…tter

CI's check job failed on YAML formatting, which ./task lint does not cover -- ./task fmt does. Ran it;
the changes are quoting and blank lines only.

docker_image needs custom containers enabled on the workspace, which this one does not have.

vector_search_indexes is local_only now. Every field of an index is immutable, so a transition recreates
it, and a real workspace deletes an index asynchronously: the create half then waits on "still exists,
waiting for deletion to complete" until the transition times out. No field can converge there, whichever
one is kept, so the type keeps its one field locally -- where the recreate path is exercised in a second
-- and does not run on cloud. That also spares the sweep 70 recreates behind an endpoint that takes five
minutes to provision, which is what made this type a three-hour outlier.

Co-authored-by: Isaac
…lability

"The field 'aws_attributes.availability' cannot be supplied when an instance pool ID is provided", and
base names a pool -- the pool decides availability for the instances it hands out. Skipped, the same
reason node_type_flexibility already is. The clusters run's other three rows were network timeouts.

The title carries AUTOTEST_ALL so this commit's integration run drives every field against a real
workspace rather than the two-per-type sample a PR gets by default.

Also documents what cost me most in this sweep: the workspace is shared, and running several types at
once makes it push back in ways that look exactly like findings. Three types "diverged" under three
parallel lanes and were clean serially -- catalogs 5024s then 19s, registered_models 7094s then 14s,
experiments 166s then 17s. The runtime is the tell, so the README now says to sweep serially and to read
a failure's runtime before believing it.

Co-authored-by: Isaac
The gate read only HEAD's subject, which is wrong for the checkout CI is likely to use: a pull request's
merge ref has a synthetic "Merge <sha> into <base>" at HEAD, carrying nothing the author wrote. A PR
titled AUTOTEST_ALL would then have sampled anyway, silently.

It now reads HEAD's subject and, when HEAD is a merge, its parents' too. Verified by building such a
merge commit and running against a real workspace from it: the marker is found through the parent. Only
a merge's parents are consulted, not the branch's history, so using the marker once does not keep
triggering full runs on the commits after it.

Co-authored-by: Isaac
…t reuse one

A rename recreates the app, and a real workspace holds a deleted app's name for up to twenty minutes
while it sits in DELETING. The chain of transitions therefore hits "An app with the same name already
exists" the moment it returns to a name an earlier transition used. The mock server deletes outright
when this suite asks it to -- which is deliberate, a suite doing thousands of updates cannot wait -- so
the chain works locally and cannot on cloud.

The run's other five rows were the workspace at its 100-app limit.

Co-authored-by: Isaac
@denik denik changed the title Catalog how the direct engine handles every settable field Auto test every field mutation Sep 3, 2026
A row read "cannot update resources.postgres_projects.foo: updating id=projects/test-pg-project-[UNIQU
...ovided in request", which is the question rather than the answer: the reason was
"Field 'spec.budget_policy_id' is in update_mask but not provided in request", and the cut fell in the
middle of it.

The engine's own framing was the thing crowding it out, and it says only what the row already says --
the resource is the report's own filename, the operation follows from the transition. Dropping that
prefix and widening the cap to 200 characters leaves every message in the catalog intact: 49 elided rows
become 0, and the longest surviving detail is 213 characters.

Worth the change beyond legibility. With the message whole, one row now reads "Field
'spec.default_endpoint_settings.suspension' is in update_mask but not provided" against a transition of
autoscaling_limit_min_cu -- the engine naming a different field than the one being changed, which the
elision had been hiding.

Also restores the rune-safe truncation from an earlier commit, lost in a merge resolution: the ellipsis
substituted for a URL is multi-byte, so a byte index can split it.

Co-authored-by: Isaac
A field the API will not clear leaves the resource drifted, and the suite replaced the resource for it.
That was the wrong response twice over.

It was wasteful: on apps it cost a fresh app per non-converging field, 31 in one run, on a shared
workspace that allows 100 in total. The sweep kept hitting "reached the maximum limit of 100 apps" for
no reason but this.

And it was wrong. The drift was real, and destroying the resource that carried it meant the next
resource had none -- so the drift reappeared later and was attributed to whichever field was under test
at the time. database_instances shows it plainly: COLLATERAL_DRIFT 46 rows to 0, OK 2 rows to 26, and
capacity now reads UPDATE_IGNORED, which is what the API actually does with it, rather than
POST_DEPLOY_DRIFT.

A resource is replaced when it will no longer deploy at all -- which is what the original comment was
really guarding against, and is rare. Drift on its own goes into the baseline, where remeasure already
knew how to put it, and every later field is measured against the drift that is really there.

Across the catalog: COLLATERAL_DRIFT 46 to 20.

Co-authored-by: Isaac
…n assume it

A failed deploy is not evidence that a resource is unusable -- the API may simply refuse to put one
field back. So ask it something it must be able to answer.

Three outcomes now, and only the last spends a new resource:

  - Base comes back. Any remaining drift is real and goes into the baseline.
  - Base does not, but the state the transitions left does. The API had already accepted that state, so
    a deploy of it succeeds unless the resource itself is broken -- it answered, so the resource is
    alive and updatable and only this one field is stuck. Base advances to what is reachable, so later
    fields start from a state the API accepts instead of re-failing on this field all run.
  - Neither deploys: past repair, replace it.

The probe is a snapshot rather than a reconstruction -- the config as the field's transitions left it is
already the state the remote holds -- so the whole thing is a switch with three arms.

Verdicts improve again, because a discarded resource was taking its drift with it and the next field was
being blamed for it: OK 1486 to 1488, and postgres_projects' BASE_ERROR 14 to 12 with the remaining ones
naming autoscaling_limit_min_cu, the field actually under test, instead of suspension.

Co-authored-by: Isaac
… does

"use_ml_runtime is not allowed with unspecified kind" -- the same constraint already recorded for
is_single_node, on a fixture whose cluster has no kind. Found by the cloud run; the mock server stores
the field either way.

Co-authored-by: Isaac
Review found the three-arm switch was doing more than it needed. The first arm deployed, then called
converged(), which deployed again -- and converged() answers false for both "the deploy failed" and
"the plan is dirty", so a second failure was being recorded as drift. The arms also differed in whether
they refreshed the baseline, so a clean restore left stale entries behind, and driftDetail would have
suppressed a later drift on one of those paths.

It is now one condition and one shared conclusion: try base, fall back to the state the transitions
left, rebuild only if neither deploys -- then re-measure the baseline from whatever landed. Always
replacing the baseline is what fixes the stale entries, and it is also simpler than deciding when not to.

converged() and hasDrift() have no callers left, which is the clearest evidence the shape was wrong:
their whole job was the second deploy this no longer does.

No golden moves.

Co-authored-by: Isaac
@eng-dev-ecosystem-bot

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: a5a8668

Run: 33797474225

Env ❌​FAIL 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
❌​ aws linux 2 1 1 551 1235 325:51
❌​ aws windows 2 1 1 553 1233 339:00
❌​ azure linux 2 1 1 536 1235 14:24
❌​ azure windows 2 1 1 538 1233 10:50
❌​ gcp linux 2 1 1 551 1235 103:25
❌​ gcp windows 2 1 1 553 1233 100:18
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
❌​ TestFields ❌​F ❌​F ❌​F ❌​F ❌​F ❌​F
❌​ TestFields/clusters ❌​F ❌​F ❌​F ❌​F ❌​F ❌​F
Top 50 slowest tests (at least 2 minutes):
duration env testname
98:16 aws linux TestFields/clusters/cluster_name
73:11 aws windows TestFields/clusters/cluster_name
30:04 aws windows TestFields/clusters/aws_attributes.instance_profile_arn
30:04 aws linux TestFields/clusters/aws_attributes.instance_profile_arn
22:57 aws linux TestFields/clusters/cluster_name/y-UNIQUE_to_x-UNIQUE
20:06 aws windows TestFields/clusters/cluster_name/x-UNIQUE_to_y-UNIQUE
16:54 aws linux TestFields/clusters/cluster_name/x-UNIQUE_to_absent
15:00 aws linux TestFields/clusters/cluster_name/absent_to_y-UNIQUE
15:00 aws windows TestFields/clusters/cluster_name/y-UNIQUE_to_x-UNIQUE
15:00 aws windows TestFields/clusters/cluster_name/absent_to_y-UNIQUE
15:00 aws linux TestFields/clusters/cluster_name/absent_to_x-UNIQUE
15:00 aws linux TestFields/clusters/cluster_name/x-UNIQUE_to_y-UNIQUE
14:18 gcp linux TestFields/clusters/cluster_name
13:22 aws linux TestFields/clusters/cluster_name/y-UNIQUE_to_absent
13:15 gcp linux TestFields/clusters/aws_attributes.instance_profile_arn
12:22 gcp windows TestFields/clusters/cluster_name
12:13 gcp windows TestFields/clusters/aws_attributes.instance_profile_arn
8:45 aws windows TestFields/clusters/cluster_name/y-UNIQUE_to_absent
7:37 aws windows TestFields/clusters/cluster_name/x-UNIQUE_to_absent
6:41 aws windows TestFields/clusters/cluster_name/absent_to_x-UNIQUE
5:10 azure windows TestFields/apps
5:01 aws linux TestFields/clusters/aws_attributes.instance_profile_arn/absent_to_y
5:00 aws windows TestFields/clusters/aws_attributes.instance_profile_arn/absent_to_y
5:00 aws windows TestFields/clusters/aws_attributes.instance_profile_arn/absent_to_x
5:00 aws linux TestFields/clusters/aws_attributes.instance_profile_arn/absent_to_x
5:00 aws windows TestFields/clusters/aws_attributes.instance_profile_arn/y_to_x
5:00 aws windows TestFields/clusters/aws_attributes.instance_profile_arn/x_to_y
5:00 aws windows TestFields/clusters/aws_attributes.instance_profile_arn/y_to_absent
5:00 aws windows TestFields/clusters/aws_attributes.instance_profile_arn/x_to_absent
5:00 aws linux TestFields/clusters/aws_attributes.instance_profile_arn/x_to_y
5:00 aws linux TestFields/clusters/aws_attributes.instance_profile_arn/y_to_absent
5:00 aws linux TestFields/clusters/aws_attributes.instance_profile_arn/y_to_x
5:00 aws linux TestFields/clusters/aws_attributes.instance_profile_arn/x_to_absent
4:48 azure linux TestFields/apps
4:38 gcp linux TestFields/apps
4:34 aws windows TestFields/apps
4:24 azure windows TestFields/apps/git_source.tag
4:22 gcp windows TestFields/apps
4:11 aws linux TestFields/apps
4:10 aws windows TestAccept
4:00 azure windows TestAccept
3:59 azure linux TestFields/apps/git_source.tag
3:52 gcp windows TestAccept
3:26 gcp linux TestFields/apps/git_source.tag
3:23 gcp windows TestFields/apps/git_source.tag
3:21 aws linux TestFields/apps/git_source.tag
3:20 aws windows TestFields/apps/git_source.tag
2:38 gcp linux TestFields/clusters/cluster_name/absent_to_y-UNIQUE
2:35 gcp windows TestFields/clusters/aws_attributes.instance_profile_arn/y_to_x
2:27 gcp linux TestFields/clusters/cluster_name/x-UNIQUE_to_y-UNIQUE

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.

2 participants