diff --git a/acceptance/bundle/migrate/dashboards/out.plan_after_migrate.json b/acceptance/bundle/migrate/dashboards/out.plan_after_migrate.json index 6b55f64bd8d..916ffa79e83 100644 --- a/acceptance/bundle/migrate/dashboards/out.plan_after_migrate.json +++ b/acceptance/bundle/migrate/dashboards/out.plan_after_migrate.json @@ -21,18 +21,43 @@ "warehouse_id": "123456" }, "changes": { + "create_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" + }, + "dashboard_id": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[DASHBOARD1_ID]" + }, "etag": { "action": "skip", "reason": "custom", "old": "[NUMID]", "remote": "[NUMID]" }, + "lifecycle_state": { + "action": "skip", + "reason": "spec:output_only", + "remote": "ACTIVE" + }, + "path": { + "action": "skip", + "reason": "spec:output_only", + "remote": "/Users/[USERNAME]/my dashboard.lvdash.json" + }, "serialized_dashboard": { "action": "skip", "reason": "etag_based", "old": "{\"pages\":[{\"name\":\"02724bf2\",\"displayName\":\"Dashboard test bundle-deploy-dashboard\"}]}\n", "new": "{\"pages\":[{\"name\":\"02724bf2\",\"displayName\":\"Dashboard test bundle-deploy-dashboard\"}]}\n", "remote": "{\"pages\":[{\"displayName\":\"Dashboard test bundle-deploy-dashboard\",\"name\":\"02724bf2\",\"pageType\":\"PAGE_TYPE_CANVAS\"}]}\n" + }, + "update_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" } } } diff --git a/acceptance/bundle/resources/dashboards/dataset-catalog-schema/out.plan.direct.json b/acceptance/bundle/resources/dashboards/dataset-catalog-schema/out.plan.direct.json index 96ec1597f9e..d7e628091b1 100644 --- a/acceptance/bundle/resources/dashboards/dataset-catalog-schema/out.plan.direct.json +++ b/acceptance/bundle/resources/dashboards/dataset-catalog-schema/out.plan.direct.json @@ -21,6 +21,16 @@ "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" }, "changes": { + "create_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" + }, + "dashboard_id": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[DASHBOARD_ID]" + }, "dataset_catalog": { "action": "skip", "reason": "input_only", @@ -39,10 +49,25 @@ "old": "[ETAG]", "remote": "[ETAG]" }, + "lifecycle_state": { + "action": "skip", + "reason": "spec:output_only", + "remote": "ACTIVE" + }, + "path": { + "action": "skip", + "reason": "spec:output_only", + "remote": "/Users/[USERNAME]/test bundle-deploy-dashboard-dataset [UUID].lvdash.json" + }, "serialized_dashboard": { "action": "skip", "old": "[SERIALIZED_FIXTURE_OLD]", "new": "[SERIALIZED_FIXTURE_NEW]" + }, + "update_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" } } } diff --git a/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json b/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json index b113b73d33a..e444b79716f 100644 --- a/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json +++ b/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json @@ -31,17 +31,42 @@ "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" }, "changes": { + "create_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" + }, + "dashboard_id": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[DASHBOARD_ID]" + }, "etag": { "action": "update", "old": "[ETAG_1]", "remote": "[ETAG_2]" }, + "lifecycle_state": { + "action": "skip", + "reason": "spec:output_only", + "remote": "ACTIVE" + }, + "path": { + "action": "skip", + "reason": "spec:output_only", + "remote": "/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/resources/test-dashboard-[UNIQUE_NAME].lvdash.json" + }, "serialized_dashboard": { "action": "skip", "reason": "etag_based", "old": "{\n \"pages\": [\n {\n \"displayName\": \"New Page\",\n \"layout\": [\n {\n \"position\": {\n \"height\": 2,\n \"width\": 6,\n \"x\": 0,\n \"y\": 0\n },\n \"widget\": {\n \"name\": \"82eb9107\",\n \"textbox_spec\": \"# I'm a title\"\n }\n },\n {\n \"position\": {\n \"height\": 2,\n \"width\": 6,\n \"x\": 0,\n \"y\": 2\n },\n \"widget\": {\n \"name\": \"ffa6de4f\",\n \"textbox_spec\": \"Text\"\n }\n }\n ],\n \"name\": \"fdd21a3c\"\n }\n ]\n}\n", "new": "{\n \"pages\": [\n {\n \"displayName\": \"New Page\",\n \"layout\": [\n {\n \"position\": {\n \"height\": 2,\n \"width\": 6,\n \"x\": 0,\n \"y\": 0\n },\n \"widget\": {\n \"name\": \"82eb9107\",\n \"textbox_spec\": \"# I'm a title\"\n }\n },\n {\n \"position\": {\n \"height\": 2,\n \"width\": 6,\n \"x\": 0,\n \"y\": 2\n },\n \"widget\": {\n \"name\": \"ffa6de4f\",\n \"textbox_spec\": \"Text\"\n }\n }\n ],\n \"name\": \"fdd21a3c\"\n }\n ]\n}\n", "remote": "{}\n" + }, + "update_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" } } } diff --git a/acceptance/bundle/resources/dashboards/simple/out.plan.direct.json b/acceptance/bundle/resources/dashboards/simple/out.plan.direct.json index f0e8fb6f047..9f1aa53885b 100644 --- a/acceptance/bundle/resources/dashboards/simple/out.plan.direct.json +++ b/acceptance/bundle/resources/dashboards/simple/out.plan.direct.json @@ -21,18 +21,43 @@ "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" }, "changes": { + "create_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" + }, + "dashboard_id": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[DASHBOARD_ID]" + }, "etag": { "action": "skip", "reason": "custom", "old": "[ETAG]", "remote": "[ETAG]" }, + "lifecycle_state": { + "action": "skip", + "reason": "spec:output_only", + "remote": "ACTIVE" + }, + "path": { + "action": "skip", + "reason": "spec:output_only", + "remote": "/Users/[USERNAME]/test bundle-deploy-dashboard [UUID].lvdash.json" + }, "serialized_dashboard": { "action": "skip", "reason": "etag_based", "old": "{ }\n", "new": "{ }\n", "remote": "{}\n" + }, + "update_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" } } } diff --git a/acceptance/bundle/resources/dashboards/unpublish-out-of-band/out.plan.direct.json b/acceptance/bundle/resources/dashboards/unpublish-out-of-band/out.plan.direct.json index 7b3761ca39e..38ec178472f 100644 --- a/acceptance/bundle/resources/dashboards/unpublish-out-of-band/out.plan.direct.json +++ b/acceptance/bundle/resources/dashboards/unpublish-out-of-band/out.plan.direct.json @@ -31,12 +31,32 @@ "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" }, "changes": { + "create_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" + }, + "dashboard_id": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[DASHBOARD1_ID]" + }, "etag": { "action": "skip", "reason": "custom", "old": "[ETAG]", "remote": "[ETAG]" }, + "lifecycle_state": { + "action": "skip", + "reason": "spec:output_only", + "remote": "ACTIVE" + }, + "path": { + "action": "skip", + "reason": "spec:output_only", + "remote": "/Users/[USERNAME]/.bundle/unpublish-out-of-band-[UNIQUE_NAME]/default/resources/test bundle-deploy-dashboard [UNIQUE_NAME].lvdash.json" + }, "published": { "action": "update", "old": true, @@ -49,6 +69,11 @@ "old": "{\"pages\":[{\"displayName\":\"Test Dashboard\",\"name\":\"test-page\"}]}", "new": "{\"pages\":[{\"displayName\":\"Test Dashboard\",\"name\":\"test-page\"}]}", "remote": "{\"pages\":[{\"displayName\":\"Test Dashboard\",\"name\":\"test-page\",\"pageType\":\"PAGE_TYPE_CANVAS\"}]}" + }, + "update_time": { + "action": "skip", + "reason": "spec:output_only", + "remote": "[TIMESTAMP]" } } } diff --git a/bundle/direct/dresources/README.md b/bundle/direct/dresources/README.md index 30a2de3cd5d..0425e52ec52 100644 --- a/bundle/direct/dresources/README.md +++ b/bundle/direct/dresources/README.md @@ -18,7 +18,7 @@ Each field with special plan/deploy behavior must be declared in `resources.yml`. Choose the right category: - **`backend_defaults`**: The backend may fill in a value when the user doesn't specify one. Suppresses the diff when the user's config is nil/empty but remote has a value. Optionally restrict to specific allowed remote values via `values:`. Use for fields the API fills in as defaults (e.g., `format`, `run_if`, `node_type_id`). Link to TF provider suppression comment in the same format as existing entries. - - **`ignore_remote_changes`**: Ignore changes the remote makes to this field. Use for fields the backend manages (e.g., cloud-provider attributes like `aws_attributes`, `gcp_attributes`) or fields not returned by the update endpoint. Reason codes: + - **`ignore_remote_changes`**: Ignore changes the remote makes to this field. Use for fields the backend manages (e.g., cloud-provider attributes like `aws_attributes`, `gcp_attributes`) or fields not returned by the update endpoint. Do not zero out such fields in `RemapState` to hide them from diff computation: carry the real remote value through and declare the field here instead, since zeroing discards information and duplicates the suppression logic. For `output_only` fields this rule is often already produced by `resources.generated.yml` from the OpenAPI annotation. Reason codes: - `output_only` — the field is computed by the backend; the user never sets it - `input_only` — accepted on create/update but not returned by GET (e.g., write-only tokens, flags) - `managed` — managed by the cloud provider or platform, not by the user config diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 06cbca8a933..bb3b3921a43 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -95,12 +95,13 @@ func (r *ResourceDashboard) RemapState(state *DashboardState) *DashboardState { ForceSendFields: forceSendFields, - // Clear output only fields. They should not show up on remote diff computation. - CreateTime: "", - DashboardId: "", - LifecycleState: dashboards.LifecycleState(""), - Path: "", - UpdateTime: "", + // Output only fields. Remote changes to these are ignored via + // ignore_remote_changes in resources.yml rather than zeroed here. + CreateTime: state.CreateTime, + DashboardId: state.DashboardId, + LifecycleState: state.LifecycleState, + Path: state.Path, + UpdateTime: state.UpdateTime, }, Published: state.Published, } diff --git a/bundle/direct/dresources/model.go b/bundle/direct/dresources/model.go index 9d04231456d..5e04803b688 100644 --- a/bundle/direct/dresources/model.go +++ b/bundle/direct/dresources/model.go @@ -2,6 +2,7 @@ package dresources import ( "context" + "errors" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/utils" @@ -57,6 +58,9 @@ func (r *ResourceMlflowModel) DoCreate(ctx context.Context, config *ml.CreateMod if err != nil { return "", nil, err } + if response.RegisteredModel == nil { + return "", nil, errors.New("CreateModel returned no registered_model") + } // Return nil for refresh output; the engine will call DoRead to populate the full state // including the numeric model ID needed for permissions. return response.RegisteredModel.Name, nil, nil @@ -73,6 +77,9 @@ func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, id string, config *m if err != nil { return nil, err } + if response.RegisteredModel == nil { + return nil, errors.New("UpdateModel returned no registered_model") + } // Carry forward model_id from existing state since UpdateModelResponse doesn't include it. var modelId string @@ -80,18 +87,22 @@ func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, id string, config *m modelId = old.ModelId } + // Id and PermissionLevel are left empty because ml.Model (the UpdateModel + // response) does not carry them; every other field is taken from the + // response so references against the post-update remote state resolve to + // real values until the next plan's DoRead. return &MlflowModelRemote{ ModelDatabricks: ml.ModelDatabricks{ - CreationTimestamp: 0, + CreationTimestamp: response.RegisteredModel.CreationTimestamp, Description: response.RegisteredModel.Description, Id: "", - LastUpdatedTimestamp: 0, - LatestVersions: nil, + LastUpdatedTimestamp: response.RegisteredModel.LastUpdatedTimestamp, + LatestVersions: response.RegisteredModel.LatestVersions, Name: response.RegisteredModel.Name, PermissionLevel: "", Tags: response.RegisteredModel.Tags, - UserId: "", - ForceSendFields: utils.FilterFields[ml.ModelDatabricks](response.RegisteredModel.ForceSendFields, "CreationTimestamp", "Id", "LastUpdatedTimestamp", "LatestVersions", "PermissionLevel", "UserId"), + UserId: response.RegisteredModel.UserId, + ForceSendFields: utils.FilterFields[ml.ModelDatabricks](response.RegisteredModel.ForceSendFields, "Id", "PermissionLevel"), }, ModelId: modelId, }, nil diff --git a/bundle/direct/dresources/registered_model.go b/bundle/direct/dresources/registered_model.go index 888870a7581..b72038a79f0 100644 --- a/bundle/direct/dresources/registered_model.go +++ b/bundle/direct/dresources/registered_model.go @@ -38,11 +38,12 @@ func (*ResourceRegisteredModel) RemapState(model *catalog.RegisteredModelInfo) * MetastoreId: model.MetastoreId, Owner: model.Owner, - // Clear output only fields. They should not show up on remote diff computation. - CreatedAt: 0, - CreatedBy: "", - UpdatedAt: 0, - UpdatedBy: "", + // Output only fields. Remote changes to these are ignored via + // ignore_remote_changes in resources.yml rather than zeroed here. + CreatedAt: model.CreatedAt, + CreatedBy: model.CreatedBy, + UpdatedAt: model.UpdatedAt, + UpdatedBy: model.UpdatedBy, } } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 624e0eb580f..35aac1ebf5c 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -271,8 +271,9 @@ resources: registered_models: ignore_remote_changes: - # Output-only timestamp/user fields zeroed in RemapState but still show up - # via ForceSendFields. These should never participate in diffs. + # Output-only timestamp/user fields populated by the backend on read. + # The user never sets them, so remote-only differences are ignored here + # rather than zeroed in RemapState. - field: created_at reason: output_only - field: created_by