0358efe95b
- SQLite busy_timeout(5000) + SetMaxOpenConns(1) on all 4 DSNs - secrets file flock (concurrent set on same ns no longer loses data) - upgrade lock file (refuse concurrent orca upgrade) - backup lock file (refuse concurrent backup) - cache invalidation by writes (read-after-write consistency) - Executor.Run mutex scope fix (hold only for DB inserts) - ns create/inherit/set-constraint atomic writeNSMdAtomic - writeCurrentLead + rotateSSHKeys atomic - consolidate 3 writeAtomic impls onto security.WriteAtomic - WebAuthn session stores guarded with sync.Mutex Tests: concurrent secrets set, upgrade lock rejection, cache read-after-write, WebAuthn session thread-safety (pass under -race). ---ci--- project: orca phase: 7 milestone: v0.13 status: complete requirements: covered: [156] ---/ci---
240 lines
7.1 KiB
Go
240 lines
7.1 KiB
Go
// 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)
|
|
}
|
|
|
|
// cacheInvalidate drops all entries for the given cache class
|
|
// (REQ-156, P07 T5). It is called after write operations (node
|
|
// join/leave, ns create/delete, job run/stop) so the very next read
|
|
// does not surface a stale cached list. Errors are logged but never
|
|
// returned — a failed invalidation must not break the write command
|
|
// (the cache entry will simply expire at its TTL).
|
|
func cacheInvalidate(class string) {
|
|
if !cacheAvailable() {
|
|
return
|
|
}
|
|
c, err := cache.Open(paths.CacheDB())
|
|
if err != nil {
|
|
slog.Warn("cache: open failed during invalidate", "class", class, "err", err)
|
|
return
|
|
}
|
|
defer c.Close()
|
|
if err := c.Invalidate(class); err != nil {
|
|
slog.Warn("cache: invalidate failed", "class", class, "err", err)
|
|
}
|
|
}
|
|
|
|
// 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 <c> — 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 <class>",
|
|
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)
|
|
}
|