Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now run uv find python from the Taskfile instead of assuming the right version exists.

This means we need to make sure uv is available before using the Taskfile.

- name: Run Go lint checks (does not include formatting checks)
run: go tool -modfile=tools/task/go.mod task lint

Expand All @@ -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: |
Expand Down
6 changes: 4 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions acceptance/internal/jq.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions acceptance/internal/jq_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
57 changes: 57 additions & 0 deletions acceptance/internal/python.go
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions acceptance/internal/python_test.go
Original file line number Diff line number Diff line change
@@ -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)))
}
8 changes: 8 additions & 0 deletions acceptance/internal/rejecting_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
31 changes: 31 additions & 0 deletions acceptance/internal/ruff.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions acceptance/internal/ruff_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
31 changes: 31 additions & 0 deletions acceptance/internal/uv.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions acceptance/internal/uv_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
26 changes: 26 additions & 0 deletions libs/testserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"maps"
"net"
"net/http"
"net/http/httptest"
"net/url"
Expand All @@ -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 == "/"
Comment thread
pietern marked this conversation as resolved.
}

func ExtractPidFromHeaders(headers http.Header) int {
ua := headers.Get("User-Agent")
matches := testPidRegex.FindStringSubmatch(ua)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading