From c155c4ee527edbf5f63e713cd9ce04c8fb65a0a2 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 17 Jul 2026 14:46:35 +0200 Subject: [PATCH 1/2] Add --cluster-name target flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the [P0] CLI Changes spec, add --cluster-name as a compute target: it resolves the cluster name to an ID via the Clusters API and then behaves identically to --cluster-id (source=cluster, env key from the resolved cluster's spark_version). - ComputeClient gains GetClusterByName; sdkCompute implements it via the SDK's Clusters.GetByClusterName, which errors on an unknown or ambiguous name (two clusters sharing it) — surfaced as an actionable E_RESOLVE. - --cluster-name joins the mutually-exclusive target group and the bundle-fallback guard. - Unit tests for success, ambiguity, and cluster-id/-name exclusivity; acceptance tests cluster-name-check (happy path) and cluster-name-ambiguous (E_RESOLVE), plus the help golden. Co-authored-by: Isaac --- .../cluster-name-ambiguous/out.test.toml | 3 ++ .../cluster-name-ambiguous/output.txt | 7 ++++ .../localenv/cluster-name-ambiguous/script | 1 + .../localenv/cluster-name-ambiguous/test.toml | 17 ++++++++ .../localenv/cluster-name-check/out.test.toml | 3 ++ .../localenv/cluster-name-check/output.txt | 13 ++++++ acceptance/localenv/cluster-name-check/script | 1 + .../localenv/cluster-name-check/test.toml | 34 +++++++++++++++ acceptance/localenv/flag-conflict/output.txt | 2 +- acceptance/localenv/help/output.txt | 1 + cmd/environments/compute.go | 14 +++++++ cmd/environments/sync.go | 15 ++++--- libs/localenv/target.go | 32 +++++++++++++-- libs/localenv/target_test.go | 41 +++++++++++++++++++ 14 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 acceptance/localenv/cluster-name-ambiguous/out.test.toml create mode 100644 acceptance/localenv/cluster-name-ambiguous/output.txt create mode 100644 acceptance/localenv/cluster-name-ambiguous/script create mode 100644 acceptance/localenv/cluster-name-ambiguous/test.toml create mode 100644 acceptance/localenv/cluster-name-check/out.test.toml create mode 100644 acceptance/localenv/cluster-name-check/output.txt create mode 100644 acceptance/localenv/cluster-name-check/script create mode 100644 acceptance/localenv/cluster-name-check/test.toml diff --git a/acceptance/localenv/cluster-name-ambiguous/out.test.toml b/acceptance/localenv/cluster-name-ambiguous/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/localenv/cluster-name-ambiguous/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/localenv/cluster-name-ambiguous/output.txt b/acceptance/localenv/cluster-name-ambiguous/output.txt new file mode 100644 index 00000000000..b8c08108857 --- /dev/null +++ b/acceptance/localenv/cluster-name-ambiguous/output.txt @@ -0,0 +1,7 @@ +preflight ok check +resolve error resolving cluster name "dup": get cluster by name "dup": there are 2 instances of ClusterDetails named 'dup' +fetch pending +merge pending +provision pending +validate pending +For more detail, re-run with --debug, or --output json to share a structured report. diff --git a/acceptance/localenv/cluster-name-ambiguous/script b/acceptance/localenv/cluster-name-ambiguous/script new file mode 100644 index 00000000000..a7a8d77eb72 --- /dev/null +++ b/acceptance/localenv/cluster-name-ambiguous/script @@ -0,0 +1 @@ +musterr $CLI environments setup-local --cluster-name dup --dry-run diff --git a/acceptance/localenv/cluster-name-ambiguous/test.toml b/acceptance/localenv/cluster-name-ambiguous/test.toml new file mode 100644 index 00000000000..d8947aa5684 --- /dev/null +++ b/acceptance/localenv/cluster-name-ambiguous/test.toml @@ -0,0 +1,17 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] + +[Env] +DATABRICKS_LOCALENV_CONSTRAINT_SOURCE = "$DATABRICKS_HOST" + +# Two clusters share the same name, so name→ID resolution is ambiguous: the +# Clusters API errors, surfaced as an actionable E_RESOLVE at the resolve phase. +[[Server]] +Pattern = "GET /api/2.1/clusters/list" +Response.Body = ''' +{ + "clusters": [ + {"cluster_id": "cid-123", "cluster_name": "dup", "spark_version": "15.4.x-scala2.12"}, + {"cluster_id": "cid-999", "cluster_name": "dup", "spark_version": "14.3.x-scala2.12"} + ] +} +''' diff --git a/acceptance/localenv/cluster-name-check/out.test.toml b/acceptance/localenv/cluster-name-check/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/localenv/cluster-name-check/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/localenv/cluster-name-check/output.txt b/acceptance/localenv/cluster-name-check/output.txt new file mode 100644 index 00000000000..b8f50099434 --- /dev/null +++ b/acceptance/localenv/cluster-name-check/output.txt @@ -0,0 +1,13 @@ + +>>> [CLI] environments setup-local --cluster-name my-cluster --dry-run +preflight ok check +resolve ok source=cluster envKey=dbr/15.4.x-scala2.12 +fetch ok source=[DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml fromCache=false +merge ok +provision ok +validate ok +Plan: [TEST_TMP_DIR]/pyproject.toml + changed region: requires-python + changed region: tool.uv.constraint-dependencies + changed region: databricks-connect +Check complete. No files were modified. diff --git a/acceptance/localenv/cluster-name-check/script b/acceptance/localenv/cluster-name-check/script new file mode 100644 index 00000000000..ab8b82e1d02 --- /dev/null +++ b/acceptance/localenv/cluster-name-check/script @@ -0,0 +1 @@ +trace $CLI environments setup-local --cluster-name my-cluster --dry-run diff --git a/acceptance/localenv/cluster-name-check/test.toml b/acceptance/localenv/cluster-name-check/test.toml new file mode 100644 index 00000000000..092973f33b2 --- /dev/null +++ b/acceptance/localenv/cluster-name-check/test.toml @@ -0,0 +1,34 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] + +[Env] +DATABRICKS_LOCALENV_CONSTRAINT_SOURCE = "$DATABRICKS_HOST" + +# --cluster-name resolves the name to an ID via the Clusters API (which lists +# clusters and matches on name), then resolves identically to --cluster-id. +[[Server]] +Pattern = "GET /api/2.1/clusters/list" +Response.Body = ''' +{ + "clusters": [ + {"cluster_id": "cid-123", "cluster_name": "my-cluster", "spark_version": "15.4.x-scala2.12"}, + {"cluster_id": "cid-999", "cluster_name": "other-cluster", "spark_version": "14.3.x-scala2.12"} + ] +} +''' + +[[Server]] +Pattern = "GET /dbr/15.4.x-scala2.12/pyproject.toml" +Response.Body = ''' +[project] +requires-python = ">=3.11" + +[dependency-groups] +dev = ["databricks-connect~=15.4.0"] + +[tool.uv] +constraint-dependencies = ["pyarrow<19"] +''' + +[[Repls]] +Old = 'uv uv \S+(?: \([^)]+\))?' +New = 'uv [UV_VERSION]' diff --git a/acceptance/localenv/flag-conflict/output.txt b/acceptance/localenv/flag-conflict/output.txt index 1f41b541f7f..a6379fb10cd 100644 --- a/acceptance/localenv/flag-conflict/output.txt +++ b/acceptance/localenv/flag-conflict/output.txt @@ -1 +1 @@ -Error: if any flags in the group [cluster-id serverless-version job-id] are set none of the others can be; [cluster-id serverless-version] were all set +Error: if any flags in the group [cluster-id cluster-name serverless-version job-id] are set none of the others can be; [cluster-id serverless-version] were all set diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index da4cb97fa10..2e0a6d1e2e4 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -11,6 +11,7 @@ Usage: Flags: --cluster-id string cluster ID to use as the compute target + --cluster-name string cluster name to use as the compute target (resolved to an ID via the Clusters API) --constraints-only apply the Python version and constraints without adding the databricks-connect dependency --dry-run compute the plan without writing files or provisioning -h, --help help for setup-local diff --git a/cmd/environments/compute.go b/cmd/environments/compute.go index 4e37040dc62..59bb5896d64 100644 --- a/cmd/environments/compute.go +++ b/cmd/environments/compute.go @@ -23,6 +23,20 @@ func (c sdkCompute) GetClusterSparkVersion(ctx context.Context, clusterID string return d.SparkVersion, nil } +// GetClusterByName resolves a cluster name to its ID and Spark version. +// +// The SDK's GetByClusterName lists clusters and matches on name; it returns an +// error when the name is unknown or when more than one cluster shares it. Both +// are surfaced to the caller so they become an actionable E_RESOLVE rather than +// a silently-wrong target. +func (c sdkCompute) GetClusterByName(ctx context.Context, name string) (string, string, error) { + d, err := c.w.Clusters.GetByClusterName(ctx, name) + if err != nil { + return "", "", fmt.Errorf("get cluster by name %q: %w", name, err) + } + return d.ClusterId, d.SparkVersion, nil +} + // GetJobSparkVersion inspects the job's configuration to determine compute type. // // A job is considered serverless when it has non-empty Environments (JobEnvironment diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index 4e15c499679..cbe97da43d3 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -48,6 +48,7 @@ env-owned sections are refreshed, user-owned content is preserved).`, // addTargetFlags adds the shared target and mode flags to a command. func addTargetFlags(cmd *cobra.Command) { cmd.Flags().String("cluster-id", "", "cluster ID to use as the compute target") + cmd.Flags().String("cluster-name", "", "cluster name to use as the compute target (resolved to an ID via the Clusters API)") cmd.Flags().String("serverless-version", "", "serverless version to use as the compute target (e.g. v4)") cmd.Flags().String("job-id", "", "job ID to use as the compute target") cmd.Flags().Bool("constraints-only", false, "apply the Python version and constraints without adding the databricks-connect dependency") @@ -55,7 +56,7 @@ func addTargetFlags(cmd *cobra.Command) { cmd.Flags().String("constraint-source-url", "", "URL for the constraint source (overrides "+envConstraintSource+")") // Hide constraint-source-url from casual --help output; it is a power-user escape hatch. _ = cmd.Flags().MarkHidden("constraint-source-url") - cmd.MarkFlagsMutuallyExclusive("cluster-id", "serverless-version", "job-id") + cmd.MarkFlagsMutuallyExclusive("cluster-id", "cluster-name", "serverless-version", "job-id") } // runPipeline builds and runs the setup-local Pipeline. @@ -63,6 +64,7 @@ func runPipeline(cmd *cobra.Command) error { ctx := cmd.Context() cluster, _ := cmd.Flags().GetString("cluster-id") + clusterName, _ := cmd.Flags().GetString("cluster-name") serverless, _ := cmd.Flags().GetString("serverless-version") job, _ := cmd.Flags().GetString("job-id") constraintsOnly, _ := cmd.Flags().GetBool("constraints-only") @@ -70,9 +72,10 @@ func runPipeline(cmd *cobra.Command) error { constraintSource, _ := cmd.Flags().GetString("constraint-source-url") targetFlags := libslocalenv.TargetFlags{ - Cluster: cluster, - Serverless: serverless, - Job: job, + Cluster: cluster, + ClusterName: clusterName, + Serverless: serverless, + Job: job, } // ValidateTargetFlags is kept despite MarkFlagsMutuallyExclusive above: // it also validates the library path (no Cobra equivalent) and guards @@ -100,11 +103,11 @@ func runPipeline(cmd *cobra.Command) error { cacheDir = filepath.Join(cacheDir, "databricks", "localenv") // The bundle is only a fallback: ResolveTarget consults it solely when no - // explicit --cluster-id/--serverless-version/--job-id flag is set. Skip the bundle load + // explicit --cluster-id/--cluster-name/--serverless-version/--job-id flag is set. Skip the bundle load // entirely when a flag is present — it would otherwise re-run TryConfigureBundle // (a second full load) and re-print any bundle load-time diagnostics for nothing. var bt libslocalenv.BundleTarget - if cluster == "" && serverless == "" && job == "" { + if cluster == "" && clusterName == "" && serverless == "" && job == "" { bt = bundleTarget(cmd) } diff --git a/libs/localenv/target.go b/libs/localenv/target.go index fdd30322191..d92378c77c8 100644 --- a/libs/localenv/target.go +++ b/libs/localenv/target.go @@ -10,6 +10,10 @@ import ( type ComputeClient interface { // GetClusterSparkVersion returns the Spark version string for a cluster. GetClusterSparkVersion(ctx context.Context, clusterID string) (string, error) + // GetClusterByName resolves a cluster name to its ID and Spark version. It + // errors when the name is unknown or ambiguous (more than one cluster shares + // the name), so the caller can surface an actionable E_RESOLVE. + GetClusterByName(ctx context.Context, name string) (clusterID, sparkVersion string, err error) // GetJobSparkVersion returns either a Spark version (isServerless=false) or a // serverless marker (isServerless=true) for a job, plus a recorded version string. GetJobSparkVersion(ctx context.Context, jobID string) (sparkVersion string, isServerless bool, version string, err error) @@ -17,9 +21,10 @@ type ComputeClient interface { // TargetFlags holds the mutually-exclusive compute target flags from the CLI. type TargetFlags struct { - Cluster string - Serverless string - Job string + Cluster string + ClusterName string + Serverless string + Job string } // BundleTarget is the three-state result of reading the bundle's configured @@ -34,13 +39,16 @@ type BundleTarget struct { // matching spec §2.3. const noTargetMessage = "No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --serverless-version / --job-id" -// ValidateTargetFlags returns an error if more than one of the three flags is set. +// ValidateTargetFlags returns an error if more than one of the target flags is set. // Cobra marks them mutually exclusive too; this guards the library path. func ValidateTargetFlags(f TargetFlags) error { var set []string if f.Cluster != "" { set = append(set, "--cluster-id") } + if f.ClusterName != "" { + set = append(set, "--cluster-name") + } if f.Serverless != "" { set = append(set, "--serverless-version") } @@ -79,6 +87,22 @@ func ResolveTarget(ctx context.Context, f TargetFlags, c ComputeClient, bt Bundl }, nil } + if f.ClusterName != "" { + // Resolve the name to an ID via the Clusters API; from there it is + // identical to --cluster-id. An unknown or ambiguous name (two clusters + // sharing it) yields an actionable E_RESOLVE. + id, v, err := c.GetClusterByName(ctx, f.ClusterName) + if err != nil { + return nil, NewError(ErrResolve, err, "resolving cluster name %q", f.ClusterName) + } + return &TargetInfo{ + Source: "cluster", + ClusterID: id, + SparkVersion: v, + EnvKey: EnvKeyForSparkVersion(v), + }, nil + } + if f.Serverless != "" { return &TargetInfo{ Source: "serverless", diff --git a/libs/localenv/target_test.go b/libs/localenv/target_test.go index 7e80dce2413..1033b90909d 100644 --- a/libs/localenv/target_test.go +++ b/libs/localenv/target_test.go @@ -12,12 +12,19 @@ import ( type stubCompute struct { clusterVersion string clusterErr error + byNameID string + byNameVersion string + byNameErr error } func (s stubCompute) GetClusterSparkVersion(_ context.Context, _ string) (string, error) { return s.clusterVersion, s.clusterErr } +func (s stubCompute) GetClusterByName(_ context.Context, _ string) (string, string, error) { + return s.byNameID, s.byNameVersion, s.byNameErr +} + func (s stubCompute) GetJobSparkVersion(_ context.Context, _ string) (string, bool, string, error) { return "", false, "", nil } @@ -48,6 +55,36 @@ func TestResolveClusterFlagError(t *testing.T) { assert.Equal(t, ErrResolve, pe.Code) } +func TestResolveClusterNameFlag(t *testing.T) { + // --cluster-name resolves to an ID via the Clusters API, then behaves like + // --cluster-id: source=cluster, the resolved ID is reported, and the env key + // derives from the resolved cluster's Spark version. + c := stubCompute{byNameID: "cid-123", byNameVersion: "15.4.x-scala2.12"} + ti, err := ResolveTarget(t.Context(), TargetFlags{ClusterName: "my-cluster"}, c, BundleTarget{}) + require.NoError(t, err) + assert.Equal(t, "cluster", ti.Source) + assert.Equal(t, "cid-123", ti.ClusterID) + assert.Equal(t, "15.4.x-scala2.12", ti.SparkVersion) + assert.Equal(t, "dbr/15.4.x-scala2.12", ti.EnvKey) +} + +func TestResolveClusterNameFlagError(t *testing.T) { + // An unknown or ambiguous name surfaces as E_RESOLVE. + c := stubCompute{byNameErr: errors.New("there are 2 instances of ClusterDetails named 'dup'")} + _, err := ResolveTarget(t.Context(), TargetFlags{ClusterName: "dup"}, c, BundleTarget{}) + var pe *PipelineError + require.ErrorAs(t, err, &pe) + assert.Equal(t, ErrResolve, pe.Code) +} + +func TestResolveClusterIdAndNameMutuallyExclusive(t *testing.T) { + // The library path rejects setting both --cluster-id and --cluster-name. + _, err := ResolveTarget(t.Context(), TargetFlags{Cluster: "abc", ClusterName: "xyz"}, stubCompute{}, BundleTarget{}) + var pe *PipelineError + require.ErrorAs(t, err, &pe) + assert.Equal(t, ErrResolve, pe.Code) +} + func TestResolveBundleNothingSelected(t *testing.T) { _, err := ResolveTarget(t.Context(), TargetFlags{}, stubCompute{}, BundleTarget{Selected: false}) var pe *PipelineError @@ -75,6 +112,10 @@ func (jobStubCompute) GetClusterSparkVersion(_ context.Context, _ string) (strin return "", nil } +func (jobStubCompute) GetClusterByName(_ context.Context, _ string) (string, string, error) { + return "", "", nil +} + func (s jobStubCompute) GetJobSparkVersion(_ context.Context, _ string) (string, bool, string, error) { return s.sparkVersion, s.isServerless, s.version, nil } From 8e83519278434a909f0084a65326837edb38d221 Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 17 Jul 2026 15:25:28 +0200 Subject: [PATCH 2/2] Address review: exclude terminated clusters in name resolution, list --cluster-name in no-target guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #5961 (isaac major + codex P3): - GetClusterByName no longer uses the SDK's GetByClusterName, which lists clusters in every state and errors on any name collision — a terminated cluster sharing a name with a live one would spuriously block resolution. List only non-terminated clusters (ListClustersFilterBy) and match by name; a name still ambiguous among active clusters, or matching none, is a genuine E_RESOLVE. - noTargetMessage now includes --cluster-name so the no-target guidance lists every valid target flag. - Acceptance: cluster-name-ambiguous now models two RUNNING clusters (genuine ambiguity) with the clearer error; no-target and json-error goldens updated. Follow-up review (anton-107, #5961): - Note on GetClusterByName that clusters/list omits clusters terminated >30 days ago, so such a name resolves as "no active cluster named". - Fix the ResolveTarget precedence doc comment to include the --cluster-name branch this PR inserts. - Add cluster-name-unknown acceptance case covering the unknown-name branch (0 matches), the counterpart to cluster-name-ambiguous. Co-authored-by: Isaac --- .../cluster-name-ambiguous/output.txt | 2 +- .../localenv/cluster-name-ambiguous/test.toml | 11 +++-- .../cluster-name-unknown/out.test.toml | 3 ++ .../localenv/cluster-name-unknown/output.txt | 7 +++ .../localenv/cluster-name-unknown/script | 1 + .../localenv/cluster-name-unknown/test.toml | 18 +++++++ acceptance/localenv/json-error/output.txt | 2 +- acceptance/localenv/no-target/output.txt | 2 +- cmd/environments/compute.go | 47 ++++++++++++++++--- libs/localenv/target.go | 4 +- 10 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 acceptance/localenv/cluster-name-unknown/out.test.toml create mode 100644 acceptance/localenv/cluster-name-unknown/output.txt create mode 100644 acceptance/localenv/cluster-name-unknown/script create mode 100644 acceptance/localenv/cluster-name-unknown/test.toml diff --git a/acceptance/localenv/cluster-name-ambiguous/output.txt b/acceptance/localenv/cluster-name-ambiguous/output.txt index b8c08108857..ece61571e20 100644 --- a/acceptance/localenv/cluster-name-ambiguous/output.txt +++ b/acceptance/localenv/cluster-name-ambiguous/output.txt @@ -1,5 +1,5 @@ preflight ok check -resolve error resolving cluster name "dup": get cluster by name "dup": there are 2 instances of ClusterDetails named 'dup' +resolve error resolving cluster name "dup": there are 2 active clusters named "dup"; use --cluster-id to disambiguate fetch pending merge pending provision pending diff --git a/acceptance/localenv/cluster-name-ambiguous/test.toml b/acceptance/localenv/cluster-name-ambiguous/test.toml index d8947aa5684..d98e399ce10 100644 --- a/acceptance/localenv/cluster-name-ambiguous/test.toml +++ b/acceptance/localenv/cluster-name-ambiguous/test.toml @@ -3,15 +3,18 @@ EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] [Env] DATABRICKS_LOCALENV_CONSTRAINT_SOURCE = "$DATABRICKS_HOST" -# Two clusters share the same name, so name→ID resolution is ambiguous: the -# Clusters API errors, surfaced as an actionable E_RESOLVE at the resolve phase. +# Two active clusters share the same name, so name→ID resolution is genuinely +# ambiguous and errors as an actionable E_RESOLVE at the resolve phase. +# (A terminated cluster sharing the name would be filtered out server-side via +# ListClustersFilterBy, so it does not cause a spurious collision — see +# GetClusterByName.) [[Server]] Pattern = "GET /api/2.1/clusters/list" Response.Body = ''' { "clusters": [ - {"cluster_id": "cid-123", "cluster_name": "dup", "spark_version": "15.4.x-scala2.12"}, - {"cluster_id": "cid-999", "cluster_name": "dup", "spark_version": "14.3.x-scala2.12"} + {"cluster_id": "cid-123", "cluster_name": "dup", "state": "RUNNING", "spark_version": "15.4.x-scala2.12"}, + {"cluster_id": "cid-999", "cluster_name": "dup", "state": "RUNNING", "spark_version": "14.3.x-scala2.12"} ] } ''' diff --git a/acceptance/localenv/cluster-name-unknown/out.test.toml b/acceptance/localenv/cluster-name-unknown/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/localenv/cluster-name-unknown/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/localenv/cluster-name-unknown/output.txt b/acceptance/localenv/cluster-name-unknown/output.txt new file mode 100644 index 00000000000..6a158bb005a --- /dev/null +++ b/acceptance/localenv/cluster-name-unknown/output.txt @@ -0,0 +1,7 @@ +preflight ok check +resolve error resolving cluster name "nope": no active cluster named "nope" +fetch pending +merge pending +provision pending +validate pending +For more detail, re-run with --debug, or --output json to share a structured report. diff --git a/acceptance/localenv/cluster-name-unknown/script b/acceptance/localenv/cluster-name-unknown/script new file mode 100644 index 00000000000..96ae54a868e --- /dev/null +++ b/acceptance/localenv/cluster-name-unknown/script @@ -0,0 +1 @@ +musterr $CLI environments setup-local --cluster-name nope --dry-run diff --git a/acceptance/localenv/cluster-name-unknown/test.toml b/acceptance/localenv/cluster-name-unknown/test.toml new file mode 100644 index 00000000000..23cd1410e08 --- /dev/null +++ b/acceptance/localenv/cluster-name-unknown/test.toml @@ -0,0 +1,18 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] + +[Env] +DATABRICKS_LOCALENV_CONSTRAINT_SOURCE = "$DATABRICKS_HOST" + +# No active cluster matches the requested name, so name→ID resolution finds +# nothing and errors as an actionable E_RESOLVE at the resolve phase. This is +# the unknown-name counterpart to cluster-name-ambiguous; the two share the +# single wrapping branch in GetClusterByName. +[[Server]] +Pattern = "GET /api/2.1/clusters/list" +Response.Body = ''' +{ + "clusters": [ + {"cluster_id": "cid-123", "cluster_name": "my-cluster", "state": "RUNNING", "spark_version": "15.4.x-scala2.12"} + ] +} +''' diff --git a/acceptance/localenv/json-error/output.txt b/acceptance/localenv/json-error/output.txt index ce887e10689..e99122aa349 100644 --- a/acceptance/localenv/json-error/output.txt +++ b/acceptance/localenv/json-error/output.txt @@ -35,7 +35,7 @@ "error": { "code": "E_NO_TARGET", "failurePhase": "resolve", - "message": "No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --serverless-version / --job-id", + "message": "No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-id", "diskMutated": false }, "durationMs": 0 diff --git a/acceptance/localenv/no-target/output.txt b/acceptance/localenv/no-target/output.txt index ede06bccdc5..46c5fa2199e 100644 --- a/acceptance/localenv/no-target/output.txt +++ b/acceptance/localenv/no-target/output.txt @@ -1,5 +1,5 @@ preflight ok uv [UV_VERSION] -resolve error No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --serverless-version / --job-id +resolve error No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-id fetch pending merge pending provision pending diff --git a/cmd/environments/compute.go b/cmd/environments/compute.go index 59bb5896d64..dc75c2e1d43 100644 --- a/cmd/environments/compute.go +++ b/cmd/environments/compute.go @@ -6,6 +6,7 @@ import ( "strconv" databricks "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/jobs" ) @@ -25,16 +26,48 @@ func (c sdkCompute) GetClusterSparkVersion(ctx context.Context, clusterID string // GetClusterByName resolves a cluster name to its ID and Spark version. // -// The SDK's GetByClusterName lists clusters and matches on name; it returns an -// error when the name is unknown or when more than one cluster shares it. Both -// are surfaced to the caller so they become an actionable E_RESOLVE rather than -// a silently-wrong target. +// It intentionally does not use the SDK's GetByClusterName, which lists clusters +// in every state and errors on any name collision: a terminated cluster sharing +// a name with a live one would then block resolution for a name the user +// reasonably considers unique. Instead we list only non-terminated clusters and +// match by name. A name that is still ambiguous among live clusters, or matches +// none, is a genuine error surfaced to the caller as an actionable E_RESOLVE. +// +// The clusters/list API omits clusters terminated more than 30 days ago, so a +// name that only ever belonged to such a cluster resolves as "no active cluster +// named"; this is acceptable since a long-terminated cluster is not a usable +// target and the user can always pass --cluster-id. func (c sdkCompute) GetClusterByName(ctx context.Context, name string) (string, string, error) { - d, err := c.w.Clusters.GetByClusterName(ctx, name) + clusters, err := c.w.Clusters.ListAll(ctx, compute.ListClustersRequest{ + FilterBy: &compute.ListClustersFilterBy{ + // TERMINATING is excluded alongside TERMINATED: a cluster on its way + // down is not a usable target, and keeping it would reintroduce the + // stale-collision problem this filter exists to avoid. + ClusterStates: []compute.State{ + compute.StatePending, + compute.StateRunning, + compute.StateRestarting, + compute.StateResizing, + }, + }, + }) if err != nil { - return "", "", fmt.Errorf("get cluster by name %q: %w", name, err) + return "", "", fmt.Errorf("list clusters to resolve name %q: %w", name, err) + } + var matches []compute.ClusterDetails + for _, cl := range clusters { + if cl.ClusterName == name { + matches = append(matches, cl) + } + } + switch len(matches) { + case 0: + return "", "", fmt.Errorf("no active cluster named %q", name) + case 1: + return matches[0].ClusterId, matches[0].SparkVersion, nil + default: + return "", "", fmt.Errorf("there are %d active clusters named %q; use --cluster-id to disambiguate", len(matches), name) } - return d.ClusterId, d.SparkVersion, nil } // GetJobSparkVersion inspects the job's configuration to determine compute type. diff --git a/libs/localenv/target.go b/libs/localenv/target.go index d92378c77c8..86b89bd56ef 100644 --- a/libs/localenv/target.go +++ b/libs/localenv/target.go @@ -37,7 +37,7 @@ type BundleTarget struct { // noTargetMessage is the actionable message shown when no target is selected, // matching spec §2.3. -const noTargetMessage = "No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --serverless-version / --job-id" +const noTargetMessage = "No compute target is selected. Select a cluster or serverless target, or pass --cluster-id / --cluster-name / --serverless-version / --job-id" // ValidateTargetFlags returns an error if more than one of the target flags is set. // Cobra marks them mutually exclusive too; this guards the library path. @@ -62,7 +62,7 @@ func ValidateTargetFlags(f TargetFlags) error { } // ResolveTarget resolves the compute target using ordered precedence: -// --cluster-id flag → --serverless-version flag → --job-id flag → bundle target. +// --cluster-id → --cluster-name → --serverless-version → --job-id → bundle target. // PythonVersion is left empty; it is filled later from constraint data. // // Incompatible flags are rejected up front: without this a library caller that