// Package cli: cache.go implements the `orca cache` subcommand family // (P00-T3, R-008) and the shared cache helpers used by the read-only // list commands (node/job/ns list). // // The cache is optional: if the cache DB cannot be opened (missing dir, // permissions, corrupt file) the list commands fall back to the // uncached read path silently with a slog.Warn. package cli import ( "encoding/json" "errors" "fmt" "log/slog" "os" "time" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/cache" "git.cloudinit.dev/coreci/orca/internal/paths" ) // cacheHit reports whether the cache returned a fresh entry for // (class, key). On any cache-open or read error it returns false (miss) // and logs a warning — the caller proceeds to the uncached path. The // cache never *creates* ORCA_HOME: if the parent directory is missing // the cache is skipped silently so that source-read errors (e.g. `orca // ns list` against a nonexistent ORCA_HOME) still surface. func cacheHit(class, key string) ([]byte, bool) { if !cacheAvailable() { return nil, false } c, err := cache.Open(paths.CacheDB()) if err != nil { slog.Warn("cache: open failed, falling back to uncached path", "class", class, "err", err) return nil, false } defer c.Close() val, _, err := c.Get(class, key) if err != nil { if !errors.Is(err, cache.ErrCacheMiss) { slog.Warn("cache: get failed, falling back to uncached path", "class", class, "err", err) } return nil, false } return val, true } // cachePopulate stores val for (class, key) with the given ttl. Errors // are logged but never returned — a failed populate must not break // the list command. func cachePopulate(class, key string, val []byte, ttl time.Duration) { if !cacheAvailable() { return } c, err := cache.Open(paths.CacheDB()) if err != nil { slog.Warn("cache: open failed during populate", "class", class, "err", err) return } defer c.Close() if err := c.Set(class, key, val, ttl); err != nil { slog.Warn("cache: populate failed", "class", class, "err", err) } } // cacheAvailable reports whether the cache DB parent dir (ORCA_HOME) // exists. The cache layer must never create ORCA_HOME; doing so would // mask source-read errors like `orca ns list` against a missing home. func cacheAvailable() bool { info, err := os.Stat(paths.Root()) if err != nil || !info.IsDir() { return false } return true } // cacheGetList returns the cached JSON list for (class, key), or nil // if miss/any error. It is the read-side helper for list commands. func cacheGetList(class, key string, out any) bool { val, ok := cacheHit(class, key) if !ok { return false } if err := json.Unmarshal(val, out); err != nil { slog.Warn("cache: unmarshal failed, falling back to uncached path", "class", class, "err", err) return false } return true } // cachePutList stores list as JSON under (class, key) with ttl. Used // by list commands after fetching from source. func cachePutList(class, key string, list any, ttl time.Duration) { val, err := json.Marshal(list) if err != nil { slog.Warn("cache: marshal failed during populate", "class", class, "err", err) return } cachePopulate(class, key, val, ttl) } // Per-class TTLs (P00-T2). const ( cacheNodeTTL = 30 * time.Second cacheJobTTL = 10 * time.Second cacheNamespaceTTL = 60 * time.Second cacheNodeClass = "nodes" cacheJobClass = "jobs" cacheNamespaceClass = "namespaces" cacheListKey = "list" ) // --- `orca cache` CLI (P00-T3) --- var cacheCmd = &cobra.Command{ Use: "cache", Short: "Inspect or invalidate the orca CLI cache", Long: `Manage the CLI-side SQLite cache (R-008) at ` + "`" + `ORCA_HOME/orca_cache.db` + "`" + `. Subcommands: show — print per-class entry counts, total size, oldest entry invalidate — drop all entries for a class (e.g. "nodes", "jobs") invalidate-all — drop every entry in the cache Read-only list commands (node/job/ns list) populate the cache; writes bypass it. The --watch flag bypasses the cache entirely (streaming).`, } var cacheShowCmd = &cobra.Command{ Use: "show", Short: "Print cache stats (per-class counts, sizes, oldest entry)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := cache.Open(paths.CacheDB()) if err != nil { return fmt.Errorf("open cache: %w", err) } defer c.Close() stats, err := c.Stats() if err != nil { return fmt.Errorf("cache stats: %w", err) } if jsonOutput { return printJSON(stats) } out := cmd.OutOrStdout() if len(stats) == 0 { fmt.Fprintln(out, "Cache is empty.") return nil } fmt.Fprintf(out, "%-20s %-8s %-12s %s\n", "CLASS", "COUNT", "BYTES", "OLDEST") var totalCount, totalBytes int64 for _, s := range stats { oldest := time.Unix(0, s.OldestAt).UTC().Format(time.RFC3339) if s.OldestAt == 0 { oldest = "-" } fmt.Fprintf(out, "%-20s %-8d %-12d %s\n", s.Class, s.Count, s.Bytes, oldest) totalCount += int64(s.Count) totalBytes += s.Bytes } fmt.Fprintf(out, "%-20s %-8d %-12d\n", "TOTAL", totalCount, totalBytes) return nil }, } var cacheInvalidateCmd = &cobra.Command{ Use: "invalidate ", Short: "Drop all entries for a cache class", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { class := args[0] c, err := cache.Open(paths.CacheDB()) if err != nil { return fmt.Errorf("open cache: %w", err) } defer c.Close() if err := c.Invalidate(class); err != nil { return fmt.Errorf("invalidate %s: %w", class, err) } if jsonOutput { return printJSON(map[string]string{"class": class, "status": "invalidated"}) } fmt.Fprintf(cmd.OutOrStdout(), "✓ Cache invalidated: %s\n", class) return nil }, } var cacheInvalidateAllCmd = &cobra.Command{ Use: "invalidate-all", Short: "Drop every entry in the cache", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { c, err := cache.Open(paths.CacheDB()) if err != nil { return fmt.Errorf("open cache: %w", err) } defer c.Close() if err := c.InvalidateAll(); err != nil { return fmt.Errorf("invalidate-all: %w", err) } if jsonOutput { return printJSON(map[string]string{"status": "invalidated"}) } fmt.Fprintln(cmd.OutOrStdout(), "✓ Cache cleared.") return nil }, } func init() { cacheCmd.AddCommand(cacheShowCmd) cacheCmd.AddCommand(cacheInvalidateCmd) cacheCmd.AddCommand(cacheInvalidateAllCmd) rootCmd.AddCommand(cacheCmd) }