diff --git a/Taskfile.yml b/Taskfile.yml index b41178fbe61..f32f3ca3789 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -648,6 +648,8 @@ tasks: - libs/aitools/** - experimental/aitools/** - acceptance/apps/** + # acceptance/cmd/aitools is not listed on purpose: a change there runs + # `test`, which also runs on Windows. The -run pattern below still runs it. - acceptance/experimental/aitools/** - "{{.EMBED_SOURCES}}" cmds: @@ -662,7 +664,7 @@ tasks: --format ${GOTESTSUM_FORMAT:-pkgname-and-test-fails} \ --no-summary=skipped \ --packages ./acceptance/... \ - -- -timeout=${LOCAL_TIMEOUT:-60m} -run "TestAccept/(apps|experimental/aitools)" + -- -timeout=${LOCAL_TIMEOUT:-60m} -run "TestAccept/apps|TestAccept/cmd/aitools|TestAccept/experimental/aitools" test-exp-ssh: desc: Run experimental SSH unit and acceptance tests diff --git a/acceptance/cmd/aitools/list/explicit-ref/out.test.toml b/acceptance/cmd/aitools/list/explicit-ref/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/aitools/list/explicit-ref/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/aitools/list/explicit-ref/output.txt b/acceptance/cmd/aitools/list/explicit-ref/output.txt new file mode 100644 index 00000000000..5f057827c14 --- /dev/null +++ b/acceptance/cmd/aitools/list/explicit-ref/output.txt @@ -0,0 +1,31 @@ + +>>> [CLI] aitools list --output json +{ + "release": "test-ref", + "skills": [ + { + "name": "test-skill", + "latest_version": "1.0.0", + "experimental": false, + "installed": {} + } + ], + "summary": { + "global": { + "installed": 0, + "total": 1 + }, + "project": { + "installed": 0, + "total": 1 + } + } +} + +=== Only the skills manifest reaches the mock server: no latest-release lookup + +>>> print_requests.py --get +{ + "method": "GET", + "path": "/test-ref/manifest.json" +} diff --git a/acceptance/cmd/aitools/list/explicit-ref/script b/acceptance/cmd/aitools/list/explicit-ref/script new file mode 100644 index 00000000000..88653209b47 --- /dev/null +++ b/acceptance/cmd/aitools/list/explicit-ref/script @@ -0,0 +1,7 @@ +# Isolate HOME so parallel aitools tests don't race on a shared ~/.databricks. +sethome home + +trace $CLI aitools list --output json | list_fields + +title "Only the skills manifest reaches the mock server: no latest-release lookup\n" +trace print_requests.py --get diff --git a/acceptance/cmd/aitools/list/explicit-ref/test.toml b/acceptance/cmd/aitools/list/explicit-ref/test.toml new file mode 100644 index 00000000000..16c46ded70d --- /dev/null +++ b/acceptance/cmd/aitools/list/explicit-ref/test.toml @@ -0,0 +1,3 @@ +# DATABRICKS_SKILLS_REF sets the skills ref, so the CLI does not use the +# latest-release lookup. +Env.DATABRICKS_SKILLS_REF = "test-ref" diff --git a/acceptance/cmd/aitools/list/latest-lookup-fails/compat-manifest.json b/acceptance/cmd/aitools/list/latest-lookup-fails/compat-manifest.json new file mode 100644 index 00000000000..eaa0f4668b3 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest-lookup-fails/compat-manifest.json @@ -0,0 +1 @@ +{"1.0.0": {"appkit": "0.84.0", "skills": "latest"}} diff --git a/acceptance/cmd/aitools/list/latest-lookup-fails/out.test.toml b/acceptance/cmd/aitools/list/latest-lookup-fails/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/aitools/list/latest-lookup-fails/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/aitools/list/latest-lookup-fails/output.txt b/acceptance/cmd/aitools/list/latest-lookup-fails/output.txt new file mode 100644 index 00000000000..ff293d47269 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest-lookup-fails/output.txt @@ -0,0 +1,11 @@ + +>>> musterr [CLI] aitools list --output json +Error: resolve latest skills release: HTTP 403 fetching latest skills release + +=== The lookup fails, so the manifest is never fetched + +>>> print_requests.py --get +{ + "method": "GET", + "path": "/repos/databricks/databricks-agent-skills/releases/latest" +} diff --git a/acceptance/cmd/aitools/list/latest-lookup-fails/script b/acceptance/cmd/aitools/list/latest-lookup-fails/script new file mode 100644 index 00000000000..7d44f3d74c6 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest-lookup-fails/script @@ -0,0 +1,10 @@ +# Isolate HOME so parallel aitools tests don't race on a shared ~/.databricks. +sethome home +use_compat_manifest + +# Known gap: list does not fall back to the embedded skills version, so a +# failed latest-release lookup fails the command. +trace musterr $CLI aitools list --output json + +title "The lookup fails, so the manifest is never fetched\n" +trace print_requests.py --get diff --git a/acceptance/cmd/aitools/list/latest-lookup-fails/test.toml b/acceptance/cmd/aitools/list/latest-lookup-fails/test.toml new file mode 100644 index 00000000000..d17526f92a8 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest-lookup-fails/test.toml @@ -0,0 +1,5 @@ +# The unauthenticated GitHub API answers 403 when the per-IP rate limit is hit. +[[Server]] +Pattern = "GET /repos/databricks/databricks-agent-skills/releases/latest" +Response.StatusCode = 403 +Response.Body = '{"message": "API rate limit exceeded"}' diff --git a/acceptance/cmd/aitools/list/latest/compat-manifest.json b/acceptance/cmd/aitools/list/latest/compat-manifest.json new file mode 100644 index 00000000000..eaa0f4668b3 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest/compat-manifest.json @@ -0,0 +1 @@ +{"1.0.0": {"appkit": "0.84.0", "skills": "latest"}} diff --git a/acceptance/cmd/aitools/list/latest/out.test.toml b/acceptance/cmd/aitools/list/latest/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/aitools/list/latest/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/aitools/list/latest/output.txt b/acceptance/cmd/aitools/list/latest/output.txt new file mode 100644 index 00000000000..3e8dbfe030a --- /dev/null +++ b/acceptance/cmd/aitools/list/latest/output.txt @@ -0,0 +1,35 @@ + +>>> [CLI] aitools list --output json +{ + "release": "0.2.33", + "skills": [ + { + "name": "test-skill", + "latest_version": "1.0.0", + "experimental": false, + "installed": {} + } + ], + "summary": { + "global": { + "installed": 0, + "total": 1 + }, + "project": { + "installed": 0, + "total": 1 + } + } +} + +=== The latest-release lookup picks the manifest version + +>>> print_requests.py --get +{ + "method": "GET", + "path": "/repos/databricks/databricks-agent-skills/releases/latest" +} +{ + "method": "GET", + "path": "/v0.2.33/manifest.json" +} diff --git a/acceptance/cmd/aitools/list/latest/script b/acceptance/cmd/aitools/list/latest/script new file mode 100644 index 00000000000..e7a3f89da84 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest/script @@ -0,0 +1,8 @@ +# Isolate HOME so parallel aitools tests don't race on a shared ~/.databricks. +sethome home +use_compat_manifest + +trace $CLI aitools list --output json | list_fields + +title "The latest-release lookup picks the manifest version\n" +trace print_requests.py --get diff --git a/acceptance/cmd/aitools/list/latest/test.toml b/acceptance/cmd/aitools/list/latest/test.toml new file mode 100644 index 00000000000..4ab7e64bf67 --- /dev/null +++ b/acceptance/cmd/aitools/list/latest/test.toml @@ -0,0 +1,3 @@ +[[Server]] +Pattern = "GET /repos/databricks/databricks-agent-skills/releases/latest" +Response.Body = '{"tag_name": "v0.2.33"}' diff --git a/acceptance/cmd/aitools/list/pinned/compat-manifest.json b/acceptance/cmd/aitools/list/pinned/compat-manifest.json new file mode 100644 index 00000000000..deb1eb20945 --- /dev/null +++ b/acceptance/cmd/aitools/list/pinned/compat-manifest.json @@ -0,0 +1 @@ +{"1.0.0": {"appkit": "0.84.0", "skills": "0.2.32"}} diff --git a/acceptance/cmd/aitools/list/pinned/out.test.toml b/acceptance/cmd/aitools/list/pinned/out.test.toml new file mode 100644 index 00000000000..18b1a88417e --- /dev/null +++ b/acceptance/cmd/aitools/list/pinned/out.test.toml @@ -0,0 +1 @@ +Cloud = false diff --git a/acceptance/cmd/aitools/list/pinned/output.txt b/acceptance/cmd/aitools/list/pinned/output.txt new file mode 100644 index 00000000000..329eab653cb --- /dev/null +++ b/acceptance/cmd/aitools/list/pinned/output.txt @@ -0,0 +1,31 @@ + +>>> [CLI] aitools list --output json +{ + "release": "0.2.32", + "skills": [ + { + "name": "test-skill", + "latest_version": "1.0.0", + "experimental": false, + "installed": {} + } + ], + "summary": { + "global": { + "installed": 0, + "total": 1 + }, + "project": { + "installed": 0, + "total": 1 + } + } +} + +=== A pinned version skips the latest-release lookup + +>>> print_requests.py --get +{ + "method": "GET", + "path": "/v0.2.32/manifest.json" +} diff --git a/acceptance/cmd/aitools/list/pinned/script b/acceptance/cmd/aitools/list/pinned/script new file mode 100644 index 00000000000..2125ca20877 --- /dev/null +++ b/acceptance/cmd/aitools/list/pinned/script @@ -0,0 +1,8 @@ +# Isolate HOME so parallel aitools tests don't race on a shared ~/.databricks. +sethome home +use_compat_manifest + +trace $CLI aitools list --output json | list_fields + +title "A pinned version skips the latest-release lookup\n" +trace print_requests.py --get diff --git a/acceptance/cmd/aitools/list/script.prepare b/acceptance/cmd/aitools/list/script.prepare new file mode 100644 index 00000000000..337b973834e --- /dev/null +++ b/acceptance/cmd/aitools/list/script.prepare @@ -0,0 +1,12 @@ +# use_compat_manifest copies the test's compat-manifest.json into the CLI +# cache. A fresh cache file replaces the remote compat manifest. +use_compat_manifest() { + mkdir -p "$DATABRICKS_CACHE_DIR" + cp compat-manifest.json "$DATABRICKS_CACHE_DIR/compat-manifest.json" +} + +# list_fields keeps the fields of `aitools list --output json` that the tests +# assert. Agent detection depends on the binaries on PATH, so agents are left out. +list_fields() { + jq '{release, skills, summary}' +} diff --git a/acceptance/cmd/aitools/list/test.toml b/acceptance/cmd/aitools/list/test.toml new file mode 100644 index 00000000000..7e9761d5c88 --- /dev/null +++ b/acceptance/cmd/aitools/list/test.toml @@ -0,0 +1,31 @@ +# The mock server replaces raw.githubusercontent.com (skills manifest) and +# api.github.com (latest skills release lookup). +Env.DATABRICKS_SKILLS_BASE_URL = "$DATABRICKS_HOST" +Env.DATABRICKS_CLI_GITHUB_API_URL = "$DATABRICKS_HOST" +# Use the skills ref and the compat manifest that each test sets, even if the +# shell sets other values. +Env.DATABRICKS_SKILLS_REF = "" +Env.DATABRICKS_FORCE_EMBEDDED_COMPAT = "false" +Env.DATABRICKS_CACHE_ENABLED = "true" +RecordRequests = true + +Ignore = [ + "home", +] + +[[Server]] +Pattern = "GET /{ref}/manifest.json" +Response.Body = ''' +{ + "version": "2", + "updated_at": "2026-01-01T00:00:00Z", + "skills": { + "test-skill": { + "version": "1.0.0", + "description": "Test skill", + "files": ["SKILL.md"], + "repo_dir": "skills" + } + } +} +''' diff --git a/libs/aitools/installer/installer.go b/libs/aitools/installer/installer.go index 0ca33012f0f..537d6c7322d 100644 --- a/libs/aitools/installer/installer.go +++ b/libs/aitools/installer/installer.go @@ -26,6 +26,7 @@ import ( "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/versioncheck" "golang.org/x/mod/semver" "golang.org/x/sync/errgroup" ) @@ -141,7 +142,7 @@ func resolveLatestSkillsReleaseRef(ctx context.Context, allowFallback bool) (ref } func fetchLatestSkillsReleaseRef(ctx context.Context) (string, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", skillsRepoOwner, skillsRepoName) + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", versioncheck.GitHubAPIURL(ctx), skillsRepoOwner, skillsRepoName) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go index 0a78cf35192..409fa67ed00 100644 --- a/libs/versioncheck/versioncheck.go +++ b/libs/versioncheck/versioncheck.go @@ -127,13 +127,17 @@ type latestRelease struct { URL string `json:"url"` } -func fetchLatestRelease(ctx context.Context) (latestRelease, error) { - base := env.Get(ctx, gitHubAPIURLEnv) - if base == "" { - base = defaultGitHubAPIURL +// GitHubAPIURL returns the GitHub API base URL. DATABRICKS_CLI_GITHUB_API_URL +// overrides it. +func GitHubAPIURL(ctx context.Context) string { + if base := env.Get(ctx, gitHubAPIURLEnv); base != "" { + return strings.TrimRight(base, "/") } + return defaultGitHubAPIURL +} - url := base + latestReleasePath +func fetchLatestRelease(ctx context.Context) (latestRelease, error) { + url := GitHubAPIURL(ctx) + latestReleasePath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return latestRelease{}, err diff --git a/libs/versioncheck/versioncheck_test.go b/libs/versioncheck/versioncheck_test.go index 84089e53171..0e4335a0017 100644 --- a/libs/versioncheck/versioncheck_test.go +++ b/libs/versioncheck/versioncheck_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/databricks/cli/internal/build" + "github.com/databricks/cli/libs/env" "github.com/stretchr/testify/assert" ) @@ -131,3 +132,21 @@ func TestCheck(t *testing.T) { assert.True(t, result.CheckFailed) }) } + +func TestGitHubAPIURL(t *testing.T) { + tests := []struct { + name string + override string + want string + }{ + {"default", "", "https://api.github.com"}, + {"override", "http://mirror.test", "http://mirror.test"}, + {"override with trailing slash", "http://mirror.test/", "http://mirror.test"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := env.Set(t.Context(), gitHubAPIURLEnv, tc.override) + assert.Equal(t, tc.want, GitHubAPIURL(ctx)) + }) + } +}