Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/cli/setup-local-provision-conflict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`databricks environments setup-local` now reports a distinct `E_PROVISION_CONFLICT` error code in `--output json` when `uv sync` fails and the merge phase detected a version conflict between the project's dependencies and the pins written for the target environment (the same conflict surfaced as a `W_USER_CONSTRAINT_CONFLICT` warning). Other sync failures continue to report `E_PROVISION`.
31 changes: 31 additions & 0 deletions acceptance/localenv/provision-conflict/fake-uv.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
#
# Fake `uv` for the provision-conflict acceptance test. A real `uv sync` version
# conflict needs a specific managed Python installed offline, which no CI machine
# guarantees, so this stub stands in for uv on PATH and drives the provision phase
# deterministically:
#
# uv --version -> a version banner (EnsureAvailable preflight)
# uv python install ... -> succeeds (EnsurePython)
# uv sync ... -> exits non-zero, printing uv's real resolver-conflict
# banner to stderr
#
# setup-local reports E_PROVISION_CONFLICT because the merge phase already flagged
# the pip==24.0 vs pip<24 conflict (W_USER_CONSTRAINT_CONFLICT); the stderr here is
# realistic uv output that ends up in the error message, not the classification
# signal.
set -euo pipefail

if [[ "${1:-}" == "--version" ]]; then
echo "uv 0.0.0-fake"
exit 0
fi

if [[ "${1:-}" == "sync" ]]; then
echo " × No solution found when resolving dependencies:" >&2
echo " ╰─▶ Because your project depends on pip==24.0 and pip<24, we can conclude that your project's requirements are unsatisfiable." >&2
exit 1
fi

# Every other invocation (notably `uv python install <minor>`) succeeds silently.
exit 0
3 changes: 3 additions & 0 deletions acceptance/localenv/provision-conflict/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions acceptance/localenv/provision-conflict/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"schemaVersion": 1,
"command": "environments setup-local",
"ok": false,
"mode": "default",
"dryRun": false,
"compute": {
"source": "serverless",
"serverlessVersion": "v4",
"envKey": "serverless/serverless-v4"
},
"resolved": {
"pythonVersion": "3.12",
"dbconnectVersion": "17.2.0",
"artifactSource": "network"
},
"greenfield": false,
"phases": [
{
"phase": "preflight",
"status": "ok"
},
{
"phase": "resolve",
"status": "ok"
},
{
"phase": "fetch",
"status": "ok"
},
{
"phase": "merge",
"status": "ok"
},
{
"phase": "provision",
"status": "error"
},
{
"phase": "validate",
"status": "pending"
}
],
"warnings": [
{
"code": "W_DBCONNECT_PIN_OVERRIDDEN",
"message": "databricks-connect \"databricks-connect~=16.0.0\" is replaced by the environment's \"databricks-connect~=17.2.0\""
},
{
"code": "W_USER_CONSTRAINT_CONFLICT",
"message": "dependency \"pip==24.0\" conflicts with the environment constraint \"pip\u003c24\""
}
],
"error": {
"code": "E_PROVISION_CONFLICT",
"failurePhase": "provision",
"message": "uv sync failed: × No solution found when resolving dependencies:\n ╰─▶ Because your project depends on pip==24.0 and pip\u003c24, we can conclude that your project's requirements are unsatisfiable.: [TEST_TMP_DIR]/uv sync --python 3.12: exit status 1",
"diskMutated": true
},
"backupPath": "[TEST_TMP_DIR]/pyproject.toml.bak",
"durationMs": [DURATION_MS]
}
7 changes: 7 additions & 0 deletions acceptance/localenv/provision-conflict/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "demo"
requires-python = ">=3.12"
dependencies = ["pip==24.0"]

[dependency-groups]
dev = ["databricks-connect~=16.0.0"]
19 changes: 19 additions & 0 deletions acceptance/localenv/provision-conflict/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# A `uv sync` that fails because the merged dependency pins can't be resolved is
# reported as E_PROVISION_CONFLICT — distinct from a generic E_PROVISION — with the
# constraints already written to disk (diskMutated=true), the contract the
# extension's recovery flow depends on. Here the user pins pip==24.0 while the
# remote constraints pin pip<24, so the merged project is unsatisfiable.
#
# A real `uv sync` version conflict needs a specific managed Python installed
# offline, which CI cannot guarantee, so put a fake `uv` on PATH that fails
# `uv sync` with uv's real resolver-conflict banner (see fake-uv.sh).
mv fake-uv.sh uv

cleanup() {
rm -f uv
}
trap cleanup EXIT

export PATH="$(pwd):$PATH"

musterr $CLI environments setup-local --serverless-version 4 --output json
26 changes: 26 additions & 0 deletions acceptance/localenv/provision-conflict/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"]

# The fake `uv` on PATH is a bash script; the psql acceptance tests document that
# this binary-on-PATH technique does not work on the Windows GitHub runner.
GOOS.windows = false

# setup-local writes pyproject.toml through the merge phase (backing up the
# committed one) before provision runs; the assertion is the JSON output, so ignore
# the mutated file and its backup. No .venv is created because the fake `uv sync`
# fails before provisioning one.
Ignore = ["pyproject.toml", "pyproject.toml.bak"]

Env.DATABRICKS_LOCALENV_CONSTRAINT_SOURCE_URL_TEST_OVERRIDE = "$DATABRICKS_HOST"

[[Server]]
Pattern = "GET /serverless/serverless-v4/pyproject.toml"
Response.Body = '''
[project]
requires-python = ">=3.12"

[dependency-groups]
dev = ["databricks-connect~=17.2.0"]

[tool.uv]
constraint-dependencies = ["pip<24"]
'''
2 changes: 2 additions & 0 deletions cmd/environments/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ func errorCodeType(code libslocalenv.ErrorCode) protos.SetupLocalErrorCode {
return protos.SetupLocalErrorCodePythonInstall
case libslocalenv.ErrProvision:
return protos.SetupLocalErrorCodeProvision
case libslocalenv.ErrProvisionConflict:
return protos.SetupLocalErrorCodeProvisionConflict
case libslocalenv.ErrValidate:
return protos.SetupLocalErrorCodeValidate
case libslocalenv.ErrCanceled:
Expand Down
1 change: 1 addition & 0 deletions cmd/environments/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func TestErrorCodeCoversLocalenv(t *testing.T) {
libslocalenv.ErrMerge,
libslocalenv.ErrPythonInstall,
libslocalenv.ErrProvision,
libslocalenv.ErrProvisionConflict,
libslocalenv.ErrValidate,
libslocalenv.ErrCanceled,
}
Expand Down
35 changes: 34 additions & 1 deletion libs/localenv/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,27 @@ func (p *Pipeline) provision(ctx context.Context, pyMinor string) error {
return p.fail(PhaseProvision, true, asPipelineError(err, ErrPythonInstall, "ensure python %s failed", pyMinor))
}
if err := p.PM.Provision(ctx, p.ProjectDir, pyMinor); err != nil {
return p.fail(PhaseProvision, true, asPipelineError(err, ErrProvision, "provision failed"))
pe := asPipelineError(err, ErrProvision, "provision failed")
// When the merge phase already flagged a provable version conflict between
// the user's dependencies and the pins this command wrote
// (W_USER_CONSTRAINT_CONFLICT), a failing uv sync is that conflict surfacing.
// Report the distinct E_PROVISION_CONFLICT so a caller can act on it — relax
// the written pins and retry — instead of the generic E_PROVISION every other
// sync failure (build backend, permissions, transport, an unavailable package)
// keeps. Gating on the merge signal rather than uv's stderr keeps the code
// precise: it fires only when a conflict the CLI itself detected is present.
//
// The warning means the merged pins are provably unsatisfiable, so a real
// conflict exists to fix regardless of what uv reported. If an unrelated
// failure (e.g. a transport error) happens to surface first, the code still
// truthfully says a conflict is present — relaxing the pins is then a
// necessary step, even if not the whole fix. We deliberately do not also parse
// uv's stderr to disambiguate: uv is left as the source of truth for whether
// sync fails, and the CLI's own detection classifies why.
if p.hasConstraintConflictWarning() {
pe.Code = ErrProvisionConflict
}
return p.fail(PhaseProvision, true, pe)
}
if err := p.PM.PostProvision(ctx, p.ProjectDir); err != nil {
return p.fail(PhaseProvision, true, asPipelineError(err, ErrProvision, "post-provision failed"))
Expand All @@ -558,6 +578,19 @@ func (p *Pipeline) provision(ctx context.Context, pyMinor string) error {
return nil
}

// hasConstraintConflictWarning reports whether the merge phase recorded a provable
// user/environment version conflict (W_USER_CONSTRAINT_CONFLICT). It reads the
// warnings already accumulated on the Result, which the merge phase populates
// before provision runs, so it is only meaningful once merge has completed.
func (p *Pipeline) hasConstraintConflictWarning() bool {
for _, w := range p.res.Warnings {
if w.Code == WarnUserConstraintConflict {
return true
}
}
return false
}

// validate reads the Python and databricks-connect versions from the venv and
// populates the venv path. dbcPin is "" in constraints-only mode, where the DB
// Connect assertion is skipped.
Expand Down
92 changes: 92 additions & 0 deletions libs/localenv/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ func (c cancelPM) Provision(ctx context.Context, _, _ string) error {
}, "uv sync")
}

// provisionFailPM fails `uv sync` with the given error, so a test can drive the
// provision phase to failure without a real uv. EnsurePython succeeds first, as it
// does in a real run before sync. Validate is never reached.
type provisionFailPM struct {
fakePM
err error
}

func (p provisionFailPM) Provision(context.Context, string, string) error { return p.err }

func writeProject(t *testing.T) string {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(`[project]
Expand Down Expand Up @@ -432,6 +442,88 @@ func TestPipelineProvisionsAndValidatesExisting(t *testing.T) {
assert.FileExists(t, filepath.Join(dir, "pyproject.toml.bak"))
}

// syncFailure builds the *PipelineError a failing uv sync returns (uvFailure with
// ErrProvision), so the provision tests exercise the same shape the real
// uvManager produces.
func syncFailure(stderr string) error {
return uvFailure(ErrProvision, &process.ProcessError{
Command: "uv sync",
Err: errors.New("exit status 1"),
Stderr: stderr,
}, "uv sync")
}

func TestPipelineProvisionConflictWhenMergeFlagsConflict(t *testing.T) {
// The user pins pip==24.0 while the environment's constraint-dependencies pin
// pip<24 — a provably disjoint range — so the merge records
// W_USER_CONSTRAINT_CONFLICT. A failing uv sync in that state is that conflict
// surfacing, so it is reported as E_PROVISION_CONFLICT (not generic E_PROVISION),
// at the provision phase, with disk already mutated by the merge.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(`[project]
name = "demo"
requires-python = ">=3.12"
dependencies = ["pip==24.0"]

[dependency-groups]
dev = ["databricks-connect~=16.0.0"]
`), 0o644))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[project]
requires-python = ">=3.12"

[dependency-groups]
dev = ["databricks-connect~=17.2.0"]

[tool.uv]
constraint-dependencies = ["pip<24"]
`))
}))
defer srv.Close()

p := &Pipeline{
Mode: ModeDefault, ProjectDir: dir,
ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(),
Flags: ComputeFlags{Serverless: "v4"},
Compute: stubCompute{}, PM: provisionFailPM{err: syncFailure("No solution found when resolving dependencies")},
}
res, err := p.Run(t.Context())
var pe *PipelineError
require.ErrorAs(t, err, &pe)
assert.Equal(t, ErrProvisionConflict, pe.Code)
assert.Equal(t, PhaseProvision, pe.FailurePhase)
assert.True(t, pe.DiskMutated, "the merge wrote the pins before provision failed")
require.NotNil(t, res.Error)
assert.Equal(t, ErrProvisionConflict, res.Error.Code)
// The merge conflict warning that gates the code must be present.
assert.Contains(t, codes(res.Warnings), WarnUserConstraintConflict)
}

func TestPipelineProvisionGenericFailureWithoutConflictWarning(t *testing.T) {
// writeProject declares no dependency that conflicts with the environment
// constraints, so the merge records no W_USER_CONSTRAINT_CONFLICT. A failing uv
// sync — even one whose stderr looks like a resolver error — is then a generic
// E_PROVISION: the code is gated on the CLI's own conflict detection, not on uv
// output.
dir := writeProject(t)
srv := newTestServer(t)
defer srv.Close()

p := &Pipeline{
Mode: ModeDefault, ProjectDir: dir,
ConstraintBaseURL: srv.URL, CacheDir: t.TempDir(),
Flags: ComputeFlags{Serverless: "v4"},
Compute: stubCompute{}, PM: provisionFailPM{err: syncFailure("No solution found when resolving dependencies")},
}
res, err := p.Run(t.Context())
var pe *PipelineError
require.ErrorAs(t, err, &pe)
assert.Equal(t, ErrProvision, pe.Code)
assert.Equal(t, PhaseProvision, pe.FailurePhase)
assert.True(t, pe.DiskMutated)
assert.NotContains(t, codes(res.Warnings), WarnUserConstraintConflict)
}

func TestPipelineDryRunOmitsFabricatedDBConnectVersion(t *testing.T) {
// A major-only pin like ~=17.0 (serverless, environments#15) is not a concrete
// version. Under --dry-run validate never corrects the reported value, so it
Expand Down
3 changes: 2 additions & 1 deletion libs/localenv/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ const (
ErrWrite ErrorCode = "E_WRITE" // merge: greenfield write failed
ErrMerge ErrorCode = "E_MERGE" // merge: existing-project merge failed
ErrPythonInstall ErrorCode = "E_PYTHON_INSTALL" // provision: uv python install failed
ErrProvision ErrorCode = "E_PROVISION" // provision: uv sync failed
ErrProvision ErrorCode = "E_PROVISION" // provision: uv sync failed (generic)
ErrProvisionConflict ErrorCode = "E_PROVISION_CONFLICT" // provision: uv sync failed on a merge-detected version conflict
ErrValidate ErrorCode = "E_VALIDATE" // validate: post-provision version mismatch

// ErrCanceled is not in the spec's error-code table: it reports a user/parent
Expand Down
1 change: 1 addition & 0 deletions libs/telemetry/protos/setup_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (
SetupLocalErrorCodeMerge SetupLocalErrorCode = "E_MERGE"
SetupLocalErrorCodePythonInstall SetupLocalErrorCode = "E_PYTHON_INSTALL"
SetupLocalErrorCodeProvision SetupLocalErrorCode = "E_PROVISION"
SetupLocalErrorCodeProvisionConflict SetupLocalErrorCode = "E_PROVISION_CONFLICT"
SetupLocalErrorCodeValidate SetupLocalErrorCode = "E_VALIDATE"
SetupLocalErrorCodeCanceled SetupLocalErrorCode = "E_CANCELED"
)
Expand Down
Loading