Author SHA1 Message Date
sneak a68a9ae0bd snapshot remove accepts a remote-only snapshot's remote key (closes #400)
check / check (push) Successful in 14m43s
A host with no local index could remove a remote-only snapshot only by
its plain snapshot ID, which it does not have. snapshot remove passed
its argument through RemoteSnapshotKey, rehashing a remote key so it
never matched. It now resolves the identifier with the same
resolveSnapshotRemoteKey that verify and restore use, so a remote key
or an unambiguous leading part of it works.

Model: deepseek-v4-pro
2026-11-08 15:04:42 +00:00
6 changed files with 180 additions and 108 deletions

No files matched your search

+3 -2
View File
@@ -446,6 +446,9 @@ once the store is reachable to remove the snapshot's metadata from it
(`vaultik prune` does not). For a snapshot that is only on the (`vaultik prune` does not). For a snapshot that is only on the
destination store, that failure is an error. To wipe everything on the destination store, that failure is an error. To wipe everything on the
destination in one go, use `vaultik remote nuke --force`. destination in one go, use `vaultik remote nuke --force`.
* Accepts a snapshot ID, or — for a snapshot only on the destination
store — its remote key (or an unambiguous leading part of it) as shown
by `snapshot list`.
* `--local-only`: Skip remote cleanup; only touch the local index * `--local-only`: Skip remote cleanup; only touch the local index
* `--dry-run`: Show what would be deleted without deleting * `--dry-run`: Show what would be deleted without deleting
* `--force`: Skip confirmation prompt * `--force`: Skip confirmation prompt
@@ -836,8 +839,6 @@ Work planned after 1.0. Loosely ordered by priority.
Every command's user-facing output is governed by `internal/ui`, in one Every command's user-facing output is governed by `internal/ui`, in one
of two ways. Color is enabled when the stream written to is a TTY and of two ways. Color is enabled when the stream written to is a TTY and
the `NO_COLOR` environment variable is unset (https://no-color.org/). the `NO_COLOR` environment variable is unset (https://no-color.org/).
The same rule governs the colorized log lines `internal/log` writes to a
terminal.
* **Status, progress, warnings, and errors** go through the `internal/ui` * **Status, progress, warnings, and errors** go through the `internal/ui`
message methods below: marker-prefixed, colored on a TTY, and — except message methods below: marker-prefixed, colored on a TTY, and — except
+4
View File
@@ -288,6 +288,10 @@ know about), which is what 'vaultik prune' does. The command prints the
'vaultik prune' invocation to run as a follow-up only when it removed the 'vaultik prune' invocation to run as a follow-up only when it removed the
snapshot's metadata from the destination store, and never under --json. snapshot's metadata from the destination store, and never under --json.
The snapshot may be named by its ID or, on a host with no local index, by
the remote key that 'snapshot list' prints for a remote-only snapshot (an
unambiguous leading part is enough).
An ID that names no snapshot in the local index or on the destination An ID that names no snapshot in the local index or on the destination
store is an error. store is an error.
+8 -29
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -61,12 +60,6 @@ type TTYHandler struct {
mu *sync.Mutex mu *sync.Mutex
out io.Writer out io.Writer
// color reports whether records are written with ANSI color. It is
// false when NO_COLOR is set, matching internal/ui's shouldColor
// test; the caller already chose this handler for a terminal, so
// TTY-ness is not re-checked here.
color bool
// attrs are the attributes accumulated through WithAttrs, emitted // attrs are the attributes accumulated through WithAttrs, emitted
// ahead of each record's own attributes. Their keys already carry // ahead of each record's own attributes. Their keys already carry
// the group path that was open when they were added, so no // the group path that was open when they were added, so no
@@ -79,9 +72,7 @@ type TTYHandler struct {
groups []string groups []string
} }
// NewTTYHandler creates a new TTY handler. Color is enabled unless the // NewTTYHandler creates a new TTY handler with colored output.
// NO_COLOR environment variable is set to a non-empty value, the same
// test internal/ui applies to status lines.
func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler { func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
if opts == nil { if opts == nil {
opts = &slog.HandlerOptions{} opts = &slog.HandlerOptions{}
@@ -91,7 +82,6 @@ func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
out: out, out: out,
opts: *opts, opts: *opts,
mu: &sync.Mutex{}, mu: &sync.Mutex{},
color: os.Getenv("NO_COLOR") == "",
} }
} }
@@ -134,10 +124,10 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
// are written around it: it can carry text from an untrusted source // are written around it: it can carry text from an untrusted source
// (a storage error, for one), and a raw control character would // (a storage error, for one), and a raw control character would
// otherwise reach the terminal. // otherwise reach the terminal.
_, _ = fmt.Fprintf(h.out, "%s %s %s", _, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s",
h.paint(colorGray, timestamp), colorGray, timestamp, colorReset,
h.paint(levelColor, level), levelColor, level, colorReset,
h.paint(colorBold, sanitize(r.Message))) colorBold, sanitize(r.Message), colorReset)
// Attributes carried by the handler come first, then the record's // Attributes carried by the handler come first, then the record's
// own. Handler attributes were qualified when they were added; the // own. Handler attributes were qualified when they were added; the
@@ -246,7 +236,6 @@ func (h *TTYHandler) clone() *TTYHandler {
opts: h.opts, opts: h.opts,
mu: h.mu, mu: h.mu,
out: h.out, out: h.out,
color: h.color,
attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1), attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1),
groups: make([]string, len(h.groups), len(h.groups)+1), groups: make([]string, len(h.groups), len(h.groups)+1),
} }
@@ -284,19 +273,9 @@ func (h *TTYHandler) writeAttr(a slog.Attr) {
// the snapshot database — so a control character in one of them must // the snapshot database — so a control character in one of them must
// be rendered as an escape sequence rather than reaching the terminal, // be rendered as an escape sequence rather than reaching the terminal,
// where it could move the cursor or inject its own colours. // where it could move the cursor or inject its own colours.
_, _ = fmt.Fprintf(h.out, " %s=%s", _, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
h.paint(colorCyan, sanitize(a.Key)), colorCyan, sanitize(a.Key), colorReset,
h.paint(colorBlue, sanitize(value))) colorBlue, sanitize(value), colorReset)
}
// paint wraps s in the given ANSI color when color is enabled, and
// returns s unchanged otherwise.
func (h *TTYHandler) paint(color, s string) string {
if !h.color {
return s
}
return color + s + colorReset
} }
// sanitize returns s unchanged when every rune in it is printable, and a // sanitize returns s unchanged when every rune in it is printable, and a
-41
View File
@@ -421,44 +421,3 @@ func TestTTYHandlerEmptyGroupAndAttrsAreNoOps(t *testing.T) {
assert.Contains(t, plain, "kept=yes") assert.Contains(t, plain, "kept=yes")
assert.NotContains(t, plain, " =") assert.NotContains(t, plain, " =")
} }
// TestTTYHandlerRespectsNoColor checks that only a non-empty NO_COLOR
// turns color off, matching internal/ui's shouldColor test. The handler
// is only ever chosen for a terminal, so this is the one remaining
// condition that can still leave color in log output.
func TestTTYHandlerRespectsNoColor(t *testing.T) {
cases := []struct {
name string
noColor string
colored bool
}{
{
name: "NO_COLOR turns color off",
noColor: "1",
},
{
name: "empty NO_COLOR keeps color on",
noColor: "",
colored: true,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
t.Setenv("NO_COLOR", testCase.noColor)
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
logger.Info("hello", "key", "value")
if testCase.colored {
assert.Contains(t, buf.String(), "\x1b[",
"an empty NO_COLOR must leave ANSI color in log output")
} else {
assert.NotContains(t, buf.String(), "\x1b[",
"NO_COLOR must suppress every ANSI escape in log output")
}
})
}
}
+94
View File
@@ -451,6 +451,100 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
assert.Contains(t, out, "Removed snapshot metadata from remote storage") assert.Contains(t, out, "Removed snapshot metadata from remote storage")
} }
// TestRemoveSnapshot_RemoteOnlyByRemoteKey checks that a snapshot only
// on the destination store, on a host with no local index, is removed by
// the remote key that 'snapshot list' prints for such a snapshot, and by
// an unambiguous leading part of it — the same identifiers verify and
// restore accept. Snapshot remove used to hash the argument again, so
// the hashed key never matched and only the plain snapshot ID worked.
func TestRemoveSnapshot_RemoteOnlyByRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
identifiers := map[string]string{
"full key": remoteKey,
"leading part": remoteKey[:8],
}
for name, identifier := range identifiers {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// No local index: a host that sees the snapshot only remotely.
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t)
result, err := tv.RemoveSnapshot(identifier,
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.True(t, result.RemoteRemoved)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
out := tv.Stdout.String()
assert.NotContains(t, out, "from local database")
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
})
}
}
// TestRemoveSnapshot_LocalIndexByRemoteKey checks that a remote key (or
// its unambiguous leading part) that names a snapshot the local index
// holds removes that snapshot's local row and its remote metadata
// together. `snapshot list --json` prints a remote key for every
// snapshot, including local ones.
func TestRemoveSnapshot_LocalIndexByRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
identifiers := map[string]string{
"full key": remoteKey,
"leading part": remoteKey[:8],
}
for name, identifier := range identifiers {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the
// marker, and the local index holds the snapshot.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
repos := newLocalIndex(t, "snapshot-001")
tv.Repositories = repos
result, err := tv.RemoveSnapshot(identifier,
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.True(t, result.RemoteRemoved)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
row, err := repos.Snapshots.GetByID(
context.Background(), "snapshot-001")
require.NoError(t, err)
assert.Nil(t, row)
out := tv.Stdout.String()
assert.Contains(t, out,
"Removed snapshot 'snapshot-001' from local database")
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
})
}
}
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a // TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
// snapshot only the destination store holds fails when its metadata // snapshot only the destination store holds fails when its metadata
// cannot be deleted there, under --force and under --json: nothing was // cannot be deleted there, under --force and under --json: nothing was
+71 -36
View File
@@ -1480,7 +1480,7 @@ func (v *Vaultik) RemoveSnapshot(
return result, err return result, err
} }
inLocalIndex, err := v.findSnapshotToRemove(snapshotID, opts) inLocalIndex, localID, remoteKey, err := v.findSnapshotToRemove(snapshotID, opts)
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -1502,16 +1502,16 @@ func (v *Vaultik) RemoveSnapshot(
} }
if inLocalIndex { if inLocalIndex {
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID) log.Info("Removing snapshot from local database", "snapshot_id", localID)
err = v.deleteSnapshotFromLocalDB(snapshotID) err = v.deleteSnapshotFromLocalDB(localID)
if err != nil { if err != nil {
return result, fmt.Errorf("removing from local database: %w", err) return result, fmt.Errorf("removing from local database: %w", err)
} }
} }
result.RemoteRemoved, err = v.removeSnapshotRemote( result.RemoteRemoved, err = v.removeSnapshotRemote(
snapshotID, inLocalIndex, markerErr, opts) snapshotID, remoteKey, inLocalIndex, markerErr, opts)
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -1530,7 +1530,7 @@ func (v *Vaultik) RemoveSnapshot(
if inLocalIndex { if inLocalIndex {
v.UI.Infof("Removed snapshot '%s' from local database.", v.UI.Infof("Removed snapshot '%s' from local database.",
v.UI.Snapshot(snapshotID)) v.UI.Snapshot(localID))
} }
if result.RemoteRemoved { if result.RemoteRemoved {
@@ -1543,50 +1543,68 @@ func (v *Vaultik) RemoveSnapshot(
return result, nil return result, nil
} }
// findSnapshotToRemove reports whether the local index has a row for // findSnapshotToRemove resolves snapshotID, the identifier remove was
// snapshotID, and returns errSnapshotNotFound when there is nothing to // given, and returns whether the local index holds that snapshot, the
// remove: no row and, unless LocalOnly is set, no metadata on the // human ID of its row when it does, and the remote key to strip from
// destination store either. The destination store is consulted only // the destination store. A human ID is looked up in the local index
// when the local index has no row, so a snapshot in the local index can // literally and hashed without consulting the store; a remote key, or
// still be removed from it while the destination store is unreachable, // an unambiguous leading part of one, is resolved against the store's
// and only after checkDestinationMarker passes, so an unplugged volume's // metadata listing the way verify and restore do, and then matched back
// empty mount point is not taken for a store without the snapshot. // to the local row whose hashed ID equals it, so removing by such a key
// removes the local row too. The destination store is consulted only
// after checkDestinationMarker, so an unplugged volume's empty mount
// point is not taken for a store without the snapshot. The identifier
// naming nothing, on the local index or, unless LocalOnly is set, on
// the store, is errSnapshotNotFound; a remote-key form that matches
// nothing or more than one directory surfaces
// resolveSnapshotRemoteKey's errSnapshotKeyNotFound or
// errSnapshotKeyAmbiguous.
func (v *Vaultik) findSnapshotToRemove( func (v *Vaultik) findSnapshotToRemove(
snapshotID string, opts *RemoveOptions, snapshotID string, opts *RemoveOptions,
) (bool, error) { ) (bool, string, string, error) {
if v.Repositories != nil { if v.Repositories != nil {
snap, err := v.Repositories.Snapshots.GetByID(v.ctx, snapshotID) snap, err := v.Repositories.Snapshots.GetByID(v.ctx, snapshotID)
if err != nil { if err != nil {
return false, fmt.Errorf("looking up snapshot in local index: %w", err) return false, "", "", fmt.Errorf("looking up snapshot in local index: %w", err)
} }
if snap != nil { if snap != nil {
return true, nil return true, snapshotID, snapshot.RemoteSnapshotKey(snapshotID), nil
} }
} }
if opts.LocalOnly { if opts.LocalOnly {
return false, fmt.Errorf("%w in the local index: %s", return false, "", "", fmt.Errorf("%w in the local index: %s",
errSnapshotNotFound, snapshotID) errSnapshotNotFound, snapshotID)
} }
err := v.checkDestinationMarker() err := v.checkDestinationMarker()
if err != nil { if err != nil {
return false, err return false, "", "", err
} }
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID)) remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
if err != nil { if err != nil {
return false, fmt.Errorf("looking up snapshot on destination store: %w", err) return false, "", "", err
}
objects, err := v.remoteSnapshotObjects(remoteKey)
if err != nil {
return false, "", "", fmt.Errorf("looking up snapshot on destination store: %w", err)
} }
if len(objects) == 0 { if len(objects) == 0 {
return false, fmt.Errorf( return false, "", "", fmt.Errorf(
"%w in the local index or on the destination store: %s", "%w in the local index or on the destination store: %s",
errSnapshotNotFound, snapshotID) errSnapshotNotFound, snapshotID)
} }
return false, nil localID, err := v.localSnapshotIDForRemoteKey(remoteKey)
if err != nil {
return false, "", "", err
}
return localID != "", localID, remoteKey, nil
} }
// removeSnapshotDryRun reports what RemoveSnapshot would do without // removeSnapshotDryRun reports what RemoveSnapshot would do without
@@ -1649,19 +1667,20 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
} }
// removeSnapshotRemote strips the snapshot's metadata from the // removeSnapshotRemote strips the snapshot's metadata from the
// destination store. It returns true only when the delete finished // destination store. remoteKey is the resolved metadata directory name
// without an error and removed at least one object; under LocalOnly it // to delete, as returned by findSnapshotToRemove. It returns true only
// does nothing. markerErr is the result of checkDestinationMarker; when // when the delete finished without an error and removed at least one
// it is set, nothing is deleted and it is the failure. When the snapshot // object; under LocalOnly it does nothing. markerErr is the result of
// is only on the destination store, a failure is returned as an error, // checkDestinationMarker; when it is set, nothing is deleted and it is
// because that removal is all the command does. When the snapshot was in // the failure. When the snapshot is only on the destination store, a
// the local index, its removal there has already happened, so a failure // failure is returned as an error, because that removal is all the
// is only a warning telling the user to run `vaultik snapshot remove` // command does. When the snapshot was in the local index, its removal
// for the snapshot again once the destination store is reachable // there has already happened, so a failure is only a warning telling
// (`vaultik prune` never removes snapshot metadata that has its // the user to run `vaultik snapshot remove` for the snapshot again once
// manifest). // the destination store is reachable (`vaultik prune` never removes
// snapshot metadata that has its manifest).
func (v *Vaultik) removeSnapshotRemote( func (v *Vaultik) removeSnapshotRemote(
snapshotID string, inLocalIndex bool, markerErr error, snapshotID, remoteKey string, inLocalIndex bool, markerErr error,
opts *RemoveOptions, opts *RemoveOptions,
) (bool, error) { ) (bool, error) {
if opts.LocalOnly { if opts.LocalOnly {
@@ -1675,8 +1694,7 @@ func (v *Vaultik) removeSnapshotRemote(
err := markerErr err := markerErr
if err == nil { if err == nil {
deleted, err = v.deleteRemoteSnapshotByKey( deleted, err = v.deleteRemoteSnapshotByKey(remoteKey)
snapshot.RemoteSnapshotKey(snapshotID))
} }
if err != nil && !inLocalIndex { if err != nil && !inLocalIndex {
@@ -1790,6 +1808,23 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
return ids, nil return ids, nil
} }
// localSnapshotIDForRemoteKey returns the human snapshot ID of the local
// index row whose RemoteSnapshotKey is key, or "" when no row has it.
func (v *Vaultik) localSnapshotIDForRemoteKey(key string) (string, error) {
ids, err := v.localSnapshotIDs()
if err != nil {
return "", fmt.Errorf("listing local snapshots: %w", err)
}
for _, id := range ids {
if snapshot.RemoteSnapshotKey(id) == key {
return id, nil
}
}
return "", nil
}
// listAllRemoteSnapshotKeys collects the hashed remote keys // listAllRemoteSnapshotKeys collects the hashed remote keys
// (subdirectories under metadata/) currently present in the // (subdirectories under metadata/) currently present in the
// destination store. Returns (nil, err) when the store cannot be // destination store. Returns (nil, err) when the store cannot be