diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 183350f1be..b5f24c328d 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### CLI ### Bundles +* direct: Use async Update API for Apps update ([#4607](https://github.com/databricks/cli/pull/4607)) ### Dependency updates diff --git a/acceptance/bundle/apps/compute_size/app/app.py b/acceptance/bundle/apps/compute_size/app/app.py new file mode 100644 index 0000000000..271596cec0 --- /dev/null +++ b/acceptance/bundle/apps/compute_size/app/app.py @@ -0,0 +1 @@ +print("Simple test app") diff --git a/acceptance/bundle/apps/compute_size/app/app.yml b/acceptance/bundle/apps/compute_size/app/app.yml new file mode 100644 index 0000000000..45b242d406 --- /dev/null +++ b/acceptance/bundle/apps/compute_size/app/app.yml @@ -0,0 +1 @@ +command: ["python", "app.py"] diff --git a/acceptance/bundle/apps/compute_size/databricks.yml.tmpl b/acceptance/bundle/apps/compute_size/databricks.yml.tmpl new file mode 100644 index 0000000000..2270499e97 --- /dev/null +++ b/acceptance/bundle/apps/compute_size/databricks.yml.tmpl @@ -0,0 +1,13 @@ +bundle: + name: app-git-source-$UNIQUE_NAME + +workspace: + root_path: "~/.bundle/app-compute-size-$UNIQUE_NAME" + +resources: + apps: + my_app: + name: app-$UNIQUE_NAME + description: "App with compute size" + compute_size: LARGE + source_code_path: ./app diff --git a/acceptance/bundle/apps/compute_size/out.test.toml b/acceptance/bundle/apps/compute_size/out.test.toml new file mode 100644 index 0000000000..01ed6822af --- /dev/null +++ b/acceptance/bundle/apps/compute_size/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = true + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/apps/compute_size/out.update.direct.txt b/acceptance/bundle/apps/compute_size/out.update.direct.txt new file mode 100644 index 0000000000..6e02b630e6 --- /dev/null +++ b/acceptance/bundle/apps/compute_size/out.update.direct.txt @@ -0,0 +1,11 @@ + +>>> errcode [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/app-compute-size-[UNIQUE_NAME]/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] apps get app-[UNIQUE_NAME] +{ + "compute_size": "MEDIUM" +} diff --git a/acceptance/bundle/apps/compute_size/out.update.terraform.txt b/acceptance/bundle/apps/compute_size/out.update.terraform.txt new file mode 100644 index 0000000000..62a03593a3 --- /dev/null +++ b/acceptance/bundle/apps/compute_size/out.update.terraform.txt @@ -0,0 +1,24 @@ + +>>> errcode [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/app-compute-size-[UNIQUE_NAME]/files... +Deploying resources... +Error: terraform apply: exit status 1 + +Error: failed to update app + + with databricks_app.my_app, + on bundle.tf.json line 20, in resource.databricks_app.my_app: + 20: } + +Compute size updates are not supported in this update API. Please use the new +update API instead. + + +Updating deployment state... + +Exit code: 1 + +>>> [CLI] apps get app-[UNIQUE_NAME] +{ + "compute_size": "LARGE" +} diff --git a/acceptance/bundle/apps/compute_size/output.txt b/acceptance/bundle/apps/compute_size/output.txt new file mode 100644 index 0000000000..3c22e34adb --- /dev/null +++ b/acceptance/bundle/apps/compute_size/output.txt @@ -0,0 +1,17 @@ + +=== Deploy bundle +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/app-compute-size-[UNIQUE_NAME]/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Update compute size and redeploy +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.apps.my_app + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/app-compute-size-[UNIQUE_NAME] + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/apps/compute_size/script b/acceptance/bundle/apps/compute_size/script new file mode 100644 index 0000000000..5f4714ae8d --- /dev/null +++ b/acceptance/bundle/apps/compute_size/script @@ -0,0 +1,20 @@ +#!/bin/bash + +# Generate databricks.yml from template +envsubst < databricks.yml.tmpl > databricks.yml + +# Set up cleanup trap to ensure resources are destroyed even on failure +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +title "Deploy bundle" +trace $CLI bundle deploy + +title "Update compute size and redeploy" +sed -i.bak 's/compute_size: LARGE/compute_size: MEDIUM/' databricks.yml +{ + trace errcode $CLI bundle deploy + trace $CLI apps get "app-$UNIQUE_NAME" | jq '{compute_size: .compute_size}' +} &> out.update.$DATABRICKS_BUNDLE_ENGINE.txt diff --git a/acceptance/bundle/apps/compute_size/test.toml b/acceptance/bundle/apps/compute_size/test.toml new file mode 100644 index 0000000000..df8141452c --- /dev/null +++ b/acceptance/bundle/apps/compute_size/test.toml @@ -0,0 +1,14 @@ +Local = true +Cloud = true +RecordRequests = false + +Ignore = [".databricks", "databricks.yml", "databricks.yml.bak", "out.app-run"] + +# Apps can take longer to deploy +TimeoutCloud = "5m" + +# Simulate the Terraform provider's old update API not supporting compute_size changes. +[[Server]] +Pattern = "PATCH /api/2.0/apps/{name}" +Response.StatusCode = 400 +Response.Body = '{"error_code": "INVALID_PARAMETER_VALUE", "message": "Compute size updates are not supported in this update API. Please use the new update API instead."}' diff --git a/acceptance/bundle/resources/apps/update/out.requests.direct.json b/acceptance/bundle/resources/apps/update/out.requests.direct.json new file mode 100644 index 0000000000..85a9ac2bc6 --- /dev/null +++ b/acceptance/bundle/resources/apps/update/out.requests.direct.json @@ -0,0 +1,43 @@ +{ + "body": { + "description": "my_app_description", + "name": "myappname" + }, + "method": "POST", + "path": "/api/2.0/apps", + "q": { + "no_compute": "true" + } +} +{ + "body": { + "app": { + "description": "MY_APP_DESCRIPTION", + "name": "myappname" + }, + "update_mask": "description" + }, + "method": "POST", + "path": "/api/2.0/apps/myappname/update" +} +{ + "body": {}, + "method": "DELETE", + "path": "/api/2.0/apps/myappname" +} +{ + "body": { + "description": "MY_APP_DESCRIPTION", + "name": "mynewappname" + }, + "method": "POST", + "path": "/api/2.0/apps", + "q": { + "no_compute": "true" + } +} +{ + "body": {}, + "method": "DELETE", + "path": "/api/2.0/apps/mynewappname" +} diff --git a/acceptance/bundle/resources/apps/update/out.requests.terraform.json b/acceptance/bundle/resources/apps/update/out.requests.terraform.json new file mode 100644 index 0000000000..f277c7dfe4 --- /dev/null +++ b/acceptance/bundle/resources/apps/update/out.requests.terraform.json @@ -0,0 +1,40 @@ +{ + "body": { + "description": "my_app_description", + "name": "myappname" + }, + "method": "POST", + "path": "/api/2.0/apps", + "q": { + "no_compute": "true" + } +} +{ + "body": { + "description": "MY_APP_DESCRIPTION", + "name": "myappname" + }, + "method": "PATCH", + "path": "/api/2.0/apps/myappname" +} +{ + "body": {}, + "method": "DELETE", + "path": "/api/2.0/apps/myappname" +} +{ + "body": { + "description": "MY_APP_DESCRIPTION", + "name": "mynewappname" + }, + "method": "POST", + "path": "/api/2.0/apps", + "q": { + "no_compute": "true" + } +} +{ + "body": {}, + "method": "DELETE", + "path": "/api/2.0/apps/mynewappname" +} diff --git a/acceptance/bundle/resources/apps/update/output.txt b/acceptance/bundle/resources/apps/update/output.txt index a926cc0654..021b10a9cd 100644 --- a/acceptance/bundle/resources/apps/update/output.txt +++ b/acceptance/bundle/resources/apps/update/output.txt @@ -11,17 +11,6 @@ Updating deployment state... Deployment complete! >>> print_requests -{ - "body": { - "description": "my_app_description", - "name": "myappname" - }, - "method": "POST", - "path": "/api/2.0/apps", - "q": { - "no_compute": "true" - } -} >>> [CLI] bundle summary Name: test-bundle @@ -50,14 +39,6 @@ Updating deployment state... Deployment complete! >>> print_requests -{ - "body": { - "description": "MY_APP_DESCRIPTION", - "name": "myappname" - }, - "method": "PATCH", - "path": "/api/2.0/apps/myappname" -} >>> [CLI] bundle summary Name: test-bundle @@ -98,22 +79,6 @@ Updating deployment state... Deployment complete! >>> print_requests -{ - "body": {}, - "method": "DELETE", - "path": "/api/2.0/apps/myappname" -} -{ - "body": { - "description": "MY_APP_DESCRIPTION", - "name": "mynewappname" - }, - "method": "POST", - "path": "/api/2.0/apps", - "q": { - "no_compute": "true" - } -} >>> [CLI] bundle plan Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged @@ -140,8 +105,3 @@ Deleting files... Destroy complete! >>> print_requests -{ - "body": {}, - "method": "DELETE", - "path": "/api/2.0/apps/mynewappname" -} diff --git a/acceptance/bundle/resources/apps/update/script b/acceptance/bundle/resources/apps/update/script index baf8597df4..a66ff34b8c 100644 --- a/acceptance/bundle/resources/apps/update/script +++ b/acceptance/bundle/resources/apps/update/script @@ -6,14 +6,14 @@ print_requests() { trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests +trace print_requests > out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle summary title "Update description and re-deploy" trace update_file.py databricks.yml my_app_description MY_APP_DESCRIPTION trace $CLI bundle plan trace $CLI bundle deploy -trace print_requests +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle summary title "Update name and re-deploy" @@ -21,9 +21,9 @@ trace update_file.py databricks.yml myappname mynewappname trace $CLI bundle plan trace $CLI bundle summary trace $CLI bundle deploy -trace print_requests +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json trace $CLI bundle plan trace $CLI bundle summary trace $CLI bundle destroy --auto-approve -trace print_requests +trace print_requests >> out.requests.$DATABRICKS_BUNDLE_ENGINE.json diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 756079bf34..e753079dac 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -4,10 +4,10 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/databricks/cli/bundle/config/resources" - "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/retries" @@ -68,20 +68,27 @@ func (r *ResourceApp) DoCreate(ctx context.Context, config *apps.App) (string, * return app.Name, nil, nil } -func (r *ResourceApp) DoUpdate(ctx context.Context, id string, config *apps.App, _ Changes) (*apps.App, error) { - request := apps.UpdateAppRequest{ - App: *config, - Name: id, +func (r *ResourceApp) DoUpdate(ctx context.Context, id string, config *apps.App, changes Changes) (*apps.App, error) { + updateMask := strings.Join(collectUpdatePathsWithPrefix(changes, ""), ",") + + request := apps.AsyncUpdateAppRequest{ + App: config, + AppName: id, + UpdateMask: updateMask, } - response, err := r.client.Apps.Update(ctx, request) + updateWaiter, err := r.client.Apps.CreateUpdate(ctx, request) if err != nil { return nil, err } - if response.Name != id { - log.Warnf(ctx, "apps: response contains unexpected name=%#v (expected %#v)", response.Name, id) + response, err := updateWaiter.Get() + if err != nil { + return nil, err } + if response.Status.State != apps.AppUpdateUpdateStatusUpdateStateSucceeded { + return nil, fmt.Errorf("failed to update app %s: %s", id, response.Status.Message) + } return nil, nil } diff --git a/libs/testserver/apps.go b/libs/testserver/apps.go index 1f7a39dd8d..3fb513eee5 100644 --- a/libs/testserver/apps.go +++ b/libs/testserver/apps.go @@ -5,10 +5,89 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/databricks/databricks-sdk-go/service/apps" ) +func (s *FakeWorkspace) AppsCreateUpdate(req Request, name string) Response { + var updateReq apps.AsyncUpdateAppRequest + if err := json.Unmarshal(req.Body, &updateReq); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + defer s.LockUnlock()() + + existing, ok := s.Apps[name] + if !ok { + return Response{StatusCode: 404} + } + + if updateReq.App != nil { + // Convert both to maps and apply only the fields listed in update_mask. + existingJSON, err := json.Marshal(existing) + if err != nil { + return Response{Body: fmt.Sprintf("internal error: %s", err), StatusCode: http.StatusInternalServerError} + } + var existingMap map[string]any + if err := json.Unmarshal(existingJSON, &existingMap); err != nil { + return Response{Body: fmt.Sprintf("internal error: %s", err), StatusCode: http.StatusInternalServerError} + } + + updateJSON, err := json.Marshal(updateReq.App) + if err != nil { + return Response{Body: fmt.Sprintf("internal error: %s", err), StatusCode: http.StatusInternalServerError} + } + var updateMap map[string]any + if err := json.Unmarshal(updateJSON, &updateMap); err != nil { + return Response{Body: fmt.Sprintf("internal error: %s", err), StatusCode: http.StatusInternalServerError} + } + + for _, field := range strings.Split(updateReq.UpdateMask, ",") { + if v, ok := updateMap[strings.TrimSpace(field)]; ok { + existingMap[strings.TrimSpace(field)] = v + } + } + + merged, err := json.Marshal(existingMap) + if err != nil { + return Response{Body: fmt.Sprintf("internal error: %s", err), StatusCode: http.StatusInternalServerError} + } + if err := json.Unmarshal(merged, &existing); err != nil { + return Response{Body: fmt.Sprintf("internal error: %s", err), StatusCode: http.StatusInternalServerError} + } + } + s.Apps[name] = existing + + return Response{ + Body: apps.AppUpdate{ + Status: &apps.AppUpdateUpdateStatus{ + State: apps.AppUpdateUpdateStatusUpdateStateSucceeded, + }, + }, + } +} + +func (s *FakeWorkspace) AppsGetUpdate(_ Request, name string) Response { + defer s.LockUnlock()() + + _, ok := s.Apps[name] + if !ok { + return Response{StatusCode: 404} + } + + return Response{ + Body: apps.AppUpdate{ + Status: &apps.AppUpdateUpdateStatus{ + State: apps.AppUpdateUpdateStatusUpdateStateSucceeded, + }, + }, + } +} + func (s *FakeWorkspace) AppsUpsert(req Request, name string) Response { var app apps.App diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 7164934176..9e30cb5f0c 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -379,6 +379,14 @@ func AddDefaultHandlers(server *Server) { // Apps: + server.Handle("POST", "/api/2.0/apps/{name}/update", func(req Request) any { + return req.Workspace.AppsCreateUpdate(req, req.Vars["name"]) + }) + + server.Handle("GET", "/api/2.0/apps/{name}/update", func(req Request) any { + return req.Workspace.AppsGetUpdate(req, req.Vars["name"]) + }) + server.Handle("GET", "/api/2.0/apps/{name}", func(req Request) any { return MapGet(req.Workspace, req.Workspace.Apps, req.Vars["name"]) })