List remote snapshots without requiring the private key (closes #64)
check / check (push) Successful in 6s
check / check (push) Successful in 6s
ListSnapshots built its table entirely from the local SQLite index. The only remote access, reportRemoteDrift, was gated on AgeSecretKey != "", so on a correctly configured host - which by design holds no private key - snapshot list never contacted the destination store at all. A user who lost their local index could not see their own backups, and the "<remote only>" cell the README documents was unreachable dead code. The listing is now the union of the local index and the destination store, with no age_secret_key gate. Remote-only snapshots cannot have their hostname or name recovered - RemoteSnapshotKey is one-way and the manifest stores the hash - so they are listed by abbreviated remote key with the real timestamp and compressed size from the manifest, and "<remote only>" in the two columns that require the local index. Nothing new is written to remote storage and the human ID is never fabricated. An unreachable destination degrades to local-only with a warning and a zero exit code. remote_present is null rather than false in that case, so "absent" and "unknown" stay distinguishable and no drift is claimed from a listing that never happened. Also: - Snapshot timestamps are normalised to UTC in scanSnapshotRows, the single point where they enter the domain. Previously one of three scanners omitted .UTC(), so on a non-UTC host the same snapshot rendered a different time depending on whether it was locally tracked. - The 1000-row cap and the unreadable-manifest count are reported in --json mode as well as table mode, so machine consumers cannot be silently truncated. The JSON shape is unchanged. - Warnings raised while listing are routed to stderr rather than the logger, which writes to stdout and would corrupt the JSON document. This is a local workaround for the logger bug tracked in #82 and should be removed when that lands. - downloadManifestByKey is now the only remote manifest reader, so the manifest privacy question in #81 has a single call site to change. - The orphaned "vaultik snapshot cleanup" hint now names vaultik prune; that command was folded into prune by the 2026-07-02 consolidation.
This commit was merged in pull request #83.
This commit is contained in:
@@ -9,10 +9,8 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
)
|
||||
@@ -447,243 +445,6 @@ func (v *Vaultik) getSnapshotBlobSizes(snapshotID string) (int64, int64) {
|
||||
return compressed, uncompressed
|
||||
}
|
||||
|
||||
// ListSnapshots prints the table of snapshots, plus any reconciliation
|
||||
// warnings/notes between the local index and the backup destination
|
||||
// store.
|
||||
//
|
||||
// The local index database is always the primary source for the
|
||||
// table — it has the human snapshot IDs, timestamps, and per-snapshot
|
||||
// stats.
|
||||
//
|
||||
// If an age secret key is configured AND remote listing succeeds, we
|
||||
// cross-reference: any local snapshot whose hashed key isn't visible
|
||||
// remotely gets a "local-only" cleanup hint, and any remote key that
|
||||
// doesn't correspond to a known local snapshot gets reported in a
|
||||
// NOTE.
|
||||
//
|
||||
// If no age key is set the local machine is assumed write-only
|
||||
// (backup-only), so we skip remote listing entirely — there's no
|
||||
// value showing keys the user couldn't restore anyway.
|
||||
//
|
||||
// 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))
|
||||
for _, ls := range localSnaps {
|
||||
if ls.CompletedAt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, v.snapshotInfoFromLocal(ls))
|
||||
}
|
||||
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||
})
|
||||
|
||||
if jsonOutput {
|
||||
encoder := json.NewEncoder(v.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
return encoder.Encode(snapshots)
|
||||
}
|
||||
|
||||
err = v.printSnapshotTable(snapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v.Config.AgeSecretKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
v.reportRemoteDrift(localSnaps)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportRemoteDrift cross-references local snapshot records against the
|
||||
// remote metadata keys and reports local-only records and unknown
|
||||
// remote keys. Never fails: remote listing errors degrade to a warning.
|
||||
func (v *Vaultik) reportRemoteDrift(localSnaps []*database.Snapshot) {
|
||||
remoteKeys, err := v.listAllRemoteSnapshotKeys()
|
||||
if err != nil {
|
||||
v.UI.Warningf("Could not list backup destination store: %v.", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
localKeys := make(map[string]string, len(localSnaps))
|
||||
for _, ls := range localSnaps {
|
||||
if ls.CompletedAt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
localKeys[snapshot.RemoteSnapshotKey(ls.ID.String())] = ls.ID.String()
|
||||
}
|
||||
|
||||
remoteSet := make(map[string]bool, len(remoteKeys))
|
||||
for _, k := range remoteKeys {
|
||||
remoteSet[k] = true
|
||||
}
|
||||
|
||||
var localOnly []string
|
||||
|
||||
for key, humanID := range localKeys {
|
||||
if !remoteSet[key] {
|
||||
localOnly = append(localOnly, humanID)
|
||||
}
|
||||
}
|
||||
|
||||
var remoteOnlyCount int
|
||||
|
||||
for key := range remoteSet {
|
||||
if _, ok := localKeys[key]; !ok {
|
||||
remoteOnlyCount++
|
||||
}
|
||||
}
|
||||
|
||||
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 'vaultik snapshot cleanup' to remove stale local records.")
|
||||
}
|
||||
|
||||
if remoteOnlyCount > 0 {
|
||||
v.UI.Noticef("NOTE: %d remote snapshot(s) found in backup destination "+
|
||||
"store but not in local database.", remoteOnlyCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
Timestamp: ls.StartedAt,
|
||||
CompressedSize: totalSize,
|
||||
UncompressedSize: uncompressedSize,
|
||||
NewChunkSize: newChunkSize,
|
||||
LocallyTracked: true,
|
||||
}
|
||||
}
|
||||
|
||||
// tabPadding is the tabwriter cell padding for the snapshot table.
|
||||
const tabPadding = 3
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
const remoteOnlyCell = "<remote only>"
|
||||
for _, snap := range snapshots {
|
||||
uncompressed := remoteOnlyCell
|
||||
newChunks := remoteOnlyCell
|
||||
|
||||
if snap.LocallyTracked {
|
||||
uncompressed = formatBytes(snap.UncompressedSize)
|
||||
newChunks = formatBytes(snap.NewChunkSize)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
snap.ID,
|
||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
formatBytes(snap.CompressedSize),
|
||||
uncompressed,
|
||||
newChunks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
// SnapshotPurgeOptions contains options for the snapshot purge command.
|
||||
type SnapshotPurgeOptions struct {
|
||||
KeepLatest bool // Keep only the most recent snapshot per name
|
||||
@@ -1123,6 +884,14 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
|
||||
// metadata/<remoteKey>/manifest.json.zst. The remoteKey is the double-
|
||||
// SHA256 derivation produced by snapshot.RemoteSnapshotKey, not the
|
||||
// human snapshot ID. Callers that have a human ID must hash first.
|
||||
//
|
||||
// This is the only place vaultik reads a manifest off the destination
|
||||
// store, deliberately: the manifest is currently stored compressed but
|
||||
// unencrypted, which is what lets `snapshot list` enumerate the
|
||||
// destination on a host holding no private key. Whether to encrypt it
|
||||
// is open (issue #81), and routing every read through here means that
|
||||
// decision has exactly one call site to change. Keep it that way — do
|
||||
// not open metadata/<key>/manifest.json.zst directly elsewhere.
|
||||
func (v *Vaultik) downloadManifestByKey(remoteKey string) (*snapshot.Manifest, error) {
|
||||
manifestPath := fmt.Sprintf("metadata/%s/manifest.json.zst", remoteKey)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user