From a48d9888801786f6f48a4ec0b8ec8ea006284db7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 11 Sep 2026 15:46:03 +0200 Subject: [PATCH 1/9] bundle/config: guard against new same-depth json name collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-depth collision occurs when two anonymous embedded structs at the same embedding level both declare the same json name. encoding/json calls this ambiguous and serializes neither field; structaccess cannot read or write it. The test enumerates every resource type in config.Resources, walks the anonymous-embed tree breadth-first, and reports any json name that appears in two or more embeds at the same level. Three resource types have existing collisions (pipelines.id, apps.id/url, alerts.id — all from BaseResource fields colliding with identically-named SDK fields) that are listed in a documented allowlist. A new collision fails the test and must be either fixed or explicitly added to the list with an explanation. Depth-mismatch shadows (a direct named field overriding a same-named embedded field at a deeper level, as in ClusterPolicy.Definition overriding compute.CreatePolicy.Definition) are intentional and handled correctly by both encoding/json and structaccess: the shallower field wins. This test does not flag those. Co-authored-by: Isaac --- bundle/config/shadow_test.go | 155 +++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 bundle/config/shadow_test.go diff --git a/bundle/config/shadow_test.go b/bundle/config/shadow_test.go new file mode 100644 index 0000000000..83580fcf7f --- /dev/null +++ b/bundle/config/shadow_test.go @@ -0,0 +1,155 @@ +package config_test + +// TestNoSameDepthJSONShadows asserts that no resource config type has two +// anonymous embedded structs that both declare the same json name at the same +// embedding depth. +// +// Same-depth collisions are the only shadow that causes real problems: +// encoding/json calls the name ambiguous and serializes neither field, so the +// field silently disappears from the wire format and cannot be read or written +// by structaccess. A depth-mismatch shadow (a direct named field overriding an +// embedded one) is intentional and handled correctly — the shallower field wins. +// +// The test is a guard against accidentally introducing a new same-depth +// collision when adding fields to BaseResource, adding a new SDK embed, or +// creating a new resource type. + +import ( + "fmt" + "reflect" + "testing" + + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/libs/structs/structtag" + "github.com/stretchr/testify/assert" +) + +// knownSameDepthCollisions lists the existing same-depth json name collisions. +// These are bugs — encoding/json calls the name ambiguous and neither field is +// reachable — but fixing them requires a breaking change to the bundle YAML +// format, so they are listed here rather than silently tolerated. Every entry +// must describe where the collision comes from; fixing one removes its entry. +// +// Fix: add an explicit depth-0 field on the resource type that shadows both +// embedded declarations (e.g. `ID string \`json:"id,omitempty" bundle:"readonly"\“ +// directly on the resource struct). +var knownSameDepthCollisions = map[string][]string{ + // BaseResource.ID and pipelines.CreatePipeline.Id both carry json:"id". + "pipelines": {"id"}, + // BaseResource.ID and apps.App.Id carry json:"id"; + // BaseResource.URL and apps.App.Url carry json:"url". + "apps": {"id", "url"}, + // BaseResource.ID and sql.AlertV2.Id carry json:"id". + "alerts": {"id"}, +} + +func TestNoSameDepthJSONShadows(t *testing.T) { + rt := reflect.TypeFor[config.Resources]() + var newCollisions []string + + for i := range rt.NumField() { + f := rt.Field(i) + et := f.Type.Elem() + for et.Kind() == reflect.Pointer { + et = et.Elem() + } + if et.Kind() != reflect.Struct { + continue + } + group := structtag.JSONTag(f.Tag.Get("json")).Name() + + for _, c := range sameDepthCollisions(et) { + known := false + for _, k := range knownSameDepthCollisions[group] { + if k == c.name { + known = true + break + } + } + if !known { + newCollisions = append(newCollisions, + fmt.Sprintf("%s (%s): json name %q declared by %s and %s at the same embedding depth", + group, et, c.name, c.typeA, c.typeB)) + } + } + } + + assert.Empty(t, newCollisions, + "NEW same-depth json name collisions found — encoding/json calls these ambiguous "+ + "and serializes neither; structaccess cannot read or write them either. "+ + "Fix by adding an explicit depth-0 field on the resource struct, or add to knownSameDepthCollisions.") +} + +type collision struct { + name, typeA, typeB string +} + +// sameDepthCollisions returns the json names declared at the same embedding +// depth by two or more anonymous embedded structs inside t. +func sameDepthCollisions(t reflect.Type) []collision { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + + var result []collision + seen := map[reflect.Type]bool{t: true} + level := embeddedTypes(t) + + for len(level) > 0 { + nameToTypes := map[string][]string{} + for _, ft := range level { + for i := range ft.NumField() { + sf := ft.Field(i) + if sf.PkgPath != "" || sf.Anonymous { + continue + } + name := structtag.JSONTag(sf.Tag.Get("json")).Name() + if name == "" { + name = sf.Name + } + if name == "-" || sf.Name == "ForceSendFields" { + continue + } + nameToTypes[name] = append(nameToTypes[name], ft.String()) + } + } + for name, types := range nameToTypes { + if len(types) > 1 { + result = append(result, collision{name: name, typeA: types[0], typeB: types[1]}) + } + } + + var next []reflect.Type + for _, ft := range level { + for _, embedded := range embeddedTypes(ft) { + if !seen[embedded] { + seen[embedded] = true + next = append(next, embedded) + } + } + } + level = next + } + return result +} + +func embeddedTypes(t reflect.Type) []reflect.Type { + var out []reflect.Type + for i := range t.NumField() { + sf := t.Field(i) + if !sf.Anonymous { + continue + } + ft := sf.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + out = append(out, ft) + } + } + return out +} From 4ec2532fd665babf06bf8560c72e7d14ca11e274 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 11 Sep 2026 16:34:22 +0200 Subject: [PATCH 2/9] bundle/config: fix same-depth json name collisions in Pipeline, App, Alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three resource types had two anonymous embedded structs both declaring the same json name at the same embedding depth: - Pipeline: BaseResource.ID and CreatePipeline.Id both carry json:"id" - App: BaseResource.{ID,URL} and apps.App.{Id,Url} carry the same names - Alert: BaseResource.ID and AlertV2.Id both carry json:"id" encoding/json calls same-depth collisions ambiguous and serializes neither field, which means the field is silently unreachable from the wire format and from structaccess. Fix: add an explicit depth-0 field directly on the resource struct for each colliding name. The depth-0 field wins over both embedded depth-1 declarations in both encoding/json and structaccess. The fix does not change the json name or the field semantics — only the declaration depth changes, from embedded to direct. Tests that constructed these structs with `BaseResource: resources.BaseResource{ID: "X"}` now also set `ID: "X"` directly, because the promoted name now resolves to the new depth-0 field instead of the embedded one. The guard test (shadow_test.go) now has an empty allowlist, because there are no remaining same-depth collisions in any resource type. Co-authored-by: Isaac --- bundle/config/mutator/initialize_urls_test.go | 1 + bundle/config/resources/alerts.go | 5 ++++- bundle/config/resources/apps.go | 7 ++++++- bundle/config/resources/pipeline.go | 6 +++++- bundle/config/shadow_test.go | 18 +++++------------- bundle/deploy/metadata/compute_test.go | 3 +++ bundle/render/render_text_output_test.go | 2 ++ bundle/run/pipeline_test.go | 2 ++ 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/bundle/config/mutator/initialize_urls_test.go b/bundle/config/mutator/initialize_urls_test.go index 980c4d8851..058e675755 100644 --- a/bundle/config/mutator/initialize_urls_test.go +++ b/bundle/config/mutator/initialize_urls_test.go @@ -31,6 +31,7 @@ func TestInitializeURLs(t *testing.T) { }, Pipelines: map[string]*resources.Pipeline{ "pipeline1": { + ID: "3", BaseResource: resources.BaseResource{ID: "3"}, CreatePipeline: pipelines.CreatePipeline{Name: "pipeline1"}, }, diff --git a/bundle/config/resources/alerts.go b/bundle/config/resources/alerts.go index cf3119a6f9..73808f0445 100644 --- a/bundle/config/resources/alerts.go +++ b/bundle/config/resources/alerts.go @@ -13,7 +13,10 @@ import ( type Alert struct { BaseResource - sql.AlertV2 //nolint AlertV2 also defines Id and URL field with the same json tag "id" and "url" + sql.AlertV2 //nolint:govet // AlertV2.Id and our depth-0 ID field both carry json:"id"; the depth-0 field wins + // ID shadows the same-depth collision between BaseResource.ID and + // AlertV2.Id — both embed json:"id" at depth 1. + ID string `json:"id,omitempty" bundle:"readonly"` Permissions []Permission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/apps.go b/bundle/config/resources/apps.go index f1d62bcc89..c1d5470469 100644 --- a/bundle/config/resources/apps.go +++ b/bundle/config/resources/apps.go @@ -35,7 +35,12 @@ type AppEnvVar struct { type App struct { BaseResource - apps.App // nolint App struct also defines Id and URL field with the same json tag "id" and "url" + apps.App //nolint:govet // apps.App.{Id,Url} and our depth-0 {ID,URL} fields carry the same json names; the depth-0 fields win + // ID and URL shadow the same-depth collisions between BaseResource.{ID,URL} + // and apps.App.{Id,Url} — both embed json:"id"/"url" at depth 1. + ID string `json:"id,omitempty" bundle:"readonly"` + URL string `json:"url,omitempty" bundle:"internal"` + // Note: apps.App already includes GitRepository field from the SDK // Lifecycle shadows BaseResource.Lifecycle to add support for lifecycle.started. diff --git a/bundle/config/resources/pipeline.go b/bundle/config/resources/pipeline.go index 80c72e62fc..dbfd58a5b7 100644 --- a/bundle/config/resources/pipeline.go +++ b/bundle/config/resources/pipeline.go @@ -13,7 +13,11 @@ import ( type Pipeline struct { BaseResource - pipelines.CreatePipeline //nolint CreatePipeline also defines Id field with the same json tag "id" + pipelines.CreatePipeline //nolint:govet // CreatePipeline.Id and our depth-0 ID field both carry json:"id"; the depth-0 field wins + // ID shadows the same-depth collision between BaseResource.ID and + // CreatePipeline.Id — both embed json:"id" at depth 1. Declaring it + // here at depth 0 makes the field reachable and unambiguous. + ID string `json:"id,omitempty" bundle:"readonly"` Permissions []PipelinePermission `json:"permissions,omitempty"` diff --git a/bundle/config/shadow_test.go b/bundle/config/shadow_test.go index 83580fcf7f..e3739d5869 100644 --- a/bundle/config/shadow_test.go +++ b/bundle/config/shadow_test.go @@ -17,6 +17,7 @@ package config_test import ( "fmt" "reflect" + "slices" "testing" "github.com/databricks/cli/bundle/config" @@ -47,8 +48,7 @@ func TestNoSameDepthJSONShadows(t *testing.T) { rt := reflect.TypeFor[config.Resources]() var newCollisions []string - for i := range rt.NumField() { - f := rt.Field(i) + for f := range rt.Fields() { et := f.Type.Elem() for et.Kind() == reflect.Pointer { et = et.Elem() @@ -59,13 +59,7 @@ func TestNoSameDepthJSONShadows(t *testing.T) { group := structtag.JSONTag(f.Tag.Get("json")).Name() for _, c := range sameDepthCollisions(et) { - known := false - for _, k := range knownSameDepthCollisions[group] { - if k == c.name { - known = true - break - } - } + known := slices.Contains(knownSameDepthCollisions[group], c.name) if !known { newCollisions = append(newCollisions, fmt.Sprintf("%s (%s): json name %q declared by %s and %s at the same embedding depth", @@ -101,8 +95,7 @@ func sameDepthCollisions(t reflect.Type) []collision { for len(level) > 0 { nameToTypes := map[string][]string{} for _, ft := range level { - for i := range ft.NumField() { - sf := ft.Field(i) + for sf := range ft.Fields() { if sf.PkgPath != "" || sf.Anonymous { continue } @@ -138,8 +131,7 @@ func sameDepthCollisions(t reflect.Type) []collision { func embeddedTypes(t reflect.Type) []reflect.Type { var out []reflect.Type - for i := range t.NumField() { - sf := t.Field(i) + for sf := range t.Fields() { if !sf.Anonymous { continue } diff --git a/bundle/deploy/metadata/compute_test.go b/bundle/deploy/metadata/compute_test.go index 834a81a935..75b307a273 100644 --- a/bundle/deploy/metadata/compute_test.go +++ b/bundle/deploy/metadata/compute_test.go @@ -53,12 +53,14 @@ func TestComputeMetadataMutator(t *testing.T) { }, Pipelines: map[string]*resources.Pipeline{ "my-pipeline-1": { + ID: "3333", BaseResource: resources.BaseResource{ID: "3333"}, CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline One", }, }, "my-pipeline-2": { + ID: "4444", BaseResource: resources.BaseResource{ID: "4444"}, CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline Two", @@ -161,6 +163,7 @@ func TestComputeMetadataMutatorStateOnlyResources(t *testing.T) { }, Pipelines: map[string]*resources.Pipeline{ "state-only-pipeline": { + ID: "2222", BaseResource: resources.BaseResource{ID: "2222"}, }, }, diff --git a/bundle/render/render_text_output_test.go b/bundle/render/render_text_output_test.go index 192a95c108..172cb4788e 100644 --- a/bundle/render/render_text_output_test.go +++ b/bundle/render/render_text_output_test.go @@ -309,11 +309,13 @@ func TestRenderSummary(t *testing.T) { }, Pipelines: map[string]*resources.Pipeline{ "pipeline2": { + ID: "4", BaseResource: resources.BaseResource{ID: "4"}, // no URL CreatePipeline: pipelines.CreatePipeline{Name: "pipeline2-name"}, }, "pipeline1": { + ID: "3", BaseResource: resources.BaseResource{ID: "3", URL: "https://url3"}, CreatePipeline: pipelines.CreatePipeline{Name: "pipeline1-name"}, }, diff --git a/bundle/run/pipeline_test.go b/bundle/run/pipeline_test.go index 5645721846..a24dba7d36 100644 --- a/bundle/run/pipeline_test.go +++ b/bundle/run/pipeline_test.go @@ -17,6 +17,7 @@ import ( func TestPipelineRunnerCancel(t *testing.T) { pipeline := &resources.Pipeline{ + ID: "123", BaseResource: resources.BaseResource{ID: "123"}, } @@ -52,6 +53,7 @@ func TestPipelineRunnerCancel(t *testing.T) { func TestPipelineRunnerRestart(t *testing.T) { pipeline := &resources.Pipeline{ + ID: "123", BaseResource: resources.BaseResource{ID: "123"}, } From f3aedfb82fb78f5fe34a5cbb19b9d7fde065491c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 11 Sep 2026 19:06:21 +0200 Subject: [PATCH 3/9] bundle/config: move shadow guard into resources_types_test.go TestNoSameDepthJSONShadows belongs with the other resource type invariant tests in resources_types_test.go rather than in a standalone file. The pattern is the same as TestResourceTypesZeroValueFieldsSerialize: walk all resource types and assert a structural property. --- bundle/config/resources_types_test.go | 122 +++++++++++++++++++++ bundle/config/shadow_test.go | 147 -------------------------- 2 files changed, 122 insertions(+), 147 deletions(-) delete mode 100644 bundle/config/shadow_test.go diff --git a/bundle/config/resources_types_test.go b/bundle/config/resources_types_test.go index 4dd0b18d26..61c3cb4cc3 100644 --- a/bundle/config/resources_types_test.go +++ b/bundle/config/resources_types_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "fmt" "reflect" "slices" "testing" @@ -108,3 +109,124 @@ func zeroValueScalars(t reflect.Type, depth int, seen map[reflect.Type]bool) dyn } return dyn.V(m) } + +// knownSameDepthCollisions lists the existing same-depth json name collisions. +// These are bugs — encoding/json calls the name ambiguous and neither field is +// reachable — but fixing them requires a breaking change to the bundle YAML +// format, so they are listed here rather than silently tolerated. Every entry +// must describe where the collision comes from; fixing one removes its entry. +// +// Fix: add an explicit depth-0 field on the resource type that shadows both +// embedded declarations (e.g. `ID string \`json:"id,omitempty" bundle:"readonly"\“ +// directly on the resource struct). +var knownSameDepthCollisions = map[string][]string{ + // BaseResource.ID and pipelines.CreatePipeline.Id both carry json:"id". + "pipelines": {"id"}, + // BaseResource.ID and apps.App.Id carry json:"id"; + // BaseResource.URL and apps.App.Url carry json:"url". + "apps": {"id", "url"}, + // BaseResource.ID and sql.AlertV2.Id carry json:"id". + "alerts": {"id"}, +} + +func TestNoSameDepthJSONShadows(t *testing.T) { + rt := reflect.TypeFor[Resources]() + var newCollisions []string + + for f := range rt.Fields() { + et := f.Type.Elem() + for et.Kind() == reflect.Pointer { + et = et.Elem() + } + if et.Kind() != reflect.Struct { + continue + } + group := structtag.JSONTag(f.Tag.Get("json")).Name() + + for _, c := range sameDepthCollisions(et) { + known := slices.Contains(knownSameDepthCollisions[group], c.name) + if !known { + newCollisions = append(newCollisions, + fmt.Sprintf("%s (%s): json name %q declared by %s and %s at the same embedding depth", + group, et, c.name, c.typeA, c.typeB)) + } + } + } + + assert.Empty(t, newCollisions, + "NEW same-depth json name collisions found — encoding/json calls these ambiguous "+ + "and serializes neither; structaccess cannot read or write them either. "+ + "Fix by adding an explicit depth-0 field on the resource struct, or add to knownSameDepthCollisions.") +} + +type collision struct { + name, typeA, typeB string +} + +// sameDepthCollisions returns the json names declared at the same embedding +// depth by two or more anonymous embedded structs inside t. +func sameDepthCollisions(t reflect.Type) []collision { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + + var result []collision + seen := map[reflect.Type]bool{t: true} + level := embeddedTypes(t) + + for len(level) > 0 { + nameToTypes := map[string][]string{} + for _, ft := range level { + for sf := range ft.Fields() { + if sf.PkgPath != "" || sf.Anonymous { + continue + } + name := structtag.JSONTag(sf.Tag.Get("json")).Name() + if name == "" { + name = sf.Name + } + if name == "-" || sf.Name == "ForceSendFields" { + continue + } + nameToTypes[name] = append(nameToTypes[name], ft.String()) + } + } + for name, types := range nameToTypes { + if len(types) > 1 { + result = append(result, collision{name: name, typeA: types[0], typeB: types[1]}) + } + } + + var next []reflect.Type + for _, ft := range level { + for _, embedded := range embeddedTypes(ft) { + if !seen[embedded] { + seen[embedded] = true + next = append(next, embedded) + } + } + } + level = next + } + return result +} + +func embeddedTypes(t reflect.Type) []reflect.Type { + var out []reflect.Type + for sf := range t.Fields() { + if !sf.Anonymous { + continue + } + ft := sf.Type + for ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + out = append(out, ft) + } + } + return out +} diff --git a/bundle/config/shadow_test.go b/bundle/config/shadow_test.go deleted file mode 100644 index e3739d5869..0000000000 --- a/bundle/config/shadow_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package config_test - -// TestNoSameDepthJSONShadows asserts that no resource config type has two -// anonymous embedded structs that both declare the same json name at the same -// embedding depth. -// -// Same-depth collisions are the only shadow that causes real problems: -// encoding/json calls the name ambiguous and serializes neither field, so the -// field silently disappears from the wire format and cannot be read or written -// by structaccess. A depth-mismatch shadow (a direct named field overriding an -// embedded one) is intentional and handled correctly — the shallower field wins. -// -// The test is a guard against accidentally introducing a new same-depth -// collision when adding fields to BaseResource, adding a new SDK embed, or -// creating a new resource type. - -import ( - "fmt" - "reflect" - "slices" - "testing" - - "github.com/databricks/cli/bundle/config" - "github.com/databricks/cli/libs/structs/structtag" - "github.com/stretchr/testify/assert" -) - -// knownSameDepthCollisions lists the existing same-depth json name collisions. -// These are bugs — encoding/json calls the name ambiguous and neither field is -// reachable — but fixing them requires a breaking change to the bundle YAML -// format, so they are listed here rather than silently tolerated. Every entry -// must describe where the collision comes from; fixing one removes its entry. -// -// Fix: add an explicit depth-0 field on the resource type that shadows both -// embedded declarations (e.g. `ID string \`json:"id,omitempty" bundle:"readonly"\“ -// directly on the resource struct). -var knownSameDepthCollisions = map[string][]string{ - // BaseResource.ID and pipelines.CreatePipeline.Id both carry json:"id". - "pipelines": {"id"}, - // BaseResource.ID and apps.App.Id carry json:"id"; - // BaseResource.URL and apps.App.Url carry json:"url". - "apps": {"id", "url"}, - // BaseResource.ID and sql.AlertV2.Id carry json:"id". - "alerts": {"id"}, -} - -func TestNoSameDepthJSONShadows(t *testing.T) { - rt := reflect.TypeFor[config.Resources]() - var newCollisions []string - - for f := range rt.Fields() { - et := f.Type.Elem() - for et.Kind() == reflect.Pointer { - et = et.Elem() - } - if et.Kind() != reflect.Struct { - continue - } - group := structtag.JSONTag(f.Tag.Get("json")).Name() - - for _, c := range sameDepthCollisions(et) { - known := slices.Contains(knownSameDepthCollisions[group], c.name) - if !known { - newCollisions = append(newCollisions, - fmt.Sprintf("%s (%s): json name %q declared by %s and %s at the same embedding depth", - group, et, c.name, c.typeA, c.typeB)) - } - } - } - - assert.Empty(t, newCollisions, - "NEW same-depth json name collisions found — encoding/json calls these ambiguous "+ - "and serializes neither; structaccess cannot read or write them either. "+ - "Fix by adding an explicit depth-0 field on the resource struct, or add to knownSameDepthCollisions.") -} - -type collision struct { - name, typeA, typeB string -} - -// sameDepthCollisions returns the json names declared at the same embedding -// depth by two or more anonymous embedded structs inside t. -func sameDepthCollisions(t reflect.Type) []collision { - for t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t.Kind() != reflect.Struct { - return nil - } - - var result []collision - seen := map[reflect.Type]bool{t: true} - level := embeddedTypes(t) - - for len(level) > 0 { - nameToTypes := map[string][]string{} - for _, ft := range level { - for sf := range ft.Fields() { - if sf.PkgPath != "" || sf.Anonymous { - continue - } - name := structtag.JSONTag(sf.Tag.Get("json")).Name() - if name == "" { - name = sf.Name - } - if name == "-" || sf.Name == "ForceSendFields" { - continue - } - nameToTypes[name] = append(nameToTypes[name], ft.String()) - } - } - for name, types := range nameToTypes { - if len(types) > 1 { - result = append(result, collision{name: name, typeA: types[0], typeB: types[1]}) - } - } - - var next []reflect.Type - for _, ft := range level { - for _, embedded := range embeddedTypes(ft) { - if !seen[embedded] { - seen[embedded] = true - next = append(next, embedded) - } - } - } - level = next - } - return result -} - -func embeddedTypes(t reflect.Type) []reflect.Type { - var out []reflect.Type - for sf := range t.Fields() { - if !sf.Anonymous { - continue - } - ft := sf.Type - for ft.Kind() == reflect.Pointer { - ft = ft.Elem() - } - if ft.Kind() == reflect.Struct { - out = append(out, ft) - } - } - return out -} From 1edd065f4c94825d76bd587ec0a331f51c3c4a12 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 13:16:24 +0200 Subject: [PATCH 4/9] bundle/config: remove ID from BaseResource, declare it on each resource type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseResource.ID had the same json:"id" name as several SDK types (CreatePipeline, apps.App, AlertV2), causing same-depth ambiguity where encoding/json serialized neither field. The fix in the previous commit added explicit depth-0 ID fields on the three affected types; this commit makes the pattern consistent: every resource type now declares ID directly, and BaseResource no longer carries it. BaseResource retains ModifiedStatus, URL, and Lifecycle — these are bundle-invented json names that will never collide with SDK fields. ID was the sole offender. After this change: - No resource type has a dead BaseResource.ID slot alongside a depth-0 ID. - TestNoSameDepthJSONShadows passes with an empty allowlist by construction: BaseResource cannot contribute an id collision because it no longer has one. - Tests that constructed structs with BaseResource{ID: "x"} now set ID: "x" directly on the resource struct instead. Co-authored-by: Isaac --- bundle/config/mutator/initialize_urls_test.go | 38 ++++++++++++------- bundle/config/resources/base.go | 1 - bundle/config/resources/catalog.go | 1 + bundle/config/resources/cluster_policy.go | 1 + bundle/config/resources/clusters.go | 1 + bundle/config/resources/dashboard.go | 1 + bundle/config/resources/database_catalog.go | 1 + bundle/config/resources/database_instance.go | 1 + bundle/config/resources/genie_space.go | 1 + bundle/config/resources/instance_pools.go | 1 + bundle/config/resources/job.go | 1 + bundle/config/resources/job_run.go | 1 + bundle/config/resources/mlflow_experiment.go | 1 + bundle/config/resources/mlflow_model.go | 1 + bundle/config/resources/model_service.go | 1 + .../resources/model_serving_endpoint.go | 1 + bundle/config/resources/postgres_branch.go | 1 + bundle/config/resources/postgres_catalog.go | 1 + bundle/config/resources/postgres_database.go | 1 + bundle/config/resources/postgres_endpoint.go | 1 + bundle/config/resources/postgres_project.go | 1 + bundle/config/resources/postgres_role.go | 1 + .../resources/postgres_snapshot_schedule.go | 1 + .../config/resources/postgres_synced_table.go | 1 + bundle/config/resources/quality_monitor.go | 1 + bundle/config/resources/registered_model.go | 1 + bundle/config/resources/schema.go | 1 + bundle/config/resources/secret.go | 1 + bundle/config/resources/secret_scope.go | 1 + bundle/config/resources/sql_warehouses.go | 1 + .../config/resources/synced_database_table.go | 1 + .../resources/vector_search_endpoint.go | 1 + .../config/resources/vector_search_index.go | 1 + bundle/config/resources/volume.go | 1 + bundle/deploy/metadata/compute_test.go | 27 +++++++------ bundle/render/render_text_output_test.go | 28 +++++++++----- bundle/run/job_test.go | 14 ++++--- bundle/run/pipeline_test.go | 6 +-- 38 files changed, 99 insertions(+), 47 deletions(-) diff --git a/bundle/config/mutator/initialize_urls_test.go b/bundle/config/mutator/initialize_urls_test.go index 058e675755..5f1afe1b3e 100644 --- a/bundle/config/mutator/initialize_urls_test.go +++ b/bundle/config/mutator/initialize_urls_test.go @@ -25,32 +25,36 @@ func TestInitializeURLs(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "job1": { - BaseResource: resources.BaseResource{ID: "1"}, - JobSettings: jobs.JobSettings{Name: "job1"}, + ID: "1", + + JobSettings: jobs.JobSettings{Name: "job1"}, }, }, Pipelines: map[string]*resources.Pipeline{ "pipeline1": { - ID: "3", - BaseResource: resources.BaseResource{ID: "3"}, + ID: "3", + CreatePipeline: pipelines.CreatePipeline{Name: "pipeline1"}, }, }, Experiments: map[string]*resources.MlflowExperiment{ "experiment1": { - BaseResource: resources.BaseResource{ID: "4"}, + ID: "4", + CreateExperiment: ml.CreateExperiment{Name: "experiment1"}, }, }, Models: map[string]*resources.MlflowModel{ "model1": { - BaseResource: resources.BaseResource{ID: "a model uses its name for identifier"}, + ID: "a model uses its name for identifier", + CreateModelRequest: ml.CreateModelRequest{Name: "a model uses its name for identifier"}, }, }, ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{ "servingendpoint1": { - BaseResource: resources.BaseResource{ID: "my_serving_endpoint"}, + ID: "my_serving_endpoint", + CreateServingEndpoint: serving.CreateServingEndpoint{ Name: "my_serving_endpoint", }, @@ -58,7 +62,8 @@ func TestInitializeURLs(t *testing.T) { }, RegisteredModels: map[string]*resources.RegisteredModel{ "registeredmodel1": { - BaseResource: resources.BaseResource{ID: "8"}, + ID: "8", + CreateRegisteredModelRequest: catalog.CreateRegisteredModelRequest{ Name: "my_registered_model", }, @@ -72,7 +77,8 @@ func TestInitializeURLs(t *testing.T) { }, VectorSearchIndexes: map[string]*resources.VectorSearchIndex{ "vectorsearchindex1": { - BaseResource: resources.BaseResource{ID: "catalog.schema.vectorsearchindex1"}, + ID: "catalog.schema.vectorsearchindex1", + CreateVectorIndexRequest: vectorsearch.CreateVectorIndexRequest{ Name: "catalog.schema.vectorsearchindex1", }, @@ -80,7 +86,8 @@ func TestInitializeURLs(t *testing.T) { }, Schemas: map[string]*resources.Schema{ "schema1": { - BaseResource: resources.BaseResource{ID: "catalog.schema"}, + ID: "catalog.schema", + CreateSchema: catalog.CreateSchema{ Name: "schema", }, @@ -88,7 +95,8 @@ func TestInitializeURLs(t *testing.T) { }, Clusters: map[string]*resources.Cluster{ "cluster1": { - BaseResource: resources.BaseResource{ID: "1017-103929-vlr7jzcf"}, + ID: "1017-103929-vlr7jzcf", + ClusterSpec: compute.ClusterSpec{ ClusterName: "cluster1", }, @@ -96,7 +104,8 @@ func TestInitializeURLs(t *testing.T) { }, Dashboards: map[string]*resources.Dashboard{ "dashboard1": { - BaseResource: resources.BaseResource{ID: "01ef8d56871e1d50ae30ce7375e42478"}, + ID: "01ef8d56871e1d50ae30ce7375e42478", + DashboardConfig: resources.DashboardConfig{ DisplayName: "My special dashboard", }, @@ -137,8 +146,9 @@ func TestInitializeURLsWithoutOrgId(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "job1": { - BaseResource: resources.BaseResource{ID: "1"}, - JobSettings: jobs.JobSettings{Name: "job1"}, + ID: "1", + + JobSettings: jobs.JobSettings{Name: "job1"}, }, }, }, diff --git a/bundle/config/resources/base.go b/bundle/config/resources/base.go index 6a27d2b6e4..86b8f8dbda 100644 --- a/bundle/config/resources/base.go +++ b/bundle/config/resources/base.go @@ -2,7 +2,6 @@ package resources // BaseResource is a struct that contains the base settings for a resource. type BaseResource struct { - ID string `json:"id,omitempty" bundle:"readonly"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` URL string `json:"url,omitempty" bundle:"internal"` Lifecycle Lifecycle `json:"lifecycle,omitempty"` diff --git a/bundle/config/resources/catalog.go b/bundle/config/resources/catalog.go index 8c5f6dcb85..3fc4a5c89d 100644 --- a/bundle/config/resources/catalog.go +++ b/bundle/config/resources/catalog.go @@ -15,6 +15,7 @@ import ( type Catalog struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` catalog.CreateCatalog // List of grants to apply on this catalog. diff --git a/bundle/config/resources/cluster_policy.go b/bundle/config/resources/cluster_policy.go index f8f4a0f717..8315c5b8b9 100644 --- a/bundle/config/resources/cluster_policy.go +++ b/bundle/config/resources/cluster_policy.go @@ -13,6 +13,7 @@ import ( type ClusterPolicy struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` compute.CreatePolicy // Shadows the embedded compute.CreatePolicy.Definition (a string). `any` lets the diff --git a/bundle/config/resources/clusters.go b/bundle/config/resources/clusters.go index 9de7b62c52..aec5e88634 100644 --- a/bundle/config/resources/clusters.go +++ b/bundle/config/resources/clusters.go @@ -13,6 +13,7 @@ import ( type Cluster struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` compute.ClusterSpec // Lifecycle shadows BaseResource.Lifecycle to add support for lifecycle.started. diff --git a/bundle/config/resources/dashboard.go b/bundle/config/resources/dashboard.go index 692fc62b61..16b3229ed1 100644 --- a/bundle/config/resources/dashboard.go +++ b/bundle/config/resources/dashboard.go @@ -78,6 +78,7 @@ func (c DashboardConfig) MarshalJSON() ([]byte, error) { type Dashboard struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` DashboardConfig Permissions []Permission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/database_catalog.go b/bundle/config/resources/database_catalog.go index ddae1ea189..c1c0b86bd2 100644 --- a/bundle/config/resources/database_catalog.go +++ b/bundle/config/resources/database_catalog.go @@ -14,6 +14,7 @@ import ( type DatabaseCatalog struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` database.DatabaseCatalog } diff --git a/bundle/config/resources/database_instance.go b/bundle/config/resources/database_instance.go index 661ced9499..5d4abf5a27 100644 --- a/bundle/config/resources/database_instance.go +++ b/bundle/config/resources/database_instance.go @@ -14,6 +14,7 @@ import ( type DatabaseInstance struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` database.DatabaseInstance Permissions []Permission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/genie_space.go b/bundle/config/resources/genie_space.go index 19ce470289..3d60fa01a5 100644 --- a/bundle/config/resources/genie_space.go +++ b/bundle/config/resources/genie_space.go @@ -51,6 +51,7 @@ func (c GenieSpaceConfig) MarshalJSON() ([]byte, error) { type GenieSpace struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` GenieSpaceConfig Permissions []Permission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/instance_pools.go b/bundle/config/resources/instance_pools.go index 09179f509a..81c9a75e16 100644 --- a/bundle/config/resources/instance_pools.go +++ b/bundle/config/resources/instance_pools.go @@ -13,6 +13,7 @@ import ( type InstancePool struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` compute.CreateInstancePool Permissions []InstancePoolPermission `json:"permissions,omitempty"` } diff --git a/bundle/config/resources/job.go b/bundle/config/resources/job.go index 5f0eda4a17..5d62c51179 100644 --- a/bundle/config/resources/job.go +++ b/bundle/config/resources/job.go @@ -14,6 +14,7 @@ import ( type Job struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` jobs.JobSettings Permissions []JobPermission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 8b7428e8fb..ff9a91a889 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -18,6 +18,7 @@ import ( // own configuration changes; lifecycle.triggers can add further conditions. type JobRun struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` jobs.RunNow // Lifecycle shadows BaseResource.Lifecycle so job_runs can set triggers. diff --git a/bundle/config/resources/mlflow_experiment.go b/bundle/config/resources/mlflow_experiment.go index 55b1bde4f2..965a3ca55f 100644 --- a/bundle/config/resources/mlflow_experiment.go +++ b/bundle/config/resources/mlflow_experiment.go @@ -13,6 +13,7 @@ import ( type MlflowExperiment struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` ml.CreateExperiment Permissions []MlflowExperimentPermission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/mlflow_model.go b/bundle/config/resources/mlflow_model.go index eb2adf0158..f7bbcd96e7 100644 --- a/bundle/config/resources/mlflow_model.go +++ b/bundle/config/resources/mlflow_model.go @@ -13,6 +13,7 @@ import ( type MlflowModel struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` ml.CreateModelRequest Permissions []MlflowModelPermission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/model_service.go b/bundle/config/resources/model_service.go index 6f98ffbefb..d6f94624e4 100644 --- a/bundle/config/resources/model_service.go +++ b/bundle/config/resources/model_service.go @@ -52,6 +52,7 @@ func (c ModelServiceConfig) MarshalJSON() ([]byte, error) { type ModelService struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` ModelServiceConfig } diff --git a/bundle/config/resources/model_serving_endpoint.go b/bundle/config/resources/model_serving_endpoint.go index e23d3d7b68..517ea7fb13 100644 --- a/bundle/config/resources/model_serving_endpoint.go +++ b/bundle/config/resources/model_serving_endpoint.go @@ -13,6 +13,7 @@ import ( type ModelServingEndpoint struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` // This represents the input args for terraform, and will get converted // to a HCL representation for CRUD diff --git a/bundle/config/resources/postgres_branch.go b/bundle/config/resources/postgres_branch.go index 077dc83acf..a248667a87 100644 --- a/bundle/config/resources/postgres_branch.go +++ b/bundle/config/resources/postgres_branch.go @@ -48,6 +48,7 @@ func (c PostgresBranchConfig) MarshalJSON() ([]byte, error) { type PostgresBranch struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresBranchConfig } diff --git a/bundle/config/resources/postgres_catalog.go b/bundle/config/resources/postgres_catalog.go index 65c0f97112..51246f06af 100644 --- a/bundle/config/resources/postgres_catalog.go +++ b/bundle/config/resources/postgres_catalog.go @@ -29,6 +29,7 @@ func (c PostgresCatalogConfig) MarshalJSON() ([]byte, error) { type PostgresCatalog struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresCatalogConfig } diff --git a/bundle/config/resources/postgres_database.go b/bundle/config/resources/postgres_database.go index 62162b6d76..efbe5984d5 100644 --- a/bundle/config/resources/postgres_database.go +++ b/bundle/config/resources/postgres_database.go @@ -37,6 +37,7 @@ func (c PostgresDatabaseConfig) MarshalJSON() ([]byte, error) { type PostgresDatabase struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresDatabaseConfig } diff --git a/bundle/config/resources/postgres_endpoint.go b/bundle/config/resources/postgres_endpoint.go index abc2dc2c2d..0947045a71 100644 --- a/bundle/config/resources/postgres_endpoint.go +++ b/bundle/config/resources/postgres_endpoint.go @@ -36,6 +36,7 @@ func (c PostgresEndpointConfig) MarshalJSON() ([]byte, error) { type PostgresEndpoint struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresEndpointConfig } diff --git a/bundle/config/resources/postgres_project.go b/bundle/config/resources/postgres_project.go index 4b021737c9..0cdfbbcc2a 100644 --- a/bundle/config/resources/postgres_project.go +++ b/bundle/config/resources/postgres_project.go @@ -40,6 +40,7 @@ func (c PostgresProjectConfig) MarshalJSON() ([]byte, error) { type PostgresProject struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresProjectConfig Permissions []Permission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/postgres_role.go b/bundle/config/resources/postgres_role.go index cad5d86dba..7cc30a3ee0 100644 --- a/bundle/config/resources/postgres_role.go +++ b/bundle/config/resources/postgres_role.go @@ -38,6 +38,7 @@ func (c PostgresRoleConfig) MarshalJSON() ([]byte, error) { type PostgresRole struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresRoleConfig } diff --git a/bundle/config/resources/postgres_snapshot_schedule.go b/bundle/config/resources/postgres_snapshot_schedule.go index a13b78cef8..d07ac597f3 100644 --- a/bundle/config/resources/postgres_snapshot_schedule.go +++ b/bundle/config/resources/postgres_snapshot_schedule.go @@ -37,6 +37,7 @@ func (c PostgresSnapshotScheduleConfig) MarshalJSON() ([]byte, error) { type PostgresSnapshotSchedule struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresSnapshotScheduleConfig } diff --git a/bundle/config/resources/postgres_synced_table.go b/bundle/config/resources/postgres_synced_table.go index d377e6cc1c..3aac2578d7 100644 --- a/bundle/config/resources/postgres_synced_table.go +++ b/bundle/config/resources/postgres_synced_table.go @@ -31,6 +31,7 @@ func (c PostgresSyncedTableConfig) MarshalJSON() ([]byte, error) { type PostgresSyncedTable struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` PostgresSyncedTableConfig } diff --git a/bundle/config/resources/quality_monitor.go b/bundle/config/resources/quality_monitor.go index dd6143aa4a..5548ef4d7d 100644 --- a/bundle/config/resources/quality_monitor.go +++ b/bundle/config/resources/quality_monitor.go @@ -13,6 +13,7 @@ import ( type QualityMonitor struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` // This struct defines the creation payload for a monitor. catalog.CreateMonitor diff --git a/bundle/config/resources/registered_model.go b/bundle/config/resources/registered_model.go index 85288bee62..eedb33ef3d 100644 --- a/bundle/config/resources/registered_model.go +++ b/bundle/config/resources/registered_model.go @@ -13,6 +13,7 @@ import ( type RegisteredModel struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` // This represents the input args for terraform, and will get converted // to a HCL representation for CRUD diff --git a/bundle/config/resources/schema.go b/bundle/config/resources/schema.go index 71f2b13f88..5b6f500151 100644 --- a/bundle/config/resources/schema.go +++ b/bundle/config/resources/schema.go @@ -16,6 +16,7 @@ import ( type Schema struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` catalog.CreateSchema // List of grants to apply on this schema. Grants []catalog.PrivilegeAssignment `json:"grants,omitempty"` diff --git a/bundle/config/resources/secret.go b/bundle/config/resources/secret.go index 5730fcac92..a6ce754037 100644 --- a/bundle/config/resources/secret.go +++ b/bundle/config/resources/secret.go @@ -15,6 +15,7 @@ import ( type Secret struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` catalog.Secret // List of grants to apply on this secret. diff --git a/bundle/config/resources/secret_scope.go b/bundle/config/resources/secret_scope.go index 73647d2802..abd34dff9b 100644 --- a/bundle/config/resources/secret_scope.go +++ b/bundle/config/resources/secret_scope.go @@ -39,6 +39,7 @@ type SecretScopePermission struct { type SecretScope struct { //nolint:recvcheck // pointer receiver needed for UnmarshalJSON, value for other methods BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` // A unique name to identify the secret scope. Name string `json:"name"` diff --git a/bundle/config/resources/sql_warehouses.go b/bundle/config/resources/sql_warehouses.go index a279c0054c..de0fda7de7 100644 --- a/bundle/config/resources/sql_warehouses.go +++ b/bundle/config/resources/sql_warehouses.go @@ -13,6 +13,7 @@ import ( type SqlWarehouse struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` sql.CreateWarehouseRequest // Lifecycle shadows BaseResource.Lifecycle to add support for lifecycle.started. diff --git a/bundle/config/resources/synced_database_table.go b/bundle/config/resources/synced_database_table.go index 18dcde00a7..b8839d99a5 100644 --- a/bundle/config/resources/synced_database_table.go +++ b/bundle/config/resources/synced_database_table.go @@ -14,6 +14,7 @@ import ( type SyncedDatabaseTable struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` database.SyncedDatabaseTable } diff --git a/bundle/config/resources/vector_search_endpoint.go b/bundle/config/resources/vector_search_endpoint.go index dffb75e95f..5404469bdf 100644 --- a/bundle/config/resources/vector_search_endpoint.go +++ b/bundle/config/resources/vector_search_endpoint.go @@ -14,6 +14,7 @@ import ( type VectorSearchEndpoint struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` vectorsearch.CreateEndpoint Permissions []VectorSearchEndpointPermission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/vector_search_index.go b/bundle/config/resources/vector_search_index.go index 4f82d51e2f..2c84337620 100644 --- a/bundle/config/resources/vector_search_index.go +++ b/bundle/config/resources/vector_search_index.go @@ -16,6 +16,7 @@ import ( type VectorSearchIndex struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` vectorsearch.CreateVectorIndexRequest // List of grants to apply on this vector search index. diff --git a/bundle/config/resources/volume.go b/bundle/config/resources/volume.go index 8377414b58..db321e9ceb 100644 --- a/bundle/config/resources/volume.go +++ b/bundle/config/resources/volume.go @@ -16,6 +16,7 @@ import ( type Volume struct { BaseResource + ID string `json:"id,omitempty" bundle:"readonly"` catalog.CreateVolumeRequestContent // VolumePath is /Volumes/{catalog}/{schema}/{name}. Populated during initialize; not user-configurable. diff --git a/bundle/deploy/metadata/compute_test.go b/bundle/deploy/metadata/compute_test.go index 75b307a273..e26f0a84a9 100644 --- a/bundle/deploy/metadata/compute_test.go +++ b/bundle/deploy/metadata/compute_test.go @@ -39,13 +39,15 @@ func TestComputeMetadataMutator(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "my-job-1": { - BaseResource: resources.BaseResource{ID: "1111"}, + ID: "1111", + JobSettings: jobs.JobSettings{ Name: "My Job One", }, }, "my-job-2": { - BaseResource: resources.BaseResource{ID: "2222"}, + ID: "2222", + JobSettings: jobs.JobSettings{ Name: "My Job Two", }, @@ -53,15 +55,15 @@ func TestComputeMetadataMutator(t *testing.T) { }, Pipelines: map[string]*resources.Pipeline{ "my-pipeline-1": { - ID: "3333", - BaseResource: resources.BaseResource{ID: "3333"}, + ID: "3333", + CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline One", }, }, "my-pipeline-2": { - ID: "4444", - BaseResource: resources.BaseResource{ID: "4444"}, + ID: "4444", + CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline Two", }, @@ -69,12 +71,14 @@ func TestComputeMetadataMutator(t *testing.T) { }, Dashboards: map[string]*resources.Dashboard{ "my-dashboard-1": { - BaseResource: resources.BaseResource{ID: "5555"}, + ID: "5555", + DashboardConfig: resources.DashboardConfig{}, FilePath: "i/h/g", }, "my-dashboard-2": { - BaseResource: resources.BaseResource{ID: "6666"}, + ID: "6666", + DashboardConfig: resources.DashboardConfig{}, FilePath: "l/k/j", }, @@ -158,18 +162,17 @@ func TestComputeMetadataMutatorStateOnlyResources(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "state-only-job": { - BaseResource: resources.BaseResource{ID: "1111"}, + ID: "1111", }, }, Pipelines: map[string]*resources.Pipeline{ "state-only-pipeline": { - ID: "2222", - BaseResource: resources.BaseResource{ID: "2222"}, + ID: "2222", }, }, Dashboards: map[string]*resources.Dashboard{ "state-only-dashboard": { - BaseResource: resources.BaseResource{ID: "3333"}, + ID: "3333", }, }, }, diff --git a/bundle/render/render_text_output_test.go b/bundle/render/render_text_output_test.go index 172cb4788e..2d8bba123e 100644 --- a/bundle/render/render_text_output_test.go +++ b/bundle/render/render_text_output_test.go @@ -299,30 +299,36 @@ func TestRenderSummary(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "job1": { - BaseResource: resources.BaseResource{ID: "1", URL: "https://url1"}, - JobSettings: jobs.JobSettings{Name: "job1-name"}, + ID: "1", + BaseResource: resources.BaseResource{URL: "https://url1"}, + + JobSettings: jobs.JobSettings{Name: "job1-name"}, }, "job2": { - BaseResource: resources.BaseResource{ID: "2", URL: "https://url2"}, - JobSettings: jobs.JobSettings{Name: "job2-name"}, + ID: "2", + BaseResource: resources.BaseResource{URL: "https://url2"}, + + JobSettings: jobs.JobSettings{Name: "job2-name"}, }, }, Pipelines: map[string]*resources.Pipeline{ "pipeline2": { - ID: "4", - BaseResource: resources.BaseResource{ID: "4"}, + ID: "4", + // no URL CreatePipeline: pipelines.CreatePipeline{Name: "pipeline2-name"}, }, "pipeline1": { - ID: "3", - BaseResource: resources.BaseResource{ID: "3", URL: "https://url3"}, + ID: "3", + BaseResource: resources.BaseResource{URL: "https://url3"}, + CreatePipeline: pipelines.CreatePipeline{Name: "pipeline1-name"}, }, }, Schemas: map[string]*resources.Schema{ "schema1": { - BaseResource: resources.BaseResource{ID: "catalog.schema"}, + ID: "catalog.schema", + CreateSchema: catalog.CreateSchema{ Name: "schema", }, @@ -331,7 +337,9 @@ func TestRenderSummary(t *testing.T) { }, ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{ "endpoint1": { - BaseResource: resources.BaseResource{ID: "7", URL: "https://url4"}, + ID: "7", + BaseResource: resources.BaseResource{URL: "https://url4"}, + CreateServingEndpoint: serving.CreateServingEndpoint{ Name: "my_serving_endpoint", }, diff --git a/bundle/run/job_test.go b/bundle/run/job_test.go index 4307f2fae7..e579444c80 100644 --- a/bundle/run/job_test.go +++ b/bundle/run/job_test.go @@ -57,7 +57,7 @@ func TestConvertPythonParams(t *testing.T) { func TestJobRunnerCancel(t *testing.T) { job := &resources.Job{ - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ Config: config.Root{ @@ -101,7 +101,7 @@ func TestJobRunnerCancel(t *testing.T) { func TestJobRunnerCancelWithNoActiveRuns(t *testing.T) { job := &resources.Job{ - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ Config: config.Root{ @@ -140,8 +140,9 @@ func TestJobRunnerRestart(t *testing.T) { }, } { job := &resources.Job{ - BaseResource: resources.BaseResource{ID: "123"}, - JobSettings: jobSettings, + ID: "123", + + JobSettings: jobSettings, } b := &bundle.Bundle{ Config: config.Root{ @@ -206,7 +207,7 @@ func TestJobRunnerRestart(t *testing.T) { func TestJobRunnerRunNoWaitGetRunFails(t *testing.T) { job := &resources.Job{ - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ Config: config.Root{ @@ -241,7 +242,8 @@ func TestJobRunnerRunNoWaitGetRunFails(t *testing.T) { func TestJobRunnerRestartForContinuousUnpausedJobs(t *testing.T) { job := &resources.Job{ - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", + JobSettings: jobs.JobSettings{ Continuous: &jobs.Continuous{ PauseStatus: jobs.PauseStatusUnpaused, diff --git a/bundle/run/pipeline_test.go b/bundle/run/pipeline_test.go index a24dba7d36..354216ee49 100644 --- a/bundle/run/pipeline_test.go +++ b/bundle/run/pipeline_test.go @@ -17,8 +17,7 @@ import ( func TestPipelineRunnerCancel(t *testing.T) { pipeline := &resources.Pipeline{ - ID: "123", - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ @@ -53,8 +52,7 @@ func TestPipelineRunnerCancel(t *testing.T) { func TestPipelineRunnerRestart(t *testing.T) { pipeline := &resources.Pipeline{ - ID: "123", - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ From 4108c28357abd5f4ffd775e32c981578eb0c643a Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 14:51:29 +0200 Subject: [PATCH 5/9] structaccess: fix bundle_test.go for BaseResource.ID removal libs/structs/structaccess/bundle_test.go still constructed a Job with BaseResource{ID: "jobid", URL: "joburl"}, which no longer compiles since ID was removed from BaseResource. Move ID to the Job struct directly. --- libs/structs/structaccess/bundle_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/structs/structaccess/bundle_test.go b/libs/structs/structaccess/bundle_test.go index 895ef9820d..4de21ede39 100644 --- a/libs/structs/structaccess/bundle_test.go +++ b/libs/structs/structaccess/bundle_test.go @@ -16,7 +16,8 @@ func TestGet_ConfigRoot_JobTagsAccess(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "my_job": { - BaseResource: resources.BaseResource{ID: "jobid", URL: "joburl"}, + ID: "jobid", + BaseResource: resources.BaseResource{URL: "joburl"}, JobSettings: jobs.JobSettings{ Name: "example", Tasks: []jobs.Task{ From 76aebfe19fbf5f1de0e34ccaf006c94c89903a70 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 15:50:46 +0200 Subject: [PATCH 6/9] bundle/config: clean up test formatting, simplify shadow guard, add ID tag test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove blank lines left over from the BaseResource{ID: "..."} replacement - Drop the knownSameDepthCollisions allowlist (it was empty; the map is gone) - Simplify TestNoSameDepthJSONShadows — no stale/known logic needed - Update sameDepthCollisions to skip collisions already shadowed by a depth-0 direct field: App.URL at depth 0 correctly shadows the same-depth collision between BaseResource.URL and apps.App.Url at depth 1, so encoding/json and structaccess both work correctly - Add TestResourceIDFieldTags: asserts every BaseResource-embedding resource type has ID declared directly at depth 0 with the right tags (json:"id,omitempty" bundle:"readonly") --- bundle/config/mutator/initialize_urls_test.go | 11 --- bundle/config/resources/base.go | 2 +- bundle/config/resources_types_test.go | 96 +++++++++++++++---- bundle/deploy/metadata/compute_test.go | 6 -- bundle/render/render_text_output_test.go | 1 - bundle/run/job_test.go | 2 - 6 files changed, 81 insertions(+), 37 deletions(-) diff --git a/bundle/config/mutator/initialize_urls_test.go b/bundle/config/mutator/initialize_urls_test.go index 5f1afe1b3e..ef8039556a 100644 --- a/bundle/config/mutator/initialize_urls_test.go +++ b/bundle/config/mutator/initialize_urls_test.go @@ -26,35 +26,30 @@ func TestInitializeURLs(t *testing.T) { Jobs: map[string]*resources.Job{ "job1": { ID: "1", - JobSettings: jobs.JobSettings{Name: "job1"}, }, }, Pipelines: map[string]*resources.Pipeline{ "pipeline1": { ID: "3", - CreatePipeline: pipelines.CreatePipeline{Name: "pipeline1"}, }, }, Experiments: map[string]*resources.MlflowExperiment{ "experiment1": { ID: "4", - CreateExperiment: ml.CreateExperiment{Name: "experiment1"}, }, }, Models: map[string]*resources.MlflowModel{ "model1": { ID: "a model uses its name for identifier", - CreateModelRequest: ml.CreateModelRequest{Name: "a model uses its name for identifier"}, }, }, ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{ "servingendpoint1": { ID: "my_serving_endpoint", - CreateServingEndpoint: serving.CreateServingEndpoint{ Name: "my_serving_endpoint", }, @@ -63,7 +58,6 @@ func TestInitializeURLs(t *testing.T) { RegisteredModels: map[string]*resources.RegisteredModel{ "registeredmodel1": { ID: "8", - CreateRegisteredModelRequest: catalog.CreateRegisteredModelRequest{ Name: "my_registered_model", }, @@ -78,7 +72,6 @@ func TestInitializeURLs(t *testing.T) { VectorSearchIndexes: map[string]*resources.VectorSearchIndex{ "vectorsearchindex1": { ID: "catalog.schema.vectorsearchindex1", - CreateVectorIndexRequest: vectorsearch.CreateVectorIndexRequest{ Name: "catalog.schema.vectorsearchindex1", }, @@ -87,7 +80,6 @@ func TestInitializeURLs(t *testing.T) { Schemas: map[string]*resources.Schema{ "schema1": { ID: "catalog.schema", - CreateSchema: catalog.CreateSchema{ Name: "schema", }, @@ -96,7 +88,6 @@ func TestInitializeURLs(t *testing.T) { Clusters: map[string]*resources.Cluster{ "cluster1": { ID: "1017-103929-vlr7jzcf", - ClusterSpec: compute.ClusterSpec{ ClusterName: "cluster1", }, @@ -105,7 +96,6 @@ func TestInitializeURLs(t *testing.T) { Dashboards: map[string]*resources.Dashboard{ "dashboard1": { ID: "01ef8d56871e1d50ae30ce7375e42478", - DashboardConfig: resources.DashboardConfig{ DisplayName: "My special dashboard", }, @@ -147,7 +137,6 @@ func TestInitializeURLsWithoutOrgId(t *testing.T) { Jobs: map[string]*resources.Job{ "job1": { ID: "1", - JobSettings: jobs.JobSettings{Name: "job1"}, }, }, diff --git a/bundle/config/resources/base.go b/bundle/config/resources/base.go index 86b8f8dbda..03406bfa84 100644 --- a/bundle/config/resources/base.go +++ b/bundle/config/resources/base.go @@ -2,8 +2,8 @@ package resources // BaseResource is a struct that contains the base settings for a resource. type BaseResource struct { - ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` URL string `json:"url,omitempty" bundle:"internal"` + ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` Lifecycle Lifecycle `json:"lifecycle,omitempty"` } diff --git a/bundle/config/resources_types_test.go b/bundle/config/resources_types_test.go index 61c3cb4cc3..764dafc813 100644 --- a/bundle/config/resources_types_test.go +++ b/bundle/config/resources_types_test.go @@ -131,9 +131,10 @@ var knownSameDepthCollisions = map[string][]string{ func TestNoSameDepthJSONShadows(t *testing.T) { rt := reflect.TypeFor[Resources]() - var newCollisions []string + var collisions []string - for f := range rt.Fields() { + for i := range rt.NumField() { + f := rt.Field(i) et := f.Type.Elem() for et.Kind() == reflect.Pointer { et = et.Elem() @@ -142,29 +143,28 @@ func TestNoSameDepthJSONShadows(t *testing.T) { continue } group := structtag.JSONTag(f.Tag.Get("json")).Name() - for _, c := range sameDepthCollisions(et) { - known := slices.Contains(knownSameDepthCollisions[group], c.name) - if !known { - newCollisions = append(newCollisions, - fmt.Sprintf("%s (%s): json name %q declared by %s and %s at the same embedding depth", - group, et, c.name, c.typeA, c.typeB)) - } + collisions = append(collisions, + fmt.Sprintf("%s: json name %q declared by %s and %s at the same embedding depth", + group, c.name, c.typeA, c.typeB)) } } - assert.Empty(t, newCollisions, - "NEW same-depth json name collisions found — encoding/json calls these ambiguous "+ - "and serializes neither; structaccess cannot read or write them either. "+ - "Fix by adding an explicit depth-0 field on the resource struct, or add to knownSameDepthCollisions.") + assert.Empty(t, collisions, + "same-depth json name collisions found — encoding/json calls these ambiguous "+ + "and serializes neither; structaccess cannot read or write them either") } type collision struct { name, typeA, typeB string } -// sameDepthCollisions returns the json names declared at the same embedding -// depth by two or more anonymous embedded structs inside t. +// sameDepthCollisions returns json names declared at the same embedding depth +// by two or more anonymous embedded structs that are NOT already shadowed by a +// direct field on t itself. A same-depth collision is only a problem when there +// is no depth-0 field that resolves the ambiguity; if one exists (e.g. App.URL +// at depth 0 shadows both BaseResource.URL and apps.App.Url at depth 1), +// encoding/json and structaccess both use the depth-0 field correctly. func sameDepthCollisions(t reflect.Type) []collision { for t.Kind() == reflect.Pointer { t = t.Elem() @@ -173,6 +173,22 @@ func sameDepthCollisions(t reflect.Type) []collision { return nil } + // Depth-0 direct fields shadow any same-depth collision at deeper levels. + depth0 := map[string]bool{} + for i := range t.NumField() { + sf := t.Field(i) + if sf.PkgPath != "" || sf.Anonymous || sf.Name == "ForceSendFields" { + continue + } + name := structtag.JSONTag(sf.Tag.Get("json")).Name() + if name == "" { + name = sf.Name + } + if name != "-" { + depth0[name] = true + } + } + var result []collision seen := map[reflect.Type]bool{t: true} level := embeddedTypes(t) @@ -195,7 +211,7 @@ func sameDepthCollisions(t reflect.Type) []collision { } } for name, types := range nameToTypes { - if len(types) > 1 { + if len(types) > 1 && !depth0[name] { result = append(result, collision{name: name, typeA: types[0], typeB: types[1]}) } } @@ -230,3 +246,51 @@ func embeddedTypes(t reflect.Type) []reflect.Type { } return out } + +// TestResourceIDFieldTags asserts that every resource type exposes the bundle +// tracking ID with exactly the right json and bundle tags. The field must be +// json:"id,omitempty" (so it round-trips through the bundle state file) and +// bundle:"readonly" (so users can reference ${resources..id} but cannot +// set it). It must be a direct depth-0 field, not promoted from BaseResource +// or an SDK embed, to avoid same-depth collisions. +func TestResourceIDFieldTags(t *testing.T) { + rt := reflect.TypeFor[Resources]() + for i := range rt.NumField() { + f := rt.Field(i) + et := f.Type.Elem() + for et.Kind() == reflect.Pointer { + et = et.Elem() + } + if et.Kind() != reflect.Struct { + continue + } + group := structtag.JSONTag(f.Tag.Get("json")).Name() + + // Skip resource types that don't embed BaseResource (internal infra types + // like Snapshot have no user-facing ID). + hasBaseResource := false + for j := range et.NumField() { + if et.Field(j).Anonymous && et.Field(j).Type.Name() == "BaseResource" { + hasBaseResource = true + break + } + } + if !hasBaseResource { + continue + } + + t.Run(group, func(t *testing.T) { + // The ID field must be declared directly on the resource struct, + // not promoted from an anonymous embed. + sf, ok := et.FieldByName("ID") + require.True(t, ok, "%s must have a direct ID field", group) + assert.False(t, sf.Anonymous, "%s.ID must not be anonymous", group) + assert.Empty(t, sf.Index[1:], "%s.ID must be at depth 0 (got index %v)", group, sf.Index) + + assert.Equal(t, "id,omitempty", sf.Tag.Get("json"), + "%s.ID json tag must be \"id,omitempty\"", group) + assert.Equal(t, "readonly", sf.Tag.Get("bundle"), + "%s.ID bundle tag must be \"readonly\"", group) + }) + } +} diff --git a/bundle/deploy/metadata/compute_test.go b/bundle/deploy/metadata/compute_test.go index e26f0a84a9..6262ced8da 100644 --- a/bundle/deploy/metadata/compute_test.go +++ b/bundle/deploy/metadata/compute_test.go @@ -40,14 +40,12 @@ func TestComputeMetadataMutator(t *testing.T) { Jobs: map[string]*resources.Job{ "my-job-1": { ID: "1111", - JobSettings: jobs.JobSettings{ Name: "My Job One", }, }, "my-job-2": { ID: "2222", - JobSettings: jobs.JobSettings{ Name: "My Job Two", }, @@ -56,14 +54,12 @@ func TestComputeMetadataMutator(t *testing.T) { Pipelines: map[string]*resources.Pipeline{ "my-pipeline-1": { ID: "3333", - CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline One", }, }, "my-pipeline-2": { ID: "4444", - CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline Two", }, @@ -72,13 +68,11 @@ func TestComputeMetadataMutator(t *testing.T) { Dashboards: map[string]*resources.Dashboard{ "my-dashboard-1": { ID: "5555", - DashboardConfig: resources.DashboardConfig{}, FilePath: "i/h/g", }, "my-dashboard-2": { ID: "6666", - DashboardConfig: resources.DashboardConfig{}, FilePath: "l/k/j", }, diff --git a/bundle/render/render_text_output_test.go b/bundle/render/render_text_output_test.go index 2d8bba123e..80c5eb417c 100644 --- a/bundle/render/render_text_output_test.go +++ b/bundle/render/render_text_output_test.go @@ -328,7 +328,6 @@ func TestRenderSummary(t *testing.T) { Schemas: map[string]*resources.Schema{ "schema1": { ID: "catalog.schema", - CreateSchema: catalog.CreateSchema{ Name: "schema", }, diff --git a/bundle/run/job_test.go b/bundle/run/job_test.go index e579444c80..4c891ef51f 100644 --- a/bundle/run/job_test.go +++ b/bundle/run/job_test.go @@ -141,7 +141,6 @@ func TestJobRunnerRestart(t *testing.T) { } { job := &resources.Job{ ID: "123", - JobSettings: jobSettings, } b := &bundle.Bundle{ @@ -243,7 +242,6 @@ func TestJobRunnerRunNoWaitGetRunFails(t *testing.T) { func TestJobRunnerRestartForContinuousUnpausedJobs(t *testing.T) { job := &resources.Job{ ID: "123", - JobSettings: jobs.JobSettings{ Continuous: &jobs.Continuous{ PauseStatus: jobs.PauseStatusUnpaused, From 9b0637f323ee5f10ca182adbd6f77bdf8d142fb7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 16:39:35 +0200 Subject: [PATCH 7/9] bundle/config: fix lint issues in resources_types_test.go - Remove the stale knownSameDepthCollisions var - Convert NumField/Field loops to Type.Fields() iteration (modernize linter) - gofmt three test files that had stale blank lines --- bundle/config/mutator/initialize_urls_test.go | 10 +++---- bundle/config/resources_types_test.go | 28 ++----------------- bundle/deploy/metadata/compute_test.go | 4 +-- bundle/run/job_test.go | 2 +- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/bundle/config/mutator/initialize_urls_test.go b/bundle/config/mutator/initialize_urls_test.go index ef8039556a..16e64b6c42 100644 --- a/bundle/config/mutator/initialize_urls_test.go +++ b/bundle/config/mutator/initialize_urls_test.go @@ -25,25 +25,25 @@ func TestInitializeURLs(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "job1": { - ID: "1", + ID: "1", JobSettings: jobs.JobSettings{Name: "job1"}, }, }, Pipelines: map[string]*resources.Pipeline{ "pipeline1": { - ID: "3", + ID: "3", CreatePipeline: pipelines.CreatePipeline{Name: "pipeline1"}, }, }, Experiments: map[string]*resources.MlflowExperiment{ "experiment1": { - ID: "4", + ID: "4", CreateExperiment: ml.CreateExperiment{Name: "experiment1"}, }, }, Models: map[string]*resources.MlflowModel{ "model1": { - ID: "a model uses its name for identifier", + ID: "a model uses its name for identifier", CreateModelRequest: ml.CreateModelRequest{Name: "a model uses its name for identifier"}, }, }, @@ -136,7 +136,7 @@ func TestInitializeURLsWithoutOrgId(t *testing.T) { Resources: config.Resources{ Jobs: map[string]*resources.Job{ "job1": { - ID: "1", + ID: "1", JobSettings: jobs.JobSettings{Name: "job1"}, }, }, diff --git a/bundle/config/resources_types_test.go b/bundle/config/resources_types_test.go index 764dafc813..a09c43ac92 100644 --- a/bundle/config/resources_types_test.go +++ b/bundle/config/resources_types_test.go @@ -110,31 +110,11 @@ func zeroValueScalars(t reflect.Type, depth int, seen map[reflect.Type]bool) dyn return dyn.V(m) } -// knownSameDepthCollisions lists the existing same-depth json name collisions. -// These are bugs — encoding/json calls the name ambiguous and neither field is -// reachable — but fixing them requires a breaking change to the bundle YAML -// format, so they are listed here rather than silently tolerated. Every entry -// must describe where the collision comes from; fixing one removes its entry. -// -// Fix: add an explicit depth-0 field on the resource type that shadows both -// embedded declarations (e.g. `ID string \`json:"id,omitempty" bundle:"readonly"\“ -// directly on the resource struct). -var knownSameDepthCollisions = map[string][]string{ - // BaseResource.ID and pipelines.CreatePipeline.Id both carry json:"id". - "pipelines": {"id"}, - // BaseResource.ID and apps.App.Id carry json:"id"; - // BaseResource.URL and apps.App.Url carry json:"url". - "apps": {"id", "url"}, - // BaseResource.ID and sql.AlertV2.Id carry json:"id". - "alerts": {"id"}, -} - func TestNoSameDepthJSONShadows(t *testing.T) { rt := reflect.TypeFor[Resources]() var collisions []string - for i := range rt.NumField() { - f := rt.Field(i) + for f := range rt.Fields() { et := f.Type.Elem() for et.Kind() == reflect.Pointer { et = et.Elem() @@ -175,8 +155,7 @@ func sameDepthCollisions(t reflect.Type) []collision { // Depth-0 direct fields shadow any same-depth collision at deeper levels. depth0 := map[string]bool{} - for i := range t.NumField() { - sf := t.Field(i) + for sf := range t.Fields() { if sf.PkgPath != "" || sf.Anonymous || sf.Name == "ForceSendFields" { continue } @@ -255,8 +234,7 @@ func embeddedTypes(t reflect.Type) []reflect.Type { // or an SDK embed, to avoid same-depth collisions. func TestResourceIDFieldTags(t *testing.T) { rt := reflect.TypeFor[Resources]() - for i := range rt.NumField() { - f := rt.Field(i) + for f := range rt.Fields() { et := f.Type.Elem() for et.Kind() == reflect.Pointer { et = et.Elem() diff --git a/bundle/deploy/metadata/compute_test.go b/bundle/deploy/metadata/compute_test.go index 6262ced8da..58b8b390d3 100644 --- a/bundle/deploy/metadata/compute_test.go +++ b/bundle/deploy/metadata/compute_test.go @@ -67,12 +67,12 @@ func TestComputeMetadataMutator(t *testing.T) { }, Dashboards: map[string]*resources.Dashboard{ "my-dashboard-1": { - ID: "5555", + ID: "5555", DashboardConfig: resources.DashboardConfig{}, FilePath: "i/h/g", }, "my-dashboard-2": { - ID: "6666", + ID: "6666", DashboardConfig: resources.DashboardConfig{}, FilePath: "l/k/j", }, diff --git a/bundle/run/job_test.go b/bundle/run/job_test.go index 4c891ef51f..f1ef38b4be 100644 --- a/bundle/run/job_test.go +++ b/bundle/run/job_test.go @@ -140,7 +140,7 @@ func TestJobRunnerRestart(t *testing.T) { }, } { job := &resources.Job{ - ID: "123", + ID: "123", JobSettings: jobSettings, } b := &bundle.Bundle{ From bc1f1ea0b103d840998c327ea981c4eaaaad1cf7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 17:17:11 +0200 Subject: [PATCH 8/9] bundle/config: fix remaining lint issues - Fix last NumField/Field loop in TestResourceIDFieldTags - Remove stale shadow comments from pipeline.go, apps.go, alerts.go (BaseResource no longer has ID, so the "shadows" description is outdated) --- bundle/config/resources/alerts.go | 6 ++---- bundle/config/resources/apps.go | 8 +++----- bundle/config/resources/pipeline.go | 7 ++----- bundle/config/resources_types_test.go | 4 ++-- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/bundle/config/resources/alerts.go b/bundle/config/resources/alerts.go index 73808f0445..4416873d52 100644 --- a/bundle/config/resources/alerts.go +++ b/bundle/config/resources/alerts.go @@ -13,10 +13,8 @@ import ( type Alert struct { BaseResource - sql.AlertV2 //nolint:govet // AlertV2.Id and our depth-0 ID field both carry json:"id"; the depth-0 field wins - // ID shadows the same-depth collision between BaseResource.ID and - // AlertV2.Id — both embed json:"id" at depth 1. - ID string `json:"id,omitempty" bundle:"readonly"` + sql.AlertV2 //nolint:govet // AlertV2.Id and our depth-0 ID field both carry json:"id"; the depth-0 field wins + ID string `json:"id,omitempty" bundle:"readonly"` Permissions []Permission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/apps.go b/bundle/config/resources/apps.go index c1d5470469..76acbb29a9 100644 --- a/bundle/config/resources/apps.go +++ b/bundle/config/resources/apps.go @@ -35,11 +35,9 @@ type AppEnvVar struct { type App struct { BaseResource - apps.App //nolint:govet // apps.App.{Id,Url} and our depth-0 {ID,URL} fields carry the same json names; the depth-0 fields win - // ID and URL shadow the same-depth collisions between BaseResource.{ID,URL} - // and apps.App.{Id,Url} — both embed json:"id"/"url" at depth 1. - ID string `json:"id,omitempty" bundle:"readonly"` - URL string `json:"url,omitempty" bundle:"internal"` + apps.App //nolint:govet // apps.App.{Id,Url} and our depth-0 {ID,URL} fields carry the same json names; the depth-0 fields win + ID string `json:"id,omitempty" bundle:"readonly"` + URL string `json:"url,omitempty" bundle:"internal"` // Note: apps.App already includes GitRepository field from the SDK diff --git a/bundle/config/resources/pipeline.go b/bundle/config/resources/pipeline.go index dbfd58a5b7..fd1fc61ce8 100644 --- a/bundle/config/resources/pipeline.go +++ b/bundle/config/resources/pipeline.go @@ -13,11 +13,8 @@ import ( type Pipeline struct { BaseResource - pipelines.CreatePipeline //nolint:govet // CreatePipeline.Id and our depth-0 ID field both carry json:"id"; the depth-0 field wins - // ID shadows the same-depth collision between BaseResource.ID and - // CreatePipeline.Id — both embed json:"id" at depth 1. Declaring it - // here at depth 0 makes the field reachable and unambiguous. - ID string `json:"id,omitempty" bundle:"readonly"` + pipelines.CreatePipeline //nolint:govet // CreatePipeline.Id and our depth-0 ID field both carry json:"id"; the depth-0 field wins + ID string `json:"id,omitempty" bundle:"readonly"` Permissions []PipelinePermission `json:"permissions,omitempty"` diff --git a/bundle/config/resources_types_test.go b/bundle/config/resources_types_test.go index a09c43ac92..5005a8989a 100644 --- a/bundle/config/resources_types_test.go +++ b/bundle/config/resources_types_test.go @@ -247,8 +247,8 @@ func TestResourceIDFieldTags(t *testing.T) { // Skip resource types that don't embed BaseResource (internal infra types // like Snapshot have no user-facing ID). hasBaseResource := false - for j := range et.NumField() { - if et.Field(j).Anonymous && et.Field(j).Type.Name() == "BaseResource" { + for __sf := range et.Fields() { + if __sf.Anonymous && __sf.Type.Name() == "BaseResource" { hasBaseResource = true break } From 386d03b2182cf646ee10a152a28bcbe6a42ee318 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 14 Sep 2026 17:18:55 +0200 Subject: [PATCH 9/9] bundle/config: explicit exception in TestResourceIDFieldTags Replace the structural hasBaseResource check with a named constant for the one resource group that has no user-facing ID (internal_immutable_snapshots). Explicit is better than implicit: a new resource type that somehow lacks BaseResource but still needs an ID will now fail the test rather than silently passing. --- bundle/config/resources_types_test.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/bundle/config/resources_types_test.go b/bundle/config/resources_types_test.go index 5005a8989a..bf2f23a54a 100644 --- a/bundle/config/resources_types_test.go +++ b/bundle/config/resources_types_test.go @@ -244,16 +244,11 @@ func TestResourceIDFieldTags(t *testing.T) { } group := structtag.JSONTag(f.Tag.Get("json")).Name() - // Skip resource types that don't embed BaseResource (internal infra types - // like Snapshot have no user-facing ID). - hasBaseResource := false - for __sf := range et.Fields() { - if __sf.Anonymous && __sf.Type.Name() == "BaseResource" { - hasBaseResource = true - break - } - } - if !hasBaseResource { + // Snapshot is an internal infrastructure type with no user-facing ID. + // Add entries here only for resource types that genuinely have no + // deployment-tracking ID; every other resource must pass the tag checks. + const noIDField = "internal_immutable_snapshots" + if group == noIDField { continue }