Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion cmd/squirrel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,33 @@ package main

import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
)

// exitCodeError carries a specific process exit status out of a command's
// RunE. `squirrel status` uses it to report its scriptable green/amber/red
// contract (exit 0/1/2), which the generic "any error ⇒ exit 1" path below
// cannot express. errors.As recovers the code here; the command that
// returns it silences cobra's error printing so nothing but the code
// leaves the process.
type exitCodeError struct{ code int }

func (e exitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.code) }

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := newRootCmd().ExecuteContext(ctx); err != nil {
err := newRootCmd().ExecuteContext(ctx)
var ec exitCodeError
if errors.As(err, &ec) {
os.Exit(ec.code)
}
if err != nil {
os.Exit(1)
}
}
1 change: 1 addition & 0 deletions cmd/squirrel/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newRunsCmd())
root.AddCommand(newHooksCmd())
root.AddCommand(newVolumesCmd())
root.AddCommand(newStatusCmd())
root.AddCommand(newSyncCmd())
root.AddCommand(newRestoreCmd())
root.AddCommand(newOffloadCmd())
Expand Down
113 changes: 113 additions & 0 deletions cmd/squirrel/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

import (
"errors"
"fmt"
"io"
"text/tabwriter"

"github.com/spf13/cobra"

"github.com/mbertschler/squirrel/status"
)

// newStatusCmd returns the `squirrel status [volume]` cobra command: a
// read-only "am I safe?" grid answering, per (volume × target), whether
// every configured target is caught up and whether the volume's local
// content is durable where its offload policy needs it (friction-log F16,
// F17, F23). It mutates nothing. The process exit code reflects the worst
// state — 0 green, 1 amber, 2 red — so the same command scripts a health
// check without parsing the grid.
func newStatusCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "status [volume]",
Short: "Show per-target sync coverage and durability, and the offload-ready total",
Args: cobra.MaximumNArgs(1),
// The amber/red signal is carried out as an exitCodeError; silence
// cobra's own error printing so the grid is the only output and the
// signal shows up purely in the exit status.
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
var volume string
if len(args) == 1 {
volume = args[0]
}
err := runStatus(cmd, volume)
// SilenceErrors keeps the scriptable green/amber/red exit-code
// signal (an exitCodeError) from leaking a cobra "Error:" line. But
// it also silences genuine failures — an unknown volume, a missing
// config, a store error — which must still reach the user, so print
// those here. The exit-code signal stays silent.
var ec exitCodeError
if err != nil && !errors.As(err, &ec) {
fmt.Fprintln(cmd.ErrOrStderr(), cmd.ErrPrefix(), err.Error())
}
return err
},
}
return cmd
}

func runStatus(cmd *cobra.Command, volume string) error {
cfg, err := requireConfig(cmd)
if err != nil {
return err
}
if volume != "" {
if _, ok := cfg.Volumes[volume]; !ok {
return fmt.Errorf("unknown volume %q (declare it in %s)", volume, cfg.Path)
}
}
s, err := openStore(cmd, cfg)
if err != nil {
return err
}
defer s.Close()

rep, err := status.Build(cmd.Context(), s, cfg)
if err != nil {
return err
}
worst := renderStatus(cmd.OutOrStdout(), rep, volume)
if code := worst.ExitCode(); code != 0 {
return exitCodeError{code: code}
}
return nil
}

// renderStatus prints the grid and returns the worst level across the
// volumes it printed. When volume is non-empty only that volume is shown,
// and only its level drives the return (so a scripted per-volume check
// isn't reddened by an unrelated volume).
func renderStatus(w io.Writer, rep status.Report, volume string) status.Level {
worst := status.LevelNeutral
for _, v := range rep.Volumes {
if volume != "" && v.Name != volume {
continue
}
renderStatusVolume(w, v)
if v.Level() > worst {
worst = v.Level()
}
}
fmt.Fprintf(w, "overall: %s\n", status.TrafficLight(worst))
return worst
}

// renderStatusVolume prints one volume's header, its target grid, and its
// offload-readiness line, reusing the shared status labels so the wording
// matches the TUI exactly.
func renderStatusVolume(w io.Writer, v status.VolumeStatus) {
fmt.Fprintf(w, "\n%s %s [%s]\n", v.Name, v.Path, status.TrafficLight(v.Level()))
fmt.Fprintf(w, " index: %s\n", status.IndexLabel(v))
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, " TARGET\tROLE\tLAST SYNC\tSTATE\tDURABLE\tMETHOD\tEVIDENCE")
for _, t := range v.Targets {
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\t%s\n",
t.Name, status.RoleLabel(t), status.LastSyncLabel(t), status.StateLabel(t),
status.DurableLabel(t), status.MethodLabel(t), status.EvidenceLabel(t))
}
_ = tw.Flush()
fmt.Fprintf(w, " %s\n", status.OffloadLabel(v.Offload))
}
118 changes: 118 additions & 0 deletions cmd/squirrel/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

// writeStatusConfig lays down a volume with a sync destination, plus its
// DB, and returns the config path. Callers index the volume themselves.
func writeStatusConfig(t *testing.T) (configPath string) {
t.Helper()
root := t.TempDir()
volumeDir := filepath.Join(root, "pics")
if err := os.MkdirAll(volumeDir, 0o755); err != nil {
t.Fatalf("mkdir volume: %v", err)
}
writeTestFile(t, filepath.Join(volumeDir, "a.txt"), "hello")
writeTestFile(t, filepath.Join(volumeDir, "b.txt"), "world")

destDir := filepath.Join(root, "dst")
if err := os.MkdirAll(destDir, 0o755); err != nil {
t.Fatalf("mkdir dest: %v", err)
}
dbPath := filepath.Join(root, "index.db")
configPath = filepath.Join(root, "config.toml")
body := "" +
"db = \"" + dbPath + "\"\n\n" +
"[destinations.scratch]\n" +
"type = \"local\"\n" +
"root = \"" + destDir + "\"\n\n" +
"[volumes.pics]\n" +
"path = \"" + volumeDir + "\"\n" +
"sync_to = [\"scratch\"]\n" +
"sync_every = \"1h\"\n"
if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return configPath
}

// TestCLIStatusNeverSyncedIsAmber: a configured but never-synced target
// leaves the volume amber, and the command exits with code 1 carried by an
// exitCodeError.
func TestCLIStatusNeverSyncedIsAmber(t *testing.T) {
cfg := writeStatusConfig(t)
runCLI(t, "--config", cfg, "index", "pics")

out, err := runCLIExpectErr(t, "--config", cfg, "status")
var ec exitCodeError
if !errors.As(err, &ec) {
t.Fatalf("expected exitCodeError, got %v", err)
}
if ec.code != 1 {
t.Fatalf("exit code = %d, want 1 (amber)", ec.code)
}
if !strings.Contains(out, "pics") || !strings.Contains(out, "scratch") {
t.Fatalf("grid missing volume/target:\n%s", out)
}
if !strings.Contains(out, "never") {
t.Fatalf("never-synced target should read 'never':\n%s", out)
}
if !strings.Contains(out, "overall: amber") {
t.Fatalf("summary should be amber:\n%s", out)
}
// Cobra's own "Error:" line must be silenced — only the grid prints.
if strings.Contains(out, "Error:") {
t.Fatalf("cobra error line leaked into output:\n%s", out)
}
}

// TestCLIStatusAfterSyncIsGreen: once a fresh sync lands, the pair is
// caught up within cadence and the command exits 0.
func TestCLIStatusAfterSyncIsGreen(t *testing.T) {
requireRcloneCLI(t)
cfg := writeStatusConfig(t)
runCLI(t, "--config", cfg, "index", "pics")
runCLI(t, "--config", cfg, "sync", "pics", "--init")

out := runCLI(t, "--config", cfg, "status")
if !strings.Contains(out, "overall: green") {
t.Fatalf("expected green after a fresh sync:\n%s", out)
}
if !strings.Contains(out, "ok") {
t.Fatalf("expected an 'ok' target state:\n%s", out)
}
}

// TestCLIStatusUnknownVolume errors clearly on a volume not in config.
// SilenceErrors (needed to keep the amber/red exit-code signal quiet) must
// not swallow this genuine error: it has to reach the user's stderr, not
// just the returned error value.
func TestCLIStatusUnknownVolume(t *testing.T) {
cfg := writeStatusConfig(t)
out, err := runCLIExpectErr(t, "--config", cfg, "status", "nope")
if err == nil || !strings.Contains(err.Error(), `unknown volume "nope"`) {
t.Fatalf("expected unknown-volume error, got %v", err)
}
if !strings.Contains(out, `unknown volume "nope"`) {
t.Fatalf("error must reach the user, not be silenced:\n%s", out)
}
}

// TestCLIStatusRequiresConfig: status is a question about configured
// coverage, so a missing config is a clear error, not an empty grid — and
// the message must be printed, not silently swallowed by SilenceErrors.
func TestCLIStatusRequiresConfig(t *testing.T) {
missing := filepath.Join(t.TempDir(), "no-config.toml")
out, err := runCLIExpectErr(t, "--config", missing, "status")
if err == nil || !strings.Contains(err.Error(), "no config at") {
t.Fatalf("expected missing-config error, got %v", err)
}
if !strings.Contains(out, "no config at") {
t.Fatalf("error must reach the user, not be silenced:\n%s", out)
}
}
88 changes: 88 additions & 0 deletions offload/readiness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package offload

import (
"context"
"time"

"github.com/mbertschler/squirrel/store"
)

// ReadinessOptions selects the volume and durability policy for a
// Readiness evaluation. It mirrors the durability-relevant subset of
// Options: no path/age selectors (readiness is always whole-volume) and no
// DryRun flag (readiness never touches disk or opens a run).
type ReadinessOptions struct {
// VolumeID is the local volumes row the gate is evaluated against.
VolumeID int64
// Require is the volume's offload_requires policy. Empty means the
// volume has no offload policy, and Readiness reports Applicable=false
// without evaluating anything.
Require []string
// MaxEvidenceAge is the volume's offload_max_evidence_age; it feeds the
// same time-based staleness gate an offload would apply. Zero disables
// the staleness policy.
MaxEvidenceAge time.Duration
}

// ReadinessReport totals what an offload of the whole volume would move
// right now. OffloadableFiles/Bytes are the subset of the present set that
// passes the durability gate; PresentFiles/Bytes are the whole gated
// candidate set, so a UI can render "N of M".
type ReadinessReport struct {
// Applicable is false when the volume declares no offload policy — the
// caller can then distinguish "nothing offloadable" from "offload not
// configured here" without inspecting the totals.
Applicable bool
OffloadableFiles int
OffloadableBytes int64
PresentFiles int
PresentBytes int64
}

// Readiness reports how many present files, and how many bytes, currently
// pass the offload gate for the volume — the "N GB offloadable now"
// decision-support number (friction log F17) without the side effects of
// `offload --dry-run`. It reads only the local index (durability vectors,
// scan-back fingerprints), opens no run, reads no disk bytes, validates no
// volume marker, and deletes nothing, so it is safe to call from a
// read-only introspection surface at any time.
//
// The gate is evaluated exactly as Offload's dry-run path evaluates it —
// same loadGate, same present-and-not-reserved candidate predicate, same
// per-file check — so the totals match what an offload would actually move.
// Totals are order-independent, so this walks the index map in a single
// pass rather than building and sorting an intermediate candidate slice.
func Readiness(ctx context.Context, s *store.Store, opts ReadinessOptions) (ReadinessReport, error) {
if len(opts.Require) == 0 {
return ReadinessReport{Applicable: false}, nil
}
now := time.Now()
g, err := loadGate(ctx, s, opts.VolumeID, opts.Require, now.UnixNano(), opts.MaxEvidenceAge)
if err != nil {
return ReadinessReport{}, err
}
rows, err := s.LoadVolumeIndex(ctx, opts.VolumeID)
if err != nil {
return ReadinessReport{}, err
}
rep := ReadinessReport{Applicable: true}
for p, row := range rows {
if err := ctx.Err(); err != nil {
return ReadinessReport{}, err
}
if row.Status != store.StatusPresent || underReservedSubtree(p) {
continue
}
rep.PresentFiles++
rep.PresentBytes += row.SizeBytes
failures, err := g.check(ctx, row)
if err != nil {
return ReadinessReport{}, err
}
if len(failures) == 0 {
rep.OffloadableFiles++
rep.OffloadableBytes += row.SizeBytes
}
}
return rep, nil
}
Loading
Loading