Compare commits

..
1 Commits
Author SHA1 Message Date
sneak ea91b78afd Write only the document to stdout from snapshot remove --json (closes #251)
check / check (push) Canceled after 0s
When the destination store cannot be reached, `snapshot remove` still
removes the snapshot from the local index and warns. The warning went
through the UI, which writes to stdout, so under `--json` it landed
ahead of the document and `| jq` failed on a command that exited 0.
Under `--json` the UI warning is now skipped; the logger's warning on
stderr carries the follow-up, with the snapshot ID as a field.

The follow-up, in the warning, the README and the command's help, said
`vaultik prune` would finish the cleanup, but `prune` never removes
snapshot metadata. They now say to run `vaultik snapshot remove` for the
snapshot again once the destination store is reachable.

The new test removes a snapshot from the local index against a missing
destination directory and checks both streams.

Model: opus-5-5
2026-10-07 02:08:53 +00:00
2 changed files with 31 additions and 12 deletions
+20 -6
View File
@@ -2,7 +2,9 @@ package cli //nolint:testpackage // shares hermeticConfig and the capture helper
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -91,8 +93,9 @@ func TestEntryJSONFailureIsReportedOnStderr(t *testing.T) {
// --json` on a snapshot in the local index, against a destination // --json` on a snapshot in the local index, against a destination
// directory that does not exist. The command removes the snapshot from // directory that does not exist. The command removes the snapshot from
// the local index and still exits 0. Its stdout must hold the document // the local index and still exits 0. Its stdout must hold the document
// alone, with the warning about the destination store, and the command // alone, with the warning about the destination store on stderr: the
// to run again once it is reachable, on stderr. // command to run again once it is reachable, and the snapshot's ID in
// the record's snapshot_id field.
// //
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals //nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) { func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) {
@@ -104,10 +107,21 @@ func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) {
require.Equal(t, 0, code) require.Equal(t, 0, code)
requireExactlyOneJSONDocument(t, stdout) requireExactlyOneJSONDocument(t, stdout)
assert.Contains(t, stderr,
"Could not remove snapshot metadata from remote storage") // stderr is a pipe here, so the logger writes one JSON record a line.
assert.Contains(t, stderr, var warning map[string]any
"run 'vaultik snapshot remove "+stalePruneSnapshotID+"' again")
for line := range strings.Lines(stderr) {
if strings.Contains(line,
"Could not remove snapshot metadata from remote storage") {
require.NoError(t, json.Unmarshal([]byte(line), &warning))
}
}
require.NotNil(t, warning, "the warning must reach stderr")
assert.Contains(t, warning[slog.MessageKey],
"run 'vaultik snapshot remove' with the snapshot's ID again")
assert.Equal(t, stalePruneSnapshotID, warning["snapshot_id"])
} }
// writeMissingDestinationConfig builds a config whose destination // writeMissingDestinationConfig builds a config whose destination
+11 -6
View File
@@ -1110,6 +1110,12 @@ type RemoveResult struct {
// just-removed snapshot left behind on the destination store. // just-removed snapshot left behind on the destination store.
const pruneCommandHint = "vaultik prune" const pruneCommandHint = "vaultik prune"
// snapshotRemoveCommandHint is the command suggested, with the
// snapshot's ID, when a remove could not reach the destination store:
// running it again removes the snapshot's metadata there, which
// `vaultik prune` never does.
const snapshotRemoveCommandHint = "vaultik snapshot remove"
// RemoveSnapshot removes a snapshot from the local index database and, // RemoveSnapshot removes a snapshot from the local index database and,
// unless LocalOnly is set, also strips the snapshot's metadata from the // unless LocalOnly is set, also strips the snapshot's metadata from the
// destination store. Blobs are NOT touched: removing a snapshot's // destination store. Blobs are NOT touched: removing a snapshot's
@@ -1241,18 +1247,17 @@ func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) b
err := v.deleteRemoteSnapshotByKey(remoteKey) err := v.deleteRemoteSnapshotByKey(remoteKey)
if err != nil { if err != nil {
removeCommand := "vaultik snapshot remove " + snapshotID
log.Warn("Could not remove snapshot metadata from remote storage; "+ log.Warn("Could not remove snapshot metadata from remote storage; "+
"run '"+removeCommand+"' again once the remote is reachable", "run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
"error", err) "again once the remote is reachable",
"snapshot_id", snapshotID, "error", err)
// The UI writes to stdout, which under --json holds only the // The UI writes to stdout, which under --json holds only the
// document; the log record above is the warning on stderr. // document; the log record above is the warning on stderr.
if v.UI != nil && !opts.JSON { if v.UI != nil && !opts.JSON {
v.UI.Warningf("Could not remove snapshot metadata from remote: "+ v.UI.Warningf("Could not remove snapshot metadata from remote: "+
"%v. Run '%s' again once the remote is reachable.", "%v. Run '%s %s' again once the remote is reachable.",
err, removeCommand) err, snapshotRemoveCommandHint, snapshotID)
} }
return false return false