Skip to content

Commit 1744468

Browse files
committed
fix: address code review findings for clicompat
Address all review comments: add sentinel ErrNotFound and typed HTTPStatusError, validate manifest entry values, centralize not-found fallback for skills and AppKit consumers, skip retries on 4xx, add User-Agent header, support DATABRICKS_CACHE_ENABLED, and document pruning policy and trust model. Co-authored-by: Isaac
1 parent 0262d64 commit 1744468

9 files changed

Lines changed: 246 additions & 37 deletions

File tree

cmd/apps/init.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,7 @@ Environment variables:
169169

170170
cmd.Flags().StringVar(&templatePath, "template", "", "Template path (local directory or GitHub URL)")
171171
cmd.Flags().StringVar(&branch, "branch", "", "Git branch or tag (for GitHub templates, mutually exclusive with --version)")
172-
versionDesc := "AppKit version to use (use 'latest' for main branch)"
173-
if v, err := clicompat.ResolveEmbeddedAppKitVersion(); err == nil && v != "" {
174-
versionDesc = fmt.Sprintf("AppKit version to use (default: %s, use 'latest' for main branch)", v)
175-
}
176-
cmd.Flags().StringVar(&version, "version", "", versionDesc)
172+
cmd.Flags().StringVar(&version, "version", "", "AppKit version to use (default: auto-detected, use 'latest' for main branch)")
177173
cmd.Flags().StringVar(&name, "name", "", "Project name (prompts if not provided)")
178174
cmd.Flags().StringVar(&warehouseID, "warehouse-id", "", "SQL warehouse ID")
179175
_ = cmd.Flags().MarkDeprecated("warehouse-id", "use --set <plugin>.sql-warehouse.id=<value> instead")

cmd/apps/manifest.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/databricks/cli/libs/apps/manifest"
1212
"github.com/databricks/cli/libs/clicompat"
1313
"github.com/databricks/cli/libs/env"
14+
"github.com/databricks/cli/libs/log"
1415
"github.com/spf13/cobra"
1516
)
1617

@@ -44,6 +45,14 @@ func runManifestOnly(ctx context.Context, templatePath, branch, version string)
4445
subdirForClone = appkitTemplateDir
4546
}
4647
resolvedPath, cleanup, err := resolveTemplate(ctx, templateSrc, branchForClone, subdirForClone)
48+
if err != nil && usingDefaultTemplate && clicompat.IsNotFoundError(err) {
49+
fallbackVersion, fbErr := clicompat.ResolveEmbeddedAppKitVersion()
50+
if fbErr == nil && fallbackVersion != "" {
51+
log.Warnf(ctx, "Template version not found, falling back to embedded version %s", fallbackVersion)
52+
fallbackRef := normalizeVersion(fallbackVersion)
53+
resolvedPath, cleanup, err = resolveTemplate(ctx, templateSrc, fallbackRef, appkitTemplateDir)
54+
}
55+
}
4756
if err != nil {
4857
return err
4958
}

experimental/aitools/cmd/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func defaultListSkills(cmd *cobra.Command, scope string) error {
5454
}
5555

5656
src := &installer.GitHubManifestSource{}
57-
manifest, err := src.FetchManifest(ctx, ref)
57+
manifest, ref, err := installer.FetchSkillsManifestWithFallback(ctx, src, ref)
5858
if err != nil {
5959
return fmt.Errorf("failed to fetch manifest: %w", err)
6060
}

experimental/aitools/lib/installer/installer.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,27 +93,34 @@ func fetchSkillFile(ctx context.Context, ref, skillName, filePath string) ([]byt
9393
return io.ReadAll(resp.Body)
9494
}
9595

96-
// InstallSkillsForAgents fetches the manifest and installs skills for the given agents.
97-
// This is the core installation function. Callers are responsible for agent detection,
98-
// prompting, and printing the "Installing..." header.
99-
func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgents []*agents.Agent, opts InstallOptions) error {
100-
ref, err := GetSkillsRef(ctx)
101-
if err != nil {
102-
return err
103-
}
96+
// FetchSkillsManifestWithFallback fetches the skills manifest at the given ref.
97+
// If the ref points to a non-existent tag (not-found error), it falls back to
98+
// the embedded manifest's skills version. Returns the manifest, the (possibly
99+
// updated) ref, and any error.
100+
func FetchSkillsManifestWithFallback(ctx context.Context, src ManifestSource, ref string) (*Manifest, string, error) {
104101
tag := strings.TrimPrefix(ref, "v")
105-
cmdio.LogString(ctx, "Using skills version "+tag)
106102
manifest, err := src.FetchManifest(ctx, ref)
107103
if err != nil && clicompat.IsNotFoundError(err) {
108-
// The resolved version doesn't exist. Fall back to the embedded manifest.
109104
fallbackVersion, fbErr := clicompat.ResolveEmbeddedAgentSkillsVersion()
110105
if fbErr == nil && fallbackVersion != "" && fallbackVersion != tag {
111106
log.Warnf(ctx, "Skills version %s not found, falling back to embedded version %s", tag, fallbackVersion)
112107
ref = "v" + fallbackVersion
113-
tag = fallbackVersion
114108
manifest, err = src.FetchManifest(ctx, ref)
115109
}
116110
}
111+
return manifest, ref, err
112+
}
113+
114+
// InstallSkillsForAgents fetches the manifest and installs skills for the given agents.
115+
// This is the core installation function. Callers are responsible for agent detection,
116+
// prompting, and printing the "Installing..." header.
117+
func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgents []*agents.Agent, opts InstallOptions) error {
118+
ref, err := GetSkillsRef(ctx)
119+
if err != nil {
120+
return err
121+
}
122+
cmdio.LogString(ctx, "Using skills version "+strings.TrimPrefix(ref, "v"))
123+
manifest, ref, err := FetchSkillsManifestWithFallback(ctx, src, ref)
117124
if err != nil {
118125
return err
119126
}

experimental/aitools/lib/installer/source.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"time"
99

10+
"github.com/databricks/cli/libs/clicompat"
1011
"github.com/databricks/cli/libs/log"
1112
)
1213

@@ -37,6 +38,9 @@ func (s *GitHubManifestSource) FetchManifest(ctx context.Context, ref string) (*
3738
}
3839
defer resp.Body.Close()
3940

41+
if resp.StatusCode == http.StatusNotFound {
42+
return nil, fmt.Errorf("skills manifest at %s@%s: %w", skillsRepoName, ref, clicompat.ErrNotFound)
43+
}
4044
if resp.StatusCode != http.StatusOK {
4145
return nil, fmt.Errorf("failed to fetch manifest: HTTP %d", resp.StatusCode)
4246
}

experimental/aitools/lib/installer/update.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func UpdateSkills(ctx context.Context, src ManifestSource, targetAgents []*agent
9191
return &UpdateResult{Unchanged: slices.Sorted(maps.Keys(state.Skills))}, nil
9292
}
9393

94-
manifest, err := src.FetchManifest(ctx, latestTag)
94+
manifest, latestTag, err := FetchSkillsManifestWithFallback(ctx, src, latestTag)
9595
if err != nil {
9696
if opts.Check {
9797
log.Warnf(ctx, "Could not fetch manifest: %v", err)

internal/build/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,12 @@ The manifest is validated by Go tests in `libs/clicompat/`:
5252
go test ./libs/clicompat/... -run TestEmbeddedManifest -v
5353
```
5454

55-
This checks: valid JSON, `"next"` key present, at least one versioned entry, valid semver keys, `next` versions >= all entries, and ascending key order.
55+
This checks: valid JSON, `"next"` key present, at least one versioned entry, valid semver keys, valid semver entry values, `next` versions >= all entries, and ascending key order.
56+
57+
## Pruning policy
58+
59+
Entries MUST NOT be removed from the manifest. Older CLI binaries use the lowest entry as their floor when the CLI version is older than all entries. Pruning it causes them to silently resolve to a newer entry that may require CLI features they lack. If the manifest grows too large, consider archiving very old entries to a separate file while keeping the oldest entry as a sentinel.
60+
61+
## Trust model
62+
63+
The live manifest is fetched over HTTPS from GitHub (`raw.githubusercontent.com`). The trust boundary is: TLS certificate validation + GitHub's access controls + write access to the `main` branch of `databricks/cli`. A compromised manifest can only steer clients to existing published tags (AppKit or skills); it cannot inject arbitrary code. The CLI binary always ships an embedded fallback manifest that limits exposure to cache-TTL windows (1 hour). The local cache (`~/.cache/databricks/compat-manifest.json`) is trust-on-disk: an attacker with user-level write access to the cache directory could swap in a malicious manifest pointing to different tags.

libs/clicompat/clicompat.go

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212
"slices"
1313
"strings"
14+
"sync"
1415
"time"
1516

1617
"github.com/databricks/cli/internal/build"
@@ -19,6 +20,20 @@ import (
1920
"golang.org/x/mod/semver"
2021
)
2122

23+
// ErrNotFound indicates that a requested resource (tag, branch, manifest)
24+
// does not exist at the remote.
25+
var ErrNotFound = errors.New("not found")
26+
27+
// HTTPStatusError captures a non-200 HTTP status code from a manifest fetch.
28+
type HTTPStatusError struct {
29+
StatusCode int
30+
}
31+
32+
// Error implements the error interface.
33+
func (e *HTTPStatusError) Error() string {
34+
return fmt.Sprintf("HTTP %d fetching manifest", e.StatusCode)
35+
}
36+
2237
const (
2338
// manifestURL is the raw GitHub URL for the compatibility manifest.
2439
manifestURL = "https://raw.githubusercontent.com/databricks/cli/main/internal/build/cli-compat.json"
@@ -38,7 +53,7 @@ const (
3853
// devVersionPrefix identifies dev builds that always use the "next" entry.
3954
devVersionPrefix = "0.0.0-dev"
4055

41-
fetchRetries = 2
56+
maxFetchAttempts = 3
4257
fetchRetryBackoff = 300 * time.Millisecond
4358
)
4459

@@ -63,19 +78,34 @@ func (c cachedManifest) isFresh(maxAge time.Duration) bool {
6378
}
6479

6580
// httpClient is the HTTP client used for manifest fetches. Package-level var
66-
// so tests can replace it.
81+
// so tests can replace it. Not safe for parallel tests; the clicompat test
82+
// suite does not use t.Parallel().
6783
var httpClient = &http.Client{Timeout: fetchTimeout}
6884

6985
// FetchManifest returns the compatibility manifest using a 4-tier fallback:
7086
// 1. Local cached file (if fresh, < 1 hour old)
7187
// 2. Remote fetch from GitHub (with retry)
7288
// 3. Stale local file (if remote fails but a previously cached file exists)
7389
// 4. Embedded manifest compiled into the binary
90+
//
91+
// Set DATABRICKS_CACHE_ENABLED=false to bypass the local cache (tiers 1 and 3a),
92+
// which is useful to recover from a bad cached manifest.
7493
func FetchManifest(ctx context.Context) (Manifest, error) {
94+
cacheEnabled := true
95+
if enabled, ok := env.GetBool(ctx, "DATABRICKS_CACHE_ENABLED"); ok {
96+
cacheEnabled = enabled
97+
}
98+
7599
localPath := manifestLocalPath(ctx)
76100

77101
// Read local file once — reuse across tiers.
78-
local, localErr := readLocalManifest(localPath)
102+
var local cachedManifest
103+
var localErr error
104+
if cacheEnabled {
105+
local, localErr = readLocalManifest(localPath)
106+
} else {
107+
localErr = errors.New("cache disabled")
108+
}
79109

80110
// Tier 1: local file is fresh.
81111
if localErr == nil && local.isFresh(cacheTTL) {
@@ -86,7 +116,9 @@ func FetchManifest(ctx context.Context) (Manifest, error) {
86116
// Tier 2: fetch from remote (local file missing or stale).
87117
m, fetchErr := fetchRemoteWithRetry(ctx)
88118
if fetchErr == nil {
89-
writeLocalManifest(ctx, localPath, m)
119+
if cacheEnabled {
120+
writeLocalManifest(ctx, localPath, m)
121+
}
90122
return m, nil
91123
}
92124

@@ -97,7 +129,7 @@ func FetchManifest(ctx context.Context) (Manifest, error) {
97129
}
98130

99131
// Tier 3b: embedded manifest.
100-
m, embeddedErr := parseManifest(build.CLICompatManifestJSON)
132+
m, embeddedErr := parseEmbeddedManifest()
101133
if embeddedErr == nil {
102134
log.Debugf(ctx, "Using embedded manifest (remote and local cache failed)")
103135
return m, nil
@@ -106,12 +138,17 @@ func FetchManifest(ctx context.Context) (Manifest, error) {
106138
return nil, fmt.Errorf("all manifest sources failed (remote: %w, embedded: %w)", fetchErr, embeddedErr)
107139
}
108140

141+
// parseEmbeddedManifest parses the embedded manifest once and caches the result.
142+
var parseEmbeddedManifest = sync.OnceValues(func() (Manifest, error) {
143+
return parseManifest(build.CLICompatManifestJSON)
144+
})
145+
109146
// ResolveEmbeddedAppKitVersion resolves the AppKit version from only the
110147
// embedded manifest for the current CLI version. Used as a fallback when the
111148
// primary version (from remote or cached manifest) points to a non-existent tag,
112149
// and for help text defaults where a network call is not appropriate.
113150
func ResolveEmbeddedAppKitVersion() (string, error) {
114-
m, err := parseManifest(build.CLICompatManifestJSON)
151+
m, err := parseEmbeddedManifest()
115152
if err != nil {
116153
return "", fmt.Errorf("embedded manifest: %w", err)
117154
}
@@ -126,7 +163,7 @@ func ResolveEmbeddedAppKitVersion() (string, error) {
126163
// the embedded manifest for the current CLI version. Used as a fallback when the
127164
// primary version points to a non-existent tag.
128165
func ResolveEmbeddedAgentSkillsVersion() (string, error) {
129-
m, err := parseManifest(build.CLICompatManifestJSON)
166+
m, err := parseEmbeddedManifest()
130167
if err != nil {
131168
return "", fmt.Errorf("embedded manifest: %w", err)
132169
}
@@ -144,8 +181,18 @@ func IsNotFoundError(err error) bool {
144181
if err == nil {
145182
return false
146183
}
184+
if errors.Is(err, ErrNotFound) {
185+
return true
186+
}
187+
var httpErr *HTTPStatusError
188+
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
189+
return true
190+
}
191+
// Git clone errors include "not found" in stderr when a branch/tag does not
192+
// exist (e.g. "Remote branch X not found in upstream origin"). This is a
193+
// pragmatic fallback until git.Clone wraps a typed error.
147194
msg := strings.ToLower(err.Error())
148-
return strings.Contains(msg, "not found") || strings.Contains(msg, "404")
195+
return strings.Contains(msg, "not found")
149196
}
150197

151198
// Resolve returns the manifest entry for the given CLI version.
@@ -241,6 +288,7 @@ func manifestLocalPath(ctx context.Context) string {
241288
}
242289
home, err := os.UserCacheDir()
243290
if err != nil {
291+
log.Debugf(ctx, "Could not determine user cache directory: %v", err)
244292
return ""
245293
}
246294
return filepath.Join(home, "databricks", localManifestFile)
@@ -267,8 +315,7 @@ func readLocalManifest(path string) (cachedManifest, error) {
267315
}
268316

269317
// writeLocalManifest writes the manifest to the local cache path using a
270-
// temp-file-then-rename pattern. The os.Remove before os.Rename is needed
271-
// for Windows, where Rename fails if the destination exists.
318+
// temp-file-then-rename pattern for atomicity.
272319
func writeLocalManifest(ctx context.Context, path string, m Manifest) {
273320
if path == "" {
274321
return
@@ -280,12 +327,12 @@ func writeLocalManifest(ctx context.Context, path string, m Manifest) {
280327
}
281328
dir := filepath.Dir(path)
282329
if err := os.MkdirAll(dir, 0o700); err != nil {
283-
log.Debugf(ctx, "Failed to create cache directory %s: %v", dir, err)
330+
log.Warnf(ctx, "Failed to create cache directory %s: %v", dir, err)
284331
return
285332
}
286333
tmp, err := os.CreateTemp(dir, ".compat-manifest-*.tmp")
287334
if err != nil {
288-
log.Debugf(ctx, "Failed to create temp cache file: %v", err)
335+
log.Warnf(ctx, "Failed to create temp cache file: %v", err)
289336
return
290337
}
291338
tmpPath := tmp.Name()
@@ -301,20 +348,20 @@ func writeLocalManifest(ctx context.Context, path string, m Manifest) {
301348
log.Debugf(ctx, "Failed to close temp cache file: %v", err)
302349
return
303350
}
304-
_ = os.Remove(path)
305351
if err := os.Rename(tmpPath, path); err != nil {
306-
log.Debugf(ctx, "Failed to rename temp cache file: %v", err)
352+
log.Warnf(ctx, "Failed to rename temp cache file: %v", err)
307353
}
308354
}
309355

310356
// --- Remote fetch ---
311357

312-
// fetchRemoteWithRetry wraps fetchRemote with retries.
358+
// fetchRemoteWithRetry wraps fetchRemote with retries on transient errors.
359+
// Client errors (4xx) are not retried since they will not recover.
313360
func fetchRemoteWithRetry(ctx context.Context) (Manifest, error) {
314361
var lastErr error
315-
for attempt := range fetchRetries + 1 {
362+
for attempt := range maxFetchAttempts {
316363
if attempt > 0 {
317-
log.Debugf(ctx, "Retrying manifest fetch (attempt %d)", attempt+1)
364+
log.Debugf(ctx, "Retrying manifest fetch (attempt %d/%d)", attempt+1, maxFetchAttempts)
318365
select {
319366
case <-ctx.Done():
320367
return nil, ctx.Err()
@@ -326,6 +373,12 @@ func fetchRemoteWithRetry(ctx context.Context) (Manifest, error) {
326373
return m, nil
327374
}
328375
lastErr = err
376+
377+
// Do not retry client errors (4xx) — they won't resolve on retry.
378+
var httpErr *HTTPStatusError
379+
if errors.As(err, &httpErr) && httpErr.StatusCode >= 400 && httpErr.StatusCode < 500 {
380+
return nil, lastErr
381+
}
329382
}
330383
return nil, lastErr
331384
}
@@ -336,6 +389,7 @@ func fetchRemote(ctx context.Context) (Manifest, error) {
336389
if err != nil {
337390
return nil, err
338391
}
392+
req.Header.Set("User-Agent", "databricks-cli/"+build.GetInfo().Version)
339393

340394
resp, err := httpClient.Do(req)
341395
if err != nil {
@@ -344,7 +398,7 @@ func fetchRemote(ctx context.Context) (Manifest, error) {
344398
defer resp.Body.Close()
345399

346400
if resp.StatusCode != http.StatusOK {
347-
return nil, fmt.Errorf("HTTP %d fetching manifest", resp.StatusCode)
401+
return nil, &HTTPStatusError{StatusCode: resp.StatusCode}
348402
}
349403

350404
// Cap response size to guard against corrupted or malicious responses.
@@ -372,5 +426,19 @@ func parseManifest(data []byte) (Manifest, error) {
372426
return nil, fmt.Errorf("invalid semver key %q in compatibility manifest", k)
373427
}
374428
}
429+
for k, entry := range m {
430+
if entry.AppKit == "" {
431+
return nil, fmt.Errorf("manifest entry %q has empty appkit version", k)
432+
}
433+
if entry.AgentSkills == "" {
434+
return nil, fmt.Errorf("manifest entry %q has empty skills version", k)
435+
}
436+
if !semver.IsValid("v" + entry.AppKit) {
437+
return nil, fmt.Errorf("manifest entry %q has invalid appkit version %q", k, entry.AppKit)
438+
}
439+
if !semver.IsValid("v" + entry.AgentSkills) {
440+
return nil, fmt.Errorf("manifest entry %q has invalid skills version %q", k, entry.AgentSkills)
441+
}
442+
}
375443
return m, nil
376444
}

0 commit comments

Comments
 (0)