Files
vaultik/internal/vaultik/snapshot_list.go
T
clawbot eed117fe25
check / check (pull_request) Successful in 1m27s
check / check (push) Successful in 3m18s
Route direct-stdout command output through internal/ui (closes #149)
version, info, remote info, config, and database delete wrote plain text straight to stdout, so they were unstyled and ignored --quiet. Output is now governed by internal/ui in two buckets. Status lines and confirmations (config init, config set, the database delete prompt) go through the ui message methods and are silenced by --quiet. The data a command exists to produce is written plain -- the version/info/remote-info reports, the snapshot list table, config get values, and the --json documents -- and is never suppressed, since a script depends on it and a marker would corrupt a table or document. The database delete confirmation prompt is always shown. Pure-cli commands reach ui through a small commandUI helper.

Model: opus-4-8
2026-09-22 20:28:50 +02:00

577 lines
18 KiB
Go

package vaultik
import (
"encoding/json"
"fmt"
"sort"
"strings"
"text/tabwriter"
"time"
"golang.org/x/sync/errgroup"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
)
// remoteOnlyCell fills the table columns that can only be derived from
// the local index. A snapshot present only on the destination store has
// no local rows to derive them from.
const remoteOnlyCell = "<remote only>"
// remoteKeyDisplayLen is how many hex characters of a remote key are
// shown in the identifier column for a remote-only snapshot. Twelve
// matches the abbreviation length used elsewhere in the UI and is far
// past the point of ambiguity for a SHA256 digest.
const remoteKeyDisplayLen = 12
// maxRemoteOnlyRows caps how many remote-only snapshots a single
// `snapshot list` will describe. Each one costs a manifest read, so an
// uncapped listing against a destination holding many thousands of
// unknown snapshots would be both slow and unbounded in memory. Beyond
// the cap the table is truncated and the count of omitted snapshots is
// reported.
const maxRemoteOnlyRows = 1000
// remoteManifestFetchConcurrency bounds how many manifest reads are in
// flight at once while describing remote-only snapshots. The listing
// itself is a single streamed prefix request; only the per-snapshot
// manifest reads need throttling.
const remoteManifestFetchConcurrency = 8
// tabPadding is the tabwriter cell padding for the snapshot table.
const tabPadding = 3
// ListSnapshots prints the table of snapshots known to this host: the
// union of the local index database and the backup destination store.
//
// Remote listing needs no age secret key. A snapshot's manifest
// (metadata/<remote-key>/manifest.json.zst) is compressed but not
// encrypted, so a host holding only the public key — the configuration
// vaultik is designed for — can still enumerate what it has backed up
// and see each snapshot's timestamp and compressed size.
//
// What that host cannot see is a remote-only snapshot's human ID.
// snapshot.RemoteSnapshotKey is one-way and the manifest stores the
// hashed key rather than the ID, so hostname and snapshot name live
// only in the local index and in the encrypted db.zst.age. Remote-only
// rows are therefore identified by an abbreviation of their remote key,
// and the two columns that genuinely require the local index
// (uncompressed size, new chunk size) render as "<remote only>". No
// attempt is made to recover or fabricate the human ID.
//
// Snapshots in the local index with no counterpart on the destination
// store are reported as drift below the table.
//
// If remote listing fails (unmounted volume, permission denied,
// network), we degrade to local-only with a warning. List never fails
// just because the destination is unreachable.
func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
log.Info("Listing snapshots")
localSnaps, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
if err != nil {
return fmt.Errorf("listing local snapshots: %w", err)
}
snapshots := make([]SnapshotInfo, 0, len(localSnaps))
localKeys := make(map[string]bool, len(localSnaps))
for _, ls := range localSnaps {
if ls.CompletedAt == nil {
continue
}
info := v.snapshotInfoFromLocal(ls)
localKeys[info.RemoteKey] = true
snapshots = append(snapshots, info)
}
listing, remoteErr := v.collectRemoteSnapshots(localKeys)
if remoteErr != nil {
v.warnRemoteListingFailed(remoteErr, jsonOutput)
} else {
snapshots = append(snapshots, listing.remoteOnly...)
markRemotePresence(snapshots, listing.keys)
}
// Stable so that rows sharing a timestamp keep the order they were
// merged in, rather than depending on the sort's pivot choices. The
// unparseable-timestamp fallback in remoteSnapshotInfo makes ties
// realistic: every such row carries the zero time.
sort.SliceStable(snapshots, func(i, j int) bool {
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
})
if jsonOutput {
if remoteErr == nil {
v.reportJSONListingLimits(listing)
}
encoder := json.NewEncoder(v.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(snapshots)
}
// The table is the output this command exists to produce, so it is
// written plain (markers would corrupt the columns) to the UI writer's
// stdout; --quiet silences it. Reconciliation notes below go through
// the UI methods, so their warnings still emit under --quiet.
if !v.UI.Quiet() {
err = v.printSnapshotTable(snapshots)
if err != nil {
return err
}
}
if remoteErr == nil {
v.reportListDrift(snapshots, listing)
}
return nil
}
// warnRemoteListingFailed reports an unreachable or unreadable
// destination store without failing the command: the local index is
// still worth printing, and `snapshot list` exiting non-zero because a
// volume is unmounted would be worse than useless.
//
// The two output modes report it through different channels. Table mode
// uses the UI writer, whose prose and color match the table it sits
// under. The UI writer emits on stdout, though, so --json mode uses the
// logger instead: stdout has to hold nothing but the JSON document for
// `snapshot list --json | jq` to work. Both channels are chosen once,
// never both, so the user is not told the same thing twice.
//
// The failure is also representable in the document itself: every row's
// remote_present is null when the destination could not be listed.
func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) {
if jsonOutput {
log.Warn("Could not list backup destination store; "+
"showing snapshots from the local index only", "error", err)
return
}
v.UI.Warningf("Could not list backup destination store: %v.", err)
v.UI.Infof("Showing snapshots from the local index only.")
}
// reportJSONListingLimits tells a --json consumer that the document it
// is about to read is incomplete: manifests that could not be read, and
// remote-only snapshots dropped by the maxRemoteOnlyRows cap.
//
// Table mode reports both below the table (see reportListDrift) through
// the UI writer, which emits on stdout. In --json mode stdout has to
// hold nothing but the document for `snapshot list --json | jq` to
// work, and the document's shape is deliberately left alone so existing
// consumers keep parsing — so these go to the logger, which writes to
// stderr. A consumer that must react to truncation can treat any output
// on that stream as "this listing is not the whole picture"; silent
// truncation of a listing whose whole purpose is disaster recovery is
// the worse failure.
func (v *Vaultik) reportJSONListingLimits(listing *remoteSnapshotListing) {
if listing.unreadable > 0 {
log.Warn("Some remote snapshot(s) could not be described: "+
"manifest missing or unreadable; they are missing from "+
"this listing", "unreadable", listing.unreadable)
}
if listing.omitted > 0 {
log.Warn("Listing truncated: further remote-only snapshot(s) "+
"not shown", "omitted", listing.omitted,
"limit", maxRemoteOnlyRows)
}
}
// remoteSnapshotListing is the result of one pass over the destination
// store's metadata/ prefix.
type remoteSnapshotListing struct {
// keys holds every remote snapshot key present on the destination
// store, whether or not it is known locally.
keys map[string]bool
// remoteOnly holds one row per remote key with no local
// counterpart, built from that snapshot's manifest.
remoteOnly []SnapshotInfo
// omitted counts remote-only keys dropped because describing them
// all would have exceeded maxRemoteOnlyRows.
omitted int
// unreadable counts remote-only keys whose manifest could not be
// read or decoded.
unreadable int
}
// collectRemoteSnapshots enumerates the destination store and describes
// every snapshot on it that localKeys does not already account for.
//
// The enumeration is a single streamed listing of the metadata/ prefix,
// so the request count does not scale with the number of snapshots.
// Manifest reads scale only with the number of snapshots the local
// index does not already know about, and are capped at
// maxRemoteOnlyRows.
func (v *Vaultik) collectRemoteSnapshots(
localKeys map[string]bool,
) (*remoteSnapshotListing, error) {
keys, err := v.listAllRemoteSnapshotKeys()
if err != nil {
return nil, err
}
listing := &remoteSnapshotListing{
keys: make(map[string]bool, len(keys)),
}
unknown := make([]string, 0, len(keys))
for _, key := range keys {
listing.keys[key] = true
if !localKeys[key] {
unknown = append(unknown, key)
}
}
// Sorted so both the truncation point and the fetch order are
// deterministic run to run.
sort.Strings(unknown)
if len(unknown) > maxRemoteOnlyRows {
listing.omitted = len(unknown) - maxRemoteOnlyRows
unknown = unknown[:maxRemoteOnlyRows]
}
listing.remoteOnly, listing.unreadable = v.describeRemoteOnlySnapshots(
unknown)
return listing, nil
}
// listingWarning is a problem found with one remote snapshot, recorded
// rather than emitted on the spot. Manifest reads run concurrently, so
// emitting from the worker that found the problem would order the
// warnings by fetch completion — which varies run to run with network
// timing and tells the reader nothing. Holding them and emitting in key
// order from a single goroutine after every read has finished makes two
// runs over the same damaged store produce the same diagnostics in the
// same order.
//
// Concurrency safety is no longer part of the reason: these are emitted
// through log.Warn, and slog handlers are safe for concurrent use.
type listingWarning struct {
msg string
args []any
}
// describeRemoteOnlySnapshots reads the manifest for each supplied
// remote key and turns it into a table row, returning the rows and the
// number of keys whose manifest could not be read.
//
// A key whose manifest is missing or corrupt is skipped rather than
// failing the listing: one bad snapshot directory must not hide every
// other snapshot the user has.
func (v *Vaultik) describeRemoteOnlySnapshots(
keys []string,
) ([]SnapshotInfo, int) {
found := make([]SnapshotInfo, len(keys))
ok := make([]bool, len(keys))
warnings := make([]*listingWarning, len(keys))
var group errgroup.Group
group.SetLimit(remoteManifestFetchConcurrency)
for i, key := range keys {
group.Go(func() error {
info, warning, err := v.remoteSnapshotInfo(key)
if err != nil {
warnings[i] = &listingWarning{
msg: "Could not describe remote snapshot",
args: []any{"remote_key", key, "error", err},
}
// Deliberately not returned: the failure is carried in
// warnings/ok and reported as a count. Returning it
// would cancel the group and let one bad snapshot
// directory hide every other snapshot the user has.
return nil //nolint:nilerr // see above
}
found[i] = info
warnings[i] = warning
ok[i] = true
return nil
})
}
// No goroutine above ever returns an error; failures are recorded
// in ok and reported as a count.
_ = group.Wait()
infos := make([]SnapshotInfo, 0, len(keys))
unreadable := 0
for i := range keys {
if warnings[i] != nil {
log.Warn(warnings[i].msg, warnings[i].args...)
}
if !ok[i] {
unreadable++
continue
}
infos = append(infos, found[i])
}
return infos, unreadable
}
// remoteSnapshotInfo builds a table row for a snapshot that exists on
// the destination store but not in the local index, from the only
// source available without the private key: the unencrypted manifest.
//
// ID is deliberately left zero. Recovering it would mean inverting
// snapshot.RemoteSnapshotKey, which is not possible, or writing the
// human ID somewhere unencrypted on the destination, which would undo
// the privacy property that hashing the key exists to provide (see
// issue #81). The renderer marks the row as unnamed rather than
// guessing.
//
// The returned warning, when non-nil, is a problem worth telling the
// user about that was not bad enough to drop the row. It is returned
// rather than logged because this runs on a worker goroutine; see
// listingWarning.
func (v *Vaultik) remoteSnapshotInfo(
remoteKey string,
) (SnapshotInfo, *listingWarning, error) {
manifest, err := v.downloadManifestByKey(remoteKey)
if err != nil {
return SnapshotInfo{}, nil, err
}
var warning *listingWarning
timestamp, err := time.Parse(time.RFC3339, manifest.Timestamp)
if err != nil {
// The snapshot is really there; an unparseable timestamp is not
// reason enough to hide it. It sorts to the bottom as the zero
// time.
warning = &listingWarning{
msg: "Remote manifest has an unparseable timestamp",
args: []any{
"remote_key", remoteKey,
"timestamp", manifest.Timestamp,
"error", err,
},
}
timestamp = time.Time{}
}
return SnapshotInfo{
RemoteKey: remoteKey,
Timestamp: timestamp.UTC(),
CompressedSize: manifest.TotalCompressedSize,
LocallyTracked: false,
}, warning, nil
}
// markRemotePresence records, for every row, whether its remote key was
// seen on the destination store during this listing. Only called when
// the listing succeeded: when it did not, presence stays nil ("not
// known") rather than being reported as absence.
func markRemotePresence(snapshots []SnapshotInfo, remoteKeys map[string]bool) {
for i := range snapshots {
present := remoteKeys[snapshots[i].RemoteKey]
snapshots[i].RemotePresent = &present
}
}
// snapshotInfoFromLocal builds a SnapshotInfo row from a local snapshot
// record. Failures from any per-snapshot stat query degrade that
// column to its snapshot-row fallback but never fail the listing.
func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
idStr := ls.ID.String()
totalSize, err := v.Repositories.Snapshots.GetSnapshotTotalCompressedSize(
v.ctx, idStr)
if err != nil {
log.Warn("Failed to get total compressed size", "id", idStr, "error", err)
totalSize = ls.BlobSize
}
uncompressedSize, err := v.Repositories.Snapshots.GetSnapshotUncompressedChunkSize(
v.ctx, idStr)
if err != nil {
log.Warn("Failed to get uncompressed chunk size", "id", idStr, "error", err)
}
newChunkSize, err := v.Repositories.Snapshots.GetSnapshotNewChunkSize(v.ctx, idStr)
if err != nil {
log.Warn("Failed to get new chunk size", "id", idStr, "error", err)
}
return SnapshotInfo{
ID: ls.ID,
RemoteKey: snapshot.RemoteSnapshotKey(idStr),
Timestamp: ls.StartedAt,
CompressedSize: totalSize,
UncompressedSize: uncompressedSize,
NewChunkSize: newChunkSize,
LocallyTracked: true,
}
}
// reportListDrift prints the reconciliation notes the merged table
// cannot express on its own: local records with no counterpart on the
// destination store, plus counts of remote snapshots that were
// unreadable or omitted.
//
// This is what remains of the old reportRemoteDrift, and it no longer
// touches the destination store. Its remote-only half collapsed into
// the table — those snapshots are rows now, not a footnote count — and
// its local-only half reads the merge ListSnapshots already computed,
// so `snapshot list` lists the destination exactly once per invocation.
func (v *Vaultik) reportListDrift(
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
) {
var localOnly []string
for _, snap := range snapshots {
if snap.LocallyTracked && !listing.keys[snap.RemoteKey] {
localOnly = append(localOnly, snap.ID.String())
}
}
if len(localOnly) > 0 {
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
"destination store:", len(localOnly))
for _, id := range localOnly {
v.UI.Infof("%s", v.UI.Snapshot(id))
}
v.UI.Infof("Run '%s' to remove stale local records.", pruneCommandHint)
}
if len(listing.remoteOnly) > 0 {
v.UI.Noticef("NOTE: %d snapshot(s) on the backup destination store "+
"are not in the local index. Their hostname and snapshot name "+
"cannot be recovered without the age secret key, so they are "+
"listed by remote key.", len(listing.remoteOnly))
}
if listing.unreadable > 0 {
v.UI.Warningf("%d remote snapshot(s) could not be described: "+
"manifest missing or unreadable.", listing.unreadable)
}
if listing.omitted > 0 {
v.UI.Warningf("%d further remote-only snapshot(s) not shown "+
"(limit %d per listing).", listing.omitted, maxRemoteOnlyRows)
}
}
// formatRemoteOnlyID renders the identifier cell for a snapshot absent
// from the local index. Its human ID cannot be recovered without the
// private key, so the cell shows an abbreviation of the remote key
// instead. The angle brackets make it obvious this is not a snapshot
// name, which matters more than compactness: a bare hex string would
// read as a name the user simply doesn't recognize.
func formatRemoteOnlyID(remoteKey string) string {
short := remoteKey
if len(short) > remoteKeyDisplayLen {
short = short[:remoteKeyDisplayLen]
}
return "<remote only:" + short + ">"
}
// printSnapshotTable renders the snapshot list as a formatted table
func (v *Vaultik) printSnapshotTable(snapshots []SnapshotInfo) error {
w := tabwriter.NewWriter(v.Stdout, 0, 0, tabPadding, ' ', 0)
_, err := fmt.Fprintln(w, "CONFIGURED SNAPSHOTS:")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, "NAME\tPATHS")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, "────\t─────")
if err != nil {
return err
}
for _, name := range v.Config.SnapshotNames() {
snap := v.Config.Snapshots[name]
paths := strings.Join(snap.Paths, ", ")
_, err = fmt.Fprintf(w, "%s\t%s\n", name, paths)
if err != nil {
return err
}
}
_, err = fmt.Fprintln(w)
if err != nil {
return err
}
_, err = fmt.Fprintln(w, "REMOTE SNAPSHOTS:")
if err != nil {
return err
}
_, err = fmt.Fprintln(w,
"SNAPSHOT ID\tTIMESTAMP\tCOMPRESSED SIZE\t"+
"UNCOMPRESSED SIZE\tNEW CHUNK SIZE")
if err != nil {
return err
}
_, err = fmt.Fprintln(w,
"───────────\t─────────\t───────────────\t"+
"─────────────────\t──────────────")
if err != nil {
return err
}
for _, snap := range snapshots {
var id, uncompressed, newChunks string
if snap.LocallyTracked {
id = snap.ID.String()
uncompressed = formatBytes(snap.UncompressedSize)
newChunks = formatBytes(snap.NewChunkSize)
} else {
id = formatRemoteOnlyID(snap.RemoteKey)
uncompressed = remoteOnlyCell
newChunks = remoteOnlyCell
}
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
id,
snap.Timestamp.Format("2006-01-02 15:04:05"),
formatBytes(snap.CompressedSize),
uncompressed,
newChunks)
if err != nil {
return err
}
}
return w.Flush()
}