diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5bff4cf9935..b8b1208686c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -35,6 +35,11 @@ jobs: - name: Fail if go.mod/go.sum changed run: git diff --exit-code + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.8.9" + - name: Run Go lint checks (does not include formatting checks) run: go tool -modfile=tools/task/go.mod task lint @@ -44,11 +49,6 @@ jobs: version: "0.9.1" args: "format --check" - - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: "0.8.9" - - name: "task fmt: Python and Go formatting" # Python formatting is already checked above, but this also checks Go and YAML formatting run: | diff --git a/Taskfile.yml b/Taskfile.yml index 488e2284cc6..d6417f271e7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -12,8 +12,10 @@ vars: # a separate static `**/testdata/**` glob, not this script. # Limitation: git grep only scans tracked files; new //go:embed directives in # untracked files are missed until the file is staged or committed. + # Run via uv with a pinned interpreter floor so the helper works on hosts whose + # default python3 is older than the 3.11 this repo's scripts target. EMBED_SOURCES: - sh: 'python3 tools/list_embeds.py' + sh: 'uv run -p ">=3.11" --no-project python tools/list_embeds.py' # pydabs-* tasks live in python/Taskfile.yml so `task pydabs-foo` works when # run from python/. Flattened so they keep their `pydabs-` names at the root. @@ -948,7 +950,7 @@ tasks: generates: - bundle/direct/dresources/apitypes.generated.yml cmds: - - "sh -c 'python3 bundle/direct/tools/generate_apitypes.py .codegen/cli.json acceptance/bundle/refschema/out.fields.txt > bundle/direct/dresources/apitypes.generated.yml'" + - "sh -c 'uv run -p \">=3.11\" --no-project python bundle/direct/tools/generate_apitypes.py .codegen/cli.json acceptance/bundle/refschema/out.fields.txt > bundle/direct/dresources/apitypes.generated.yml'" generate-direct-resources: desc: Generate direct engine resources YAML diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 2d3326b23d6..f5a33158a0f 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -188,6 +188,22 @@ func hasRunFilter() bool { return f != nil && strings.Contains(f.Value.String(), "=") } +// requirePrerequisites verifies external tool prerequisites before doing any +// work, so a stale toolchain fails fast with an actionable message instead of +// producing confusing diffs deep into the run. +func requirePrerequisites(t *testing.T) { + // Scripts use jq 1.7 features (the pick/1 builtin and the `.foo.[]` iteration syntax). + internal.RequireJQ(t, "1.7") + // uv builds the databricks-bundles wheel and provides the test interpreter + // via `uv python find`, which landed in the 0.3 line. + internal.RequireUV(t, "0.4") + // ruff 0.9.1 is pinned across the repo (python/pyproject.toml, Taskfile.yml); + // the check-formatting test's golden output assumes its formatter behavior. + internal.RequireRuff(t, "0.9.1") + // Acceptance scripts import the stdlib tomllib module, added in Python 3.11. + internal.EnsurePython(t, "3.11") +} + func testAccept(t *testing.T, inprocessMode bool, singleTest string) int { if testdiff.OverwriteMode && !hasRunFilter() { Subset = true @@ -229,6 +245,8 @@ func testAccept(t *testing.T, inprocessMode bool, singleTest string) int { os.Unsetenv(v) //nolint:usetesting // t.Setenv cannot unset } + requirePrerequisites(t) + buildDir := getBuildDir(t, cwd, runtime.GOOS, runtime.GOARCH) // Set up terraform for tests. Skip on DBR - tests with RunsOnDbr only use direct deployment. @@ -759,6 +777,21 @@ func runTest(t *testing.T, // into compared output. Tests can override this via [Env] in test.toml. cmd.Env = append(cmd.Env, "DATABRICKS_CLI_DISABLE_UPDATE_CHECK=true") + // Neutralize Databricks-internal development-environment interference so + // acceptance tests behave the same as on CI (which has none of this). Two + // sources both reach the blocking proxy on every git invocation: + // + // 1. A command-timing shim that wraps git (ahead of the real binary on + // PATH) and POSTs per-command metrics over the network. + // COMMAND_TIMER_DISABLE=1 makes it pass through without the beacon. + // 2. A managed global git config installs a core.hooksPath whose hooks + // (secret scanning, etc.) also beacon metrics. Ignoring the global and + // system git config disables those hooks and keeps tests hermetic; tests + // configure the repos they create via git-repo-init locally. + cmd.Env = append(cmd.Env, "COMMAND_TIMER_DISABLE=1") + cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull) + cmd.Env = append(cmd.Env, "GIT_CONFIG_SYSTEM="+os.DevNull) + for _, kv := range testEnv { key, value, _ := strings.Cut(kv, "=") // Only add replacement by default if value is part of EnvMatrix with more than 1 option and length is 4 or more chars diff --git a/acceptance/internal/jq.go b/acceptance/internal/jq.go new file mode 100644 index 00000000000..e8b3ba2c570 --- /dev/null +++ b/acceptance/internal/jq.go @@ -0,0 +1,28 @@ +package internal + +import ( + "os/exec" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// RequireJQ fails the run if jq is missing from PATH or older than minVersion +// (e.g. "1.7"). See the call site for why the minimum is what it is. +func RequireJQ(t *testing.T, minVersion string) { + out, err := exec.Command("jq", "--version").Output() + if err != nil { + t.Fatalf("jq not found on PATH (acceptance tests require jq >= %s): %v", minVersion, err) + } + version := strings.TrimSpace(string(out)) + if !jqVersionOK(version, minVersion) { + t.Fatalf("acceptance tests require jq >= %s (found %q); install a newer jq", minVersion, version) + } +} + +// jqVersionOK reports whether `jq --version` output (e.g. "jq-1.7.1") is >= minVersion. +func jqVersionOK(versionOutput, minVersion string) bool { + got := strings.TrimPrefix(versionOutput, "jq-") + return semver.Compare("v"+got, "v"+minVersion) >= 0 +} diff --git a/acceptance/internal/jq_test.go b/acceptance/internal/jq_test.go new file mode 100644 index 00000000000..781c40625d2 --- /dev/null +++ b/acceptance/internal/jq_test.go @@ -0,0 +1,15 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJqVersionOK(t *testing.T) { + assert.True(t, jqVersionOK("jq-1.7", "1.7")) + assert.True(t, jqVersionOK("jq-1.7.1", "1.7")) + assert.True(t, jqVersionOK("jq-1.8.1", "1.7")) + assert.False(t, jqVersionOK("jq-1.6", "1.7")) + assert.False(t, jqVersionOK("garbage", "1.7")) +} diff --git a/acceptance/internal/python.go b/acceptance/internal/python.go new file mode 100644 index 00000000000..13f1554421a --- /dev/null +++ b/acceptance/internal/python.go @@ -0,0 +1,57 @@ +package internal + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// EnsurePython makes `python3` on PATH resolve to a Python >= minVersion (e.g. +// "3.11"). Acceptance scripts invoke `python3` directly and some import stdlib +// modules added in newer versions, but a host's default python3 may be older. +// On non-Windows hosts uv (see RequireUV) selects a compatible interpreter, +// which we symlink as python3/python into a temp dir prepended to PATH. On +// Windows os.Symlink needs extra privileges, so we instead require that the +// python3 already on PATH satisfies the floor. +func EnsurePython(t *testing.T, minVersion string) { + if runtime.GOOS == "windows" { + out, err := exec.Command("python3", "--version").Output() + if err != nil { + t.Fatalf("python3 not found on PATH (acceptance tests require python >= %s): %v", minVersion, err) + } + version := strings.TrimSpace(string(out)) + if !pythonVersionOK(version, minVersion) { + t.Fatalf("acceptance tests require python >= %s (found %q)", minVersion, version) + } + return + } + + out, err := exec.Command("uv", "python", "find", ">="+minVersion).Output() + if err != nil { + t.Fatalf("uv could not find python >= %s: %v", minVersion, err) + } + python := strings.TrimSpace(string(out)) + + binDir := t.TempDir() + for _, link := range []string{"python3", "python"} { + if err := os.Symlink(python, filepath.Join(binDir, link)); err != nil { + t.Fatalf("failed to symlink %s as %s: %v", python, link, err) + } + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) //nolint:forbidigo // acceptance test harness; no ctx for libs/env + t.Logf("acceptance tests: using %s (via uv) as python3", python) +} + +// pythonVersionOK reports whether `python3 --version` output (e.g. "Python 3.13.2") is >= minVersion. +func pythonVersionOK(versionOutput, minVersion string) bool { + fields := strings.Fields(versionOutput) + if len(fields) < 2 { + return false + } + return semver.Compare("v"+fields[1], "v"+minVersion) >= 0 +} diff --git a/acceptance/internal/python_test.go b/acceptance/internal/python_test.go new file mode 100644 index 00000000000..c7ba0a41ae1 --- /dev/null +++ b/acceptance/internal/python_test.go @@ -0,0 +1,30 @@ +package internal + +import ( + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPythonVersionOK(t *testing.T) { + assert.True(t, pythonVersionOK("Python 3.11.0", "3.11")) + assert.True(t, pythonVersionOK("Python 3.13.2", "3.11")) + assert.False(t, pythonVersionOK("Python 3.10.6", "3.11")) + assert.False(t, pythonVersionOK("garbage", "3.11")) +} + +func TestEnsurePython(t *testing.T) { + if _, err := exec.LookPath("uv"); err != nil { + t.Skip("uv not installed") + } + + EnsurePython(t, "3.11") + + // After setup, the python3 resolved from PATH must satisfy the floor. + out, err := exec.Command("python3", "-c", "import sys; print(sys.version_info >= (3, 11))").Output() + require.NoError(t, err) + assert.Equal(t, "True", strings.TrimSpace(string(out))) +} diff --git a/acceptance/internal/rejecting_proxy.go b/acceptance/internal/rejecting_proxy.go index 569fa490bc8..591954ed15a 100644 --- a/acceptance/internal/rejecting_proxy.go +++ b/acceptance/internal/rejecting_proxy.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "testing" + + "github.com/databricks/cli/libs/testserver" ) // StartRejectingProxy starts an HTTP proxy server bound to a loopback port and @@ -93,6 +95,12 @@ func handleBlockedConnection(t *testing.T, conn net.Conn, hint string) { if isLoopback || isReserved { // Expected unreachable fixture or local test server — log only, don't fail. t.Logf("blocking proxy: blocked loopback/reserved host: %s", detail) + } else if testserver.IsLocalhostProbe(req) { + // Some Databricks-internal development environments run a port watcher + // that auto-forwards every new localhost listener and probes it with + // `HEAD / Host: localhost`. This is not the CLI-under-test reaching the + // internet, so log it instead of failing the test. + t.Logf("blocking proxy: ignored localhost port-classification probe: %s", detail) } else { t.Errorf("internet access blocked by proxy: %s%s", detail, hint) } diff --git a/acceptance/internal/ruff.go b/acceptance/internal/ruff.go new file mode 100644 index 00000000000..907eada2800 --- /dev/null +++ b/acceptance/internal/ruff.go @@ -0,0 +1,31 @@ +package internal + +import ( + "os/exec" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// RequireRuff fails the run if ruff is missing from PATH or older than +// minVersion (e.g. "0.9.1"). See the call site for why the minimum is what it is. +func RequireRuff(t *testing.T, minVersion string) { + out, err := exec.Command("ruff", "--version").Output() + if err != nil { + t.Fatalf("ruff not found on PATH (acceptance tests require ruff >= %s): %v", minVersion, err) + } + version := strings.TrimSpace(string(out)) + if !ruffVersionOK(version, minVersion) { + t.Fatalf("acceptance tests require ruff >= %s (found %q); install a newer ruff", minVersion, version) + } +} + +// ruffVersionOK reports whether `ruff --version` output (e.g. "ruff 0.9.1") is >= minVersion. +func ruffVersionOK(versionOutput, minVersion string) bool { + fields := strings.Fields(versionOutput) + if len(fields) < 2 { + return false + } + return semver.Compare("v"+fields[1], "v"+minVersion) >= 0 +} diff --git a/acceptance/internal/ruff_test.go b/acceptance/internal/ruff_test.go new file mode 100644 index 00000000000..ddef9021950 --- /dev/null +++ b/acceptance/internal/ruff_test.go @@ -0,0 +1,15 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRuffVersionOK(t *testing.T) { + assert.True(t, ruffVersionOK("ruff 0.9.1", "0.9.1")) + assert.True(t, ruffVersionOK("ruff 0.9.2", "0.9.1")) + assert.True(t, ruffVersionOK("ruff 0.11.0", "0.9.1")) + assert.False(t, ruffVersionOK("ruff 0.9.0", "0.9.1")) + assert.False(t, ruffVersionOK("ruff 0.8.5", "0.9.1")) +} diff --git a/acceptance/internal/uv.go b/acceptance/internal/uv.go new file mode 100644 index 00000000000..7c0d0207a82 --- /dev/null +++ b/acceptance/internal/uv.go @@ -0,0 +1,31 @@ +package internal + +import ( + "os/exec" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// RequireUV fails the run if uv is missing from PATH or older than minVersion +// (e.g. "0.4"). See the call site for why the minimum is what it is. +func RequireUV(t *testing.T, minVersion string) { + out, err := exec.Command("uv", "--version").Output() + if err != nil { + t.Fatalf("uv not found on PATH (acceptance tests require uv >= %s): %v", minVersion, err) + } + version := strings.TrimSpace(string(out)) + if !uvVersionOK(version, minVersion) { + t.Fatalf("acceptance tests require uv >= %s (found %q); install a newer uv", minVersion, version) + } +} + +// uvVersionOK reports whether `uv --version` output (e.g. "uv 0.11.22 (abc 2025-01-01)") is >= minVersion. +func uvVersionOK(versionOutput, minVersion string) bool { + fields := strings.Fields(versionOutput) + if len(fields) < 2 { + return false + } + return semver.Compare("v"+fields[1], "v"+minVersion) >= 0 +} diff --git a/acceptance/internal/uv_test.go b/acceptance/internal/uv_test.go new file mode 100644 index 00000000000..879110411fb --- /dev/null +++ b/acceptance/internal/uv_test.go @@ -0,0 +1,15 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUvVersionOK(t *testing.T) { + assert.True(t, uvVersionOK("uv 0.4.0", "0.4")) + assert.True(t, uvVersionOK("uv 0.11.22 (abcdef 2025-01-01)", "0.4")) + assert.True(t, uvVersionOK("uv 1.0.0", "0.4")) + assert.False(t, uvVersionOK("uv 0.3.5", "0.4")) + assert.False(t, uvVersionOK("garbage", "0.4")) +} diff --git a/libs/testserver/server.go b/libs/testserver/server.go index 9dbfe32c5fa..10a283eeadc 100644 --- a/libs/testserver/server.go +++ b/libs/testserver/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "maps" + "net" "net/http" "net/http/httptest" "net/url" @@ -24,6 +25,24 @@ const testPidKey = "test-pid" var testPidRegex = regexp.MustCompile(testPidKey + `/(\d+)`) +// IsLocalhostProbe reports whether r is an external port-classification probe +// rather than traffic from the CLI-under-test or its helper scripts. +// +// Some Databricks-internal development environments run a port watcher that +// auto-forwards every new localhost listener and probes it to decide whether it +// speaks HTTP or HTTPS, connecting back and sending `HEAD / HTTP/1.0` with +// `Host: localhost`. All legitimate test traffic is configured against +// 127.0.0.1:PORT, so the Host is the reliable discriminator: a request whose +// host is bare "localhost" never originates from the test. The method and path +// checks keep the match tight so a genuinely misdirected request still surfaces. +func IsLocalhostProbe(r *http.Request) bool { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return host == "localhost" && r.Method == http.MethodHead && r.URL.Path == "/" +} + func ExtractPidFromHeaders(headers http.Header) int { ua := headers.Get("User-Agent") matches := testPidRegex.FindStringSubmatch(ua) @@ -243,6 +262,13 @@ func New(t testutil.TestingT) *Server { // Set up the not found handler as fallback notFoundFunc := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Answer external port-classification probes benignly instead of failing + // the test with a spurious "No handler" error. See IsLocalhostProbe. + if IsLocalhostProbe(r) { + w.WriteHeader(http.StatusOK) + return + } + pattern := r.Method + " " + r.URL.Path bodyBytes, err := io.ReadAll(r.Body) var body string diff --git a/libs/testserver/server_test.go b/libs/testserver/server_test.go new file mode 100644 index 00000000000..6c6dfe8c160 --- /dev/null +++ b/libs/testserver/server_test.go @@ -0,0 +1,35 @@ +package testserver_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/databricks/cli/libs/testserver" + "github.com/stretchr/testify/assert" +) + +func TestIsLocalhostProbe(t *testing.T) { + tests := []struct { + name string + method string + target string + host string + want bool + }{ + {"localhost probe", http.MethodHead, "/", "localhost", true}, + {"localhost probe with port", http.MethodHead, "/", "localhost:8080", true}, + {"cli request to loopback ip", http.MethodGet, "/api/2.0/jobs/list", "127.0.0.1:12345", false}, + {"head to loopback ip", http.MethodHead, "/", "127.0.0.1:12345", false}, + {"get to localhost root", http.MethodGet, "/", "localhost", false}, + {"head to localhost non-root", http.MethodHead, "/api/2.0/jobs/list", "localhost", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(tt.method, tt.target, nil) + r.Host = tt.host + assert.Equal(t, tt.want, testserver.IsLocalhostProbe(r)) + }) + } +}