diff --git a/.nextchanges/cli/clusters-start-already-running.md b/.nextchanges/cli/clusters-start-already-running.md new file mode 100644 index 00000000000..f3b9a28cd85 --- /dev/null +++ b/.nextchanges/cli/clusters-start-already-running.md @@ -0,0 +1 @@ +Fixed `databricks clusters start` failing with `Cluster is in unexpected state Running.` when the cluster is already running. The command is documented as a no-op for a cluster that is not terminated, so it now reports success and prints the cluster instead of erroring. ([#6288](https://github.com/databricks/cli/pull/6288)) diff --git a/acceptance/cmd/workspace/clusters-start/out.test.toml b/acceptance/cmd/workspace/clusters-start/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/cmd/workspace/clusters-start/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/cmd/workspace/clusters-start/output.txt b/acceptance/cmd/workspace/clusters-start/output.txt new file mode 100644 index 00000000000..c746ce1f044 --- /dev/null +++ b/acceptance/cmd/workspace/clusters-start/output.txt @@ -0,0 +1,16 @@ + +=== Starting a cluster that is already running is a no-op, not an error + +>>> [CLI] clusters start [CLUSTER_ID] +{ + "cluster_id": "[CLUSTER_ID]", + "state": "RUNNING" +} + +=== Starting a terminated cluster starts it + +>>> [CLI] clusters start [CLUSTER_ID] +{ + "cluster_id": "[CLUSTER_ID]", + "state": "RUNNING" +} diff --git a/acceptance/cmd/workspace/clusters-start/script b/acceptance/cmd/workspace/clusters-start/script new file mode 100644 index 00000000000..07d6758f49a --- /dev/null +++ b/acceptance/cmd/workspace/clusters-start/script @@ -0,0 +1,9 @@ +CLUSTER_ID=$($CLI clusters create --json '{"cluster_name": "test-cluster", "spark_version": "13.3.x-scala2.12", "node_type_id": "i3.xlarge", "num_workers": 1}' | jq -r '.cluster_id') +echo "$CLUSTER_ID:CLUSTER_ID" >> ACC_REPLS + +title "Starting a cluster that is already running is a no-op, not an error\n" +trace $CLI clusters start "$CLUSTER_ID" | jq '{cluster_id, state}' + +title "Starting a terminated cluster starts it\n" +$CLI clusters delete "$CLUSTER_ID" > /dev/null +trace $CLI clusters start "$CLUSTER_ID" | jq '{cluster_id, state}' diff --git a/acceptance/cmd/workspace/clusters-start/test.toml b/acceptance/cmd/workspace/clusters-start/test.toml new file mode 100644 index 00000000000..66f3d7620d2 --- /dev/null +++ b/acceptance/cmd/workspace/clusters-start/test.toml @@ -0,0 +1,2 @@ +# Cluster commands are not bundle-aware; run them once instead of per-engine. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/cmd/workspace/clusters/overrides.go b/cmd/workspace/clusters/overrides.go index 45c530a14a2..9defc84bf0c 100644 --- a/cmd/workspace/clusters/overrides.go +++ b/cmd/workspace/clusters/overrides.go @@ -1,9 +1,12 @@ package clusters import ( + "errors" "strings" + "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/databricks-sdk-go/apierr" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/spf13/cobra" ) @@ -93,8 +96,36 @@ func sparkVersionsOverride(sparkVersionsCmd *cobra.Command) { `) } +func startOverride(startCmd *cobra.Command, startReq *compute.StartCluster) { + start := startCmd.RunE + startCmd.RunE = func(cmd *cobra.Command, args []string) error { + err := start(cmd, args) + if err == nil { + return nil + } + + // The API rejects a cluster that is not TERMINATED with INVALID_STATE, even though this + // command is documented as a no-op in that case ("If the cluster is not currently in a + // TERMINATED state, nothing will happen"). Report success if the cluster is already + // running, so that starting a running cluster is idempotent. + apiErr, ok := errors.AsType[*apierr.APIError](err) + if !ok || apiErr.ErrorCode != "INVALID_STATE" { + return err + } + + ctx := cmd.Context() + cluster, getErr := cmdctx.WorkspaceClient(ctx).Clusters.GetByClusterId(ctx, startReq.ClusterId) + if getErr != nil || cluster.State != compute.StateRunning { + return err + } + + return cmdio.Render(ctx, cluster) + } +} + func init() { listOverrides = append(listOverrides, listOverride) listNodeTypesOverrides = append(listNodeTypesOverrides, listNodeTypesOverride) sparkVersionsOverrides = append(sparkVersionsOverrides, sparkVersionsOverride) + startOverrides = append(startOverrides, startOverride) } diff --git a/libs/testserver/clusters.go b/libs/testserver/clusters.go index 80991b0091a..34322081bd1 100644 --- a/libs/testserver/clusters.go +++ b/libs/testserver/clusters.go @@ -216,6 +216,19 @@ func (s *FakeWorkspace) ClustersStart(req Request) any { return Response{StatusCode: 404} } + // Only terminated clusters can be started; match the real API behavior, which rejects + // any other state with INVALID_STATE (it title-cases the state in the message, e.g. + // "Cluster 0308-101010-abcdefgh is in unexpected state Running."). + if cluster.State != compute.StateTerminated { + return Response{ + StatusCode: 400, + Body: map[string]string{ + "error_code": "INVALID_STATE", + "message": fmt.Sprintf("Cluster %s is in unexpected state %s.", request.ClusterId, cluster.State), + }, + } + } + cluster.State = compute.StateRunning s.Clusters[request.ClusterId] = cluster