@@ -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+
2237const (
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().
6783var 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.
7493func 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.
113150func 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.
128165func 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.
272319func 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.
313360func 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