diff --git a/bundle/config/mutator/initialize_urls_test.go b/bundle/config/mutator/initialize_urls_test.go index 980c4d88514..16e64b6c420 100644 --- a/bundle/config/mutator/initialize_urls_test.go +++ b/bundle/config/mutator/initialize_urls_test.go @@ -25,31 +25,31 @@ 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": { - 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", }, @@ -57,7 +57,7 @@ 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", }, @@ -71,7 +71,7 @@ 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", }, @@ -79,7 +79,7 @@ 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", }, @@ -87,7 +87,7 @@ 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", }, @@ -95,7 +95,7 @@ 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", }, @@ -136,8 +136,8 @@ 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/alerts.go b/bundle/config/resources/alerts.go index cf3119a6f9c..4416873d526 100644 --- a/bundle/config/resources/alerts.go +++ b/bundle/config/resources/alerts.go @@ -13,7 +13,8 @@ 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 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 f1d62bcc893..76acbb29a96 100644 --- a/bundle/config/resources/apps.go +++ b/bundle/config/resources/apps.go @@ -35,7 +35,10 @@ 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 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/base.go b/bundle/config/resources/base.go index 6a27d2b6e47..03406bfa843 100644 --- a/bundle/config/resources/base.go +++ b/bundle/config/resources/base.go @@ -2,9 +2,8 @@ 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"` + ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` Lifecycle Lifecycle `json:"lifecycle,omitempty"` } diff --git a/bundle/config/resources/catalog.go b/bundle/config/resources/catalog.go index 8c5f6dcb853..3fc4a5c89dc 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 f8f4a0f7170..8315c5b8b90 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 9de7b62c526..aec5e886346 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 692fc62b61b..16b3229ed16 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 ddae1ea1892..c1c0b86bd24 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 661ced94996..5d4abf5a27a 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 19ce4702892..3d60fa01a58 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 09179f509a0..81c9a75e166 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 5f0eda4a172..5d62c511791 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 8b7428e8fb8..ff9a91a8899 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 55b1bde4f23..965a3ca55ff 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 eb2adf01583..f7bbcd96e7a 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 6f98ffbefb9..d6f94624e4b 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 e23d3d7b682..517ea7fb131 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/pipeline.go b/bundle/config/resources/pipeline.go index 80c72e62fc9..fd1fc61ce86 100644 --- a/bundle/config/resources/pipeline.go +++ b/bundle/config/resources/pipeline.go @@ -13,7 +13,8 @@ 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 string `json:"id,omitempty" bundle:"readonly"` Permissions []PipelinePermission `json:"permissions,omitempty"` diff --git a/bundle/config/resources/postgres_branch.go b/bundle/config/resources/postgres_branch.go index 077dc83acf9..a248667a87e 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 65c0f97112c..51246f06af0 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 62162b6d761..efbe5984d5e 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 abc2dc2c2de..0947045a719 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 4b021737c97..0cdfbbcc2af 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 cad5d86dba9..7cc30a3ee0a 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 a13b78cef82..d07ac597f31 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 d377e6cc1cd..3aac2578d70 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 dd6143aa4a4..5548ef4d7da 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 85288bee629..eedb33ef3d3 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 71f2b13f887..5b6f5001516 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 5730fcac92f..a6ce7540374 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 73647d2802a..abd34dff9b2 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 a279c0054cf..de0fda7de74 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 18dcde00a74..b8839d99a5e 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 dffb75e95f7..5404469bdfd 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 4f82d51e2f8..2c843376209 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 8377414b588..db321e9ceb2 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/config/resources_types_test.go b/bundle/config/resources_types_test.go index 4dd0b18d26a..bf2f23a54a0 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,161 @@ func zeroValueScalars(t reflect.Type, depth int, seen map[reflect.Type]bool) dyn } return dyn.V(m) } + +func TestNoSameDepthJSONShadows(t *testing.T) { + rt := reflect.TypeFor[Resources]() + var collisions []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) { + 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, 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 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() + } + if t.Kind() != reflect.Struct { + return nil + } + + // Depth-0 direct fields shadow any same-depth collision at deeper levels. + depth0 := map[string]bool{} + for sf := range t.Fields() { + 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) + + 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 && !depth0[name] { + 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 +} + +// 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 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() + + // 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 + } + + 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 834a81a9358..58b8b390d35 100644 --- a/bundle/deploy/metadata/compute_test.go +++ b/bundle/deploy/metadata/compute_test.go @@ -39,13 +39,13 @@ 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,13 +53,13 @@ func TestComputeMetadataMutator(t *testing.T) { }, Pipelines: map[string]*resources.Pipeline{ "my-pipeline-1": { - BaseResource: resources.BaseResource{ID: "3333"}, + ID: "3333", CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline One", }, }, "my-pipeline-2": { - BaseResource: resources.BaseResource{ID: "4444"}, + ID: "4444", CreatePipeline: pipelines.CreatePipeline{ Name: "My Pipeline Two", }, @@ -67,12 +67,12 @@ 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", }, @@ -156,17 +156,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": { - 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 192a95c108b..80c5eb417c5 100644 --- a/bundle/render/render_text_output_test.go +++ b/bundle/render/render_text_output_test.go @@ -299,28 +299,35 @@ 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": { - BaseResource: resources.BaseResource{ID: "4"}, + ID: "4", + // no URL CreatePipeline: pipelines.CreatePipeline{Name: "pipeline2-name"}, }, "pipeline1": { - 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", }, @@ -329,7 +336,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 4307f2fae74..f1ef38b4be8 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,8 @@ 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 +206,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 +241,7 @@ 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 56457218468..354216ee490 100644 --- a/bundle/run/pipeline_test.go +++ b/bundle/run/pipeline_test.go @@ -17,7 +17,7 @@ import ( func TestPipelineRunnerCancel(t *testing.T) { pipeline := &resources.Pipeline{ - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ @@ -52,7 +52,7 @@ func TestPipelineRunnerCancel(t *testing.T) { func TestPipelineRunnerRestart(t *testing.T) { pipeline := &resources.Pipeline{ - BaseResource: resources.BaseResource{ID: "123"}, + ID: "123", } b := &bundle.Bundle{ diff --git a/libs/structs/structaccess/bundle_test.go b/libs/structs/structaccess/bundle_test.go index 895ef9820db..4de21ede392 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{