From 15f667df55573e574534eb3bcc511e47755c9c8d Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Fri, 17 Jul 2026 15:01:26 +0200 Subject: [PATCH 1/2] Accept bare serverless version numbers (5, not v5) Per follow-up on the [P0] CLI Changes spec, --serverless-version is documented to take a bare number (e.g. 5). NormalizeServerless already accepts a bare number and remains tolerant of a v-prefix (v5/V5); both map to the serverless-vN env key, so the environment-repo layout is unchanged. - Flag help now shows 'e.g. 5'; the E_ENV_UNSUPPORTED hint suggests '--serverless-version 5'. - defaultServerlessVersion is stored in the bare form ('5'); it still resolves to serverless-v5 via NormalizeServerless. - Acceptance scripts pass the bare form; env-key goldens are unchanged (still serverless-vN). Unit tests cover bare and v-prefixed input plus the default constant. Co-authored-by: Isaac --- acceptance/localenv/constraints-only/output.txt | 2 +- acceptance/localenv/constraints-only/script | 2 +- acceptance/localenv/env-unsupported/output.txt | 2 +- acceptance/localenv/flag-conflict-json/script | 2 +- acceptance/localenv/flag-conflict/script | 2 +- acceptance/localenv/help/output.txt | 2 +- acceptance/localenv/manager-unsupported/script | 2 +- acceptance/localenv/serverless-check/output.txt | 2 +- acceptance/localenv/serverless-check/script | 2 +- acceptance/localenv/serverless-json/output.txt | 2 +- acceptance/localenv/serverless-json/script | 2 +- cmd/environments/sync.go | 2 +- libs/localenv/constraints.go | 2 +- libs/localenv/envkey.go | 5 +++-- libs/localenv/envkey_test.go | 12 ++++++++++++ 15 files changed, 28 insertions(+), 15 deletions(-) diff --git a/acceptance/localenv/constraints-only/output.txt b/acceptance/localenv/constraints-only/output.txt index 44f4f96f282..a85265f8ccc 100644 --- a/acceptance/localenv/constraints-only/output.txt +++ b/acceptance/localenv/constraints-only/output.txt @@ -1,5 +1,5 @@ ->>> [CLI] environments setup-local --serverless-version v4 --constraints-only --dry-run --output json +>>> [CLI] environments setup-local --serverless-version 4 --constraints-only --dry-run --output json { "schemaVersion": 1, "command": "environments setup-local", diff --git a/acceptance/localenv/constraints-only/script b/acceptance/localenv/constraints-only/script index fc7a8d4201f..b4f777e7980 100644 --- a/acceptance/localenv/constraints-only/script +++ b/acceptance/localenv/constraints-only/script @@ -1 +1 @@ -trace $CLI environments setup-local --serverless-version v4 --constraints-only --dry-run --output json +trace $CLI environments setup-local --serverless-version 4 --constraints-only --dry-run --output json diff --git a/acceptance/localenv/env-unsupported/output.txt b/acceptance/localenv/env-unsupported/output.txt index c586b553371..c13d1a1d7d8 100644 --- a/acceptance/localenv/env-unsupported/output.txt +++ b/acceptance/localenv/env-unsupported/output.txt @@ -1,6 +1,6 @@ preflight ok check resolve ok source=cluster envKey=dbr/15.4.x-scala2.12 -fetch error no published environment for "dbr/15.4.x-scala2.12". If this is a new runtime, try the latest LTS target (e.g. --serverless-version v4 or a supported --cluster-id DBR): GET [DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml: environment key not found +fetch error no published environment for "dbr/15.4.x-scala2.12". If this is a new runtime, try the latest LTS target (e.g. --serverless-version 5 or a supported --cluster-id DBR): GET [DATABRICKS_URL]/dbr/15.4.x-scala2.12/pyproject.toml: environment key not found merge pending provision pending validate pending diff --git a/acceptance/localenv/flag-conflict-json/script b/acceptance/localenv/flag-conflict-json/script index a41f74e8de6..408aae874bd 100644 --- a/acceptance/localenv/flag-conflict-json/script +++ b/acceptance/localenv/flag-conflict-json/script @@ -1 +1 @@ -musterr $CLI environments setup-local --cluster-id abc --serverless-version v4 --output json +musterr $CLI environments setup-local --cluster-id abc --serverless-version 4 --output json diff --git a/acceptance/localenv/flag-conflict/script b/acceptance/localenv/flag-conflict/script index 1a63e078c3d..b251350e3c4 100644 --- a/acceptance/localenv/flag-conflict/script +++ b/acceptance/localenv/flag-conflict/script @@ -1 +1 @@ -musterr $CLI environments setup-local --cluster-id abc --serverless-version v4 +musterr $CLI environments setup-local --cluster-id abc --serverless-version 4 diff --git a/acceptance/localenv/help/output.txt b/acceptance/localenv/help/output.txt index 2e0a6d1e2e4..f96a2db80cf 100644 --- a/acceptance/localenv/help/output.txt +++ b/acceptance/localenv/help/output.txt @@ -16,7 +16,7 @@ Flags: --dry-run compute the plan without writing files or provisioning -h, --help help for setup-local --job-id string job ID to use as the compute target - --serverless-version string serverless version to use as the compute target (e.g. v4) + --serverless-version string serverless version to use as the compute target (e.g. 5) Global Flags: --debug enable debug logging diff --git a/acceptance/localenv/manager-unsupported/script b/acceptance/localenv/manager-unsupported/script index 24b88530e4c..f7a1e15df54 100644 --- a/acceptance/localenv/manager-unsupported/script +++ b/acceptance/localenv/manager-unsupported/script @@ -1 +1 @@ -musterr $CLI environments setup-local --serverless-version v4 --dry-run +musterr $CLI environments setup-local --serverless-version 4 --dry-run diff --git a/acceptance/localenv/serverless-check/output.txt b/acceptance/localenv/serverless-check/output.txt index 6df4933f44c..2b36b789ace 100644 --- a/acceptance/localenv/serverless-check/output.txt +++ b/acceptance/localenv/serverless-check/output.txt @@ -1,5 +1,5 @@ ->>> [CLI] environments setup-local --serverless-version v4 --dry-run +>>> [CLI] environments setup-local --serverless-version 4 --dry-run preflight ok check resolve ok source=serverless envKey=serverless/serverless-v4 fetch ok source=[DATABRICKS_URL]/serverless/serverless-v4/pyproject.toml fromCache=false diff --git a/acceptance/localenv/serverless-check/script b/acceptance/localenv/serverless-check/script index 3c14eef8688..c02eb922237 100644 --- a/acceptance/localenv/serverless-check/script +++ b/acceptance/localenv/serverless-check/script @@ -1 +1 @@ -trace $CLI environments setup-local --serverless-version v4 --dry-run +trace $CLI environments setup-local --serverless-version 4 --dry-run diff --git a/acceptance/localenv/serverless-json/output.txt b/acceptance/localenv/serverless-json/output.txt index cd9d3e2ad48..056a3fa34f2 100644 --- a/acceptance/localenv/serverless-json/output.txt +++ b/acceptance/localenv/serverless-json/output.txt @@ -1,5 +1,5 @@ ->>> [CLI] environments setup-local --serverless-version v4 --dry-run --output json +>>> [CLI] environments setup-local --serverless-version 4 --dry-run --output json { "schemaVersion": 1, "command": "environments setup-local", diff --git a/acceptance/localenv/serverless-json/script b/acceptance/localenv/serverless-json/script index 56b8372bf43..0bda75a032b 100644 --- a/acceptance/localenv/serverless-json/script +++ b/acceptance/localenv/serverless-json/script @@ -1 +1 @@ -trace $CLI environments setup-local --serverless-version v4 --dry-run --output json +trace $CLI environments setup-local --serverless-version 4 --dry-run --output json diff --git a/cmd/environments/sync.go b/cmd/environments/sync.go index fa2d450bccb..441e1be64b0 100644 --- a/cmd/environments/sync.go +++ b/cmd/environments/sync.go @@ -49,7 +49,7 @@ env-owned sections are refreshed, user-owned content is preserved).`, 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("serverless-version", "", "serverless version to use as the compute target (e.g. 5)") 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") cmd.Flags().Bool("dry-run", false, "compute the plan without writing files or provisioning") diff --git a/libs/localenv/constraints.go b/libs/localenv/constraints.go index 6f381cc1553..b048d6ef85d 100644 --- a/libs/localenv/constraints.go +++ b/libs/localenv/constraints.go @@ -184,7 +184,7 @@ func FetchConstraints(ctx context.Context, baseURL, envKey, cacheDir string, wri // fallback: the target resolved to an environment that isn't published. if errors.Is(fetchErr, errEnvKeyNotFound) { return nil, NewError(ErrEnvUnsupported, fetchErr, - "no published environment for %q. If this is a new runtime, try the latest LTS target (e.g. --serverless-version v4 or a supported --cluster-id DBR)", envKey) + "no published environment for %q. If this is a new runtime, try the latest LTS target (e.g. --serverless-version 5 or a supported --cluster-id DBR)", envKey) } // Network or HTTP failure: attempt to serve from cache. diff --git a/libs/localenv/envkey.go b/libs/localenv/envkey.go index 23dc0f9ca53..2dcac778e0e 100644 --- a/libs/localenv/envkey.go +++ b/libs/localenv/envkey.go @@ -20,10 +20,11 @@ var clauseRe = regexp.MustCompile(`^(>=|<=|===|==|~=|!=|<|>)?\s*(\d+)\.(\d+)(\.\ // latest LTS (spec §4.3 / §target-resolution); VS Code resolves the real version // itself and passes --serverless-version explicitly, so this fallback only // applies when the version is genuinely unknown. -const defaultServerlessVersion = "v5" +const defaultServerlessVersion = "5" // NormalizeServerless returns the canonical "vN" spelling of a serverless -// version accepting "4", "v4", or "V4". +// version. The documented input is a bare number ("5"), but a "v"-prefixed form +// ("v5"/"V5") is also accepted; both map to the "serverless-vN" env key. func NormalizeServerless(version string) string { return "v" + strings.TrimPrefix(strings.ToLower(version), "v") } diff --git a/libs/localenv/envkey_test.go b/libs/localenv/envkey_test.go index 898e93434b6..da93e59c7d1 100644 --- a/libs/localenv/envkey_test.go +++ b/libs/localenv/envkey_test.go @@ -8,9 +8,21 @@ import ( ) func TestEnvKeyForServerless(t *testing.T) { + // The documented input is a bare number; a "v"-prefixed form is also accepted. + // Both map to the "serverless-vN" env key. for _, in := range []string{"4", "v4", "V4"} { assert.Equal(t, "serverless/serverless-v4", EnvKeyForServerless(in)) } + for _, in := range []string{"5", "v5", "V5"} { + assert.Equal(t, "serverless/serverless-v5", EnvKeyForServerless(in)) + } +} + +func TestDefaultServerlessVersionIsBareNumber(t *testing.T) { + // The default stand-in is stored in the documented bare form; it still maps + // to the serverless-vN env key via NormalizeServerless. + assert.Equal(t, "5", defaultServerlessVersion) + assert.Equal(t, "serverless/serverless-v5", EnvKeyForServerless(defaultServerlessVersion)) } func TestEnvKeyForSparkVersion(t *testing.T) { From 840642ffcede9b70895aabd14a0b704d1ddbcbdc Mon Sep 17 00:00:00 2001 From: Grigory Panov Date: Mon, 20 Jul 2026 10:43:27 +0200 Subject: [PATCH 2/2] Address review: validate --serverless-version format, pin the default test Follow-up to #5965 review (anton-107): - [low] NormalizeServerless did no validation, so malformed values (vv5, v, ' 5', 5x, latest) resolved to a bogus serverless-vN env key and only 404'd two phases later at fetch with a generic E_ENV_UNSUPPORTED. Add ValidServerlessVersion (bare number, or v-prefixed) and reject at the resolve phase with an actionable E_RESOLVE, per the repo's reject-early rule. - [low] The two default-serverless tests compared the code against the same constant it uses, so they'd pass for any value. Assert the concrete serverless/serverless-v5 key so the v5 default is genuinely pinned. Co-authored-by: Isaac --- libs/localenv/envkey.go | 13 +++++++++++++ libs/localenv/envkey_test.go | 9 +++++++++ libs/localenv/target.go | 3 +++ libs/localenv/target_test.go | 18 ++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/libs/localenv/envkey.go b/libs/localenv/envkey.go index 2dcac778e0e..4e947b0c716 100644 --- a/libs/localenv/envkey.go +++ b/libs/localenv/envkey.go @@ -22,9 +22,22 @@ var clauseRe = regexp.MustCompile(`^(>=|<=|===|==|~=|!=|<|>)?\s*(\d+)\.(\d+)(\.\ // applies when the version is genuinely unknown. const defaultServerlessVersion = "5" +// serverlessVersionRe matches an accepted --serverless-version input: a bare +// number ("5", the documented form) or a "v"-prefixed one ("v5"/"V5"). +var serverlessVersionRe = regexp.MustCompile(`^[vV]?[0-9]+$`) + +// ValidServerlessVersion reports whether s is an accepted serverless version +// input. It is validated at resolve time so a malformed value (e.g. "vv5", "v", +// " 5") fails fast with an actionable error rather than resolving to a bogus +// env key that only 404s two phases later at fetch. +func ValidServerlessVersion(s string) bool { + return serverlessVersionRe.MatchString(s) +} + // NormalizeServerless returns the canonical "vN" spelling of a serverless // version. The documented input is a bare number ("5"), but a "v"-prefixed form // ("v5"/"V5") is also accepted; both map to the "serverless-vN" env key. +// Callers should validate with ValidServerlessVersion first. func NormalizeServerless(version string) string { return "v" + strings.TrimPrefix(strings.ToLower(version), "v") } diff --git a/libs/localenv/envkey_test.go b/libs/localenv/envkey_test.go index da93e59c7d1..c502cac57b4 100644 --- a/libs/localenv/envkey_test.go +++ b/libs/localenv/envkey_test.go @@ -25,6 +25,15 @@ func TestDefaultServerlessVersionIsBareNumber(t *testing.T) { assert.Equal(t, "serverless/serverless-v5", EnvKeyForServerless(defaultServerlessVersion)) } +func TestValidServerlessVersion(t *testing.T) { + for _, ok := range []string{"5", "4", "v5", "V5", "17", "0"} { + assert.True(t, ValidServerlessVersion(ok), "%q should be valid", ok) + } + for _, bad := range []string{"", "v", "vv5", "5x", "latest", " 5", "5 ", "v5.1", "-5"} { + assert.False(t, ValidServerlessVersion(bad), "%q should be invalid", bad) + } +} + func TestEnvKeyForSparkVersion(t *testing.T) { assert.Equal(t, "dbr/15.4.x-scala2.12", EnvKeyForSparkVersion("15.4.x-scala2.12")) } diff --git a/libs/localenv/target.go b/libs/localenv/target.go index 814f6d722b8..119bc7a0538 100644 --- a/libs/localenv/target.go +++ b/libs/localenv/target.go @@ -104,6 +104,9 @@ func ResolveTarget(ctx context.Context, f TargetFlags, c ComputeClient, bt Bundl } if f.Serverless != "" { + if !ValidServerlessVersion(f.Serverless) { + return nil, NewError(ErrResolve, nil, "invalid --serverless-version %q: expected a version number like 5", f.Serverless) + } return &TargetInfo{ Source: "serverless", ServerlessVersion: NormalizeServerless(f.Serverless), diff --git a/libs/localenv/target_test.go b/libs/localenv/target_test.go index 6438921a642..a202cac683c 100644 --- a/libs/localenv/target_test.go +++ b/libs/localenv/target_test.go @@ -37,6 +37,24 @@ func TestResolveServerlessFlag(t *testing.T) { assert.Equal(t, "serverless/serverless-v4", ti.EnvKey) } +func TestResolveServerlessFlagBareNumber(t *testing.T) { + // The documented input is a bare number; it normalizes to the vN env key. + ti, err := ResolveTarget(t.Context(), TargetFlags{Serverless: "5"}, stubCompute{}, BundleTarget{}) + require.NoError(t, err) + assert.Equal(t, "serverless/serverless-v5", ti.EnvKey) +} + +func TestResolveServerlessFlagRejectsMalformed(t *testing.T) { + // Malformed values fail fast at resolve (E_RESOLVE) rather than resolving to + // a bogus env key that only 404s at fetch. + for _, bad := range []string{"vv5", "v", " 5", "5x", "latest"} { + _, err := ResolveTarget(t.Context(), TargetFlags{Serverless: bad}, stubCompute{}, BundleTarget{}) + var pe *PipelineError + require.ErrorAs(t, err, &pe, "input %q should error", bad) + assert.Equal(t, ErrResolve, pe.Code, "input %q", bad) + } +} + func TestResolveClusterFlag(t *testing.T) { c := stubCompute{clusterVersion: "15.4.x-scala2.12"} ti, err := ResolveTarget(t.Context(), TargetFlags{Cluster: "abc"}, c, BundleTarget{})