Compare commits
3
Commits
b07dc44905
...
05acf3dea8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05acf3dea8 | ||
|
|
35eed79b1a | ||
|
|
94412d9ef3 |
@@ -286,7 +286,9 @@ needed.
|
||||
remove orphaned blobs from remote storage. By default keeps only the latest
|
||||
snapshot per name; use `--keep-newer-than` for a rolling window.
|
||||
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
|
||||
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`)
|
||||
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
|
||||
Without `--prune`, or with a value that is not a valid duration, the
|
||||
command fails before the backup starts.
|
||||
|
||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||
of the local index and the backup destination store — with timestamps
|
||||
@@ -401,7 +403,9 @@ orphaned blob figures are reported as unknown; `--json` gives them as
|
||||
`null`, lists the remote key of each unreadable manifest in
|
||||
`unreadable_manifests` and counts the manifests under skipped names in
|
||||
`skipped_manifest_count`. An unreadable manifest also leaves its
|
||||
snapshot's blob count and blob size unknown, `null` in `--json`.
|
||||
snapshot's blob count and blob size unknown, `null` in `--json`. A file
|
||||
under `blobs/` whose name is not a blob hash is not counted as a blob,
|
||||
since `prune` never deletes it.
|
||||
* `--json`: Output as JSON
|
||||
|
||||
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
|
||||
|
||||
@@ -22,6 +22,14 @@ the tag exists and is exercised; what is left is merging `next` to
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
|
||||
is not a blob as an orphaned blob
|
||||
([issue #295](https://git.eeqj.de/sneak/vaultik/issues/295)). A stray
|
||||
file such as `.DS_Store` was reported as an orphaned blob and the
|
||||
report advised running `prune`, which skips such a file and deleted
|
||||
nothing. `remote info` now takes its blob list from the same listing
|
||||
`prune` uses, in the table and in `--json`.
|
||||
|
||||
- 2026-10-13: Made `snapshot remove` fail for a snapshot that does not
|
||||
exist ([issue #293](https://git.eeqj.de/sneak/vaultik/issues/293)).
|
||||
The command never checked the ID: it reported the snapshot removed
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Flags of `snapshot create` used to build argument vectors below.
|
||||
const (
|
||||
flagPrune = "--prune"
|
||||
flagKeepNewerThan = "--keep-newer-than"
|
||||
)
|
||||
|
||||
// TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup checks that a
|
||||
// --keep-newer-than the post-backup prune could not use stops `snapshot
|
||||
// create` before the backup: the command fails and the destination
|
||||
// store stays empty. It used to be read only by the prune, after the
|
||||
// snapshot was uploaded, and without --prune it was ignored.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "decimal in a day count",
|
||||
args: []string{flagPrune, flagKeepNewerThan, "1.5d"},
|
||||
},
|
||||
{
|
||||
name: "unknown unit",
|
||||
args: []string{flagPrune, flagKeepNewerThan, "4x"},
|
||||
},
|
||||
{
|
||||
name: "without --prune",
|
||||
args: []string{flagKeepNewerThan, "4w"},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
code, _, _ := runEntry(t, append(
|
||||
[]string{flagConfig, configPath, cmdSnapshot, cmdCreate},
|
||||
testCase.args...)...)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
|
||||
stored, err := os.ReadDir(storeDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, stored, "nothing may be uploaded")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
|
||||
// post-backup prune fail after a good backup, by planting a manifest
|
||||
// that cannot be decoded, and checks that the failure is reported as the
|
||||
// prune's and not as a failed snapshot creation.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
// A remote key is 64 lowercase hex characters. The prune reads every
|
||||
// manifest on the store and fails on one it cannot decode.
|
||||
plantedDir := filepath.Join(storeDir, "metadata", strings.Repeat("a", 64))
|
||||
require.NoError(t, os.MkdirAll(plantedDir, 0o750))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(plantedDir, "manifest.json.zst"),
|
||||
[]byte("not a valid manifest"), 0o600))
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
|
||||
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, snapshots, 2,
|
||||
"the new snapshot is on the store beside the planted one")
|
||||
}
|
||||
|
||||
// writeBackupConfig builds a hermetic config with one small file in its
|
||||
// source directory and an empty destination store directory. Returns the
|
||||
// config path and the store directory.
|
||||
func writeBackupConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
configPath := writeHermeticPruneConfig(t, false)
|
||||
|
||||
// writeHermeticPruneConfig puts the source and store directories
|
||||
// beside the config file.
|
||||
dir := filepath.Dir(configPath)
|
||||
sourceDir := filepath.Join(dir, "source")
|
||||
|
||||
require.NoError(t, os.Mkdir(sourceDir, 0o750))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
|
||||
|
||||
return configPath, filepath.Join(dir, "store")
|
||||
}
|
||||
@@ -71,6 +71,11 @@ otherwise from the platform config directory (~/.config/vaultik/config.yml
|
||||
on Linux), then /etc/vaultik/config.yml.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := opts.Validate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass snapshot names from args
|
||||
opts.Snapshots = args
|
||||
// --skip-errors is a global flag on the root command.
|
||||
@@ -97,6 +102,13 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
}, func(v *vaultik.Vaultik) error {
|
||||
return v.CreateSnapshot(opts)
|
||||
}, func(err error) {
|
||||
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
|
||||
log.Error("Post-backup prune failed", "error", err)
|
||||
ReportErrorf("Backup completed, but %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Snapshot creation failed", "error", err)
|
||||
ReportErrorf("Snapshot creation failed: %v", err)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -60,22 +61,24 @@ func (r *Repositories) WithTx(ctx context.Context, fn TxFunc) error {
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil {
|
||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||
}
|
||||
// The panic is the failure to report, so a rollback error
|
||||
// is dropped.
|
||||
_ = tx.Rollback()
|
||||
|
||||
panic(p)
|
||||
} else if err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil {
|
||||
Fatalf("failed to rollback transaction: %v", rollbackErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = fn(ctx, tx)
|
||||
if err != nil {
|
||||
// Once ctx is cancelled, database/sql rolls the transaction
|
||||
// back itself and Rollback returns sql.ErrTxDone.
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
return fmt.Errorf("%w; rolling back transaction: %w",
|
||||
err, rollbackErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -235,6 +236,121 @@ func TestRepositoriesTransactionRollback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// When the context is cancelled during the body, database/sql rolls the
|
||||
// transaction back itself, so WithTx's own rollback finds it already
|
||||
// done. WithTx must still return the body's error.
|
||||
func TestRepositoriesTransactionContextCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
file := &database.File{
|
||||
Path: "/test/cancelled_file.txt",
|
||||
MTime: time.Now().Truncate(time.Second),
|
||||
Size: 1024,
|
||||
Mode: 0644,
|
||||
UID: 1000,
|
||||
GID: 1000,
|
||||
}
|
||||
|
||||
err := repos.Files.Create(ctx, tx, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
// database/sql rolls back from a goroutine of its own; wait
|
||||
// until it has, so the test does not depend on that race.
|
||||
for {
|
||||
_, err = tx.ExecContext(context.WithoutCancel(ctx), "SELECT 1")
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||
}
|
||||
|
||||
file, err := repos.Files.GetByPath(
|
||||
context.Background(), "/test/cancelled_file.txt",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("error checking for file: %v", err)
|
||||
}
|
||||
|
||||
if file != nil {
|
||||
t.Error("file should not exist after the cancelled transaction")
|
||||
}
|
||||
}
|
||||
|
||||
// A body that ends the transaction itself with ROLLBACK makes WithTx's
|
||||
// own rollback fail. WithTx must return that failure together with the
|
||||
// body's error.
|
||||
func TestRepositoriesTransactionRollbackFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return errIntentionalRollback
|
||||
})
|
||||
if !errors.Is(err, errIntentionalRollback) {
|
||||
t.Fatalf("expected the body's error, got: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "rolling back transaction") {
|
||||
t.Errorf("expected the failed rollback in the error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the body ends the transaction itself with ROLLBACK and then
|
||||
// panics, WithTx's own rollback fails; the panic must still reach the
|
||||
// caller.
|
||||
func TestRepositoriesTransactionPanicRollbackFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected the body's panic to reach the caller")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "ROLLBACK")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
panic(errIntentionalRollback)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRepositoriesReadTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -167,10 +167,6 @@ const (
|
||||
// metadataKeyParts is the minimum "/"-separated segment count of a
|
||||
// metadata object key (metadata/<snapshot-id>/<filename>).
|
||||
metadataKeyParts = 3
|
||||
|
||||
// blobKeyParts is the minimum "/"-separated segment count of a blob
|
||||
// object key (blobs/<aa>/<bb>/<hash>).
|
||||
blobKeyParts = 4
|
||||
)
|
||||
|
||||
// SnapshotMetadataInfo contains information about a single snapshot's metadata
|
||||
@@ -454,23 +450,16 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
||||
v.stdoutf("Scanning blobs...\n")
|
||||
}
|
||||
|
||||
blobCh := v.Storage.ListStream(v.ctx, "blobs/")
|
||||
allBlobs := make(map[string]int64)
|
||||
|
||||
for obj := range blobCh {
|
||||
if obj.Err != nil {
|
||||
return fmt.Errorf("listing blobs: %w", obj.Err)
|
||||
// Use prune's listing, so a file under blobs/ that prune would never
|
||||
// delete is not counted as a blob, and so not as an orphaned one.
|
||||
allBlobs, err := v.listAllRemoteBlobs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parts := strings.Split(obj.Key, "/")
|
||||
if len(parts) < blobKeyParts {
|
||||
continue
|
||||
}
|
||||
|
||||
hash := parts[3]
|
||||
allBlobs[hash] = obj.Size
|
||||
for _, size := range allBlobs {
|
||||
result.TotalBlobCount++
|
||||
result.TotalBlobSize += obj.Size
|
||||
result.TotalBlobSize += size
|
||||
}
|
||||
|
||||
// A blob named only by a manifest that could not be read, or by one
|
||||
|
||||
@@ -23,6 +23,10 @@ var errNukeRequiresForce = errors.New(
|
||||
// metadata.
|
||||
const metadataDirName = "metadata"
|
||||
|
||||
// blobKeyParts is the "/"-separated segment count of a blob object key
|
||||
// (blobs/<aa>/<bb>/<hash>).
|
||||
const blobKeyParts = 4
|
||||
|
||||
// NukeRemote deletes every snapshot's metadata and every blob from remote
|
||||
// storage, along with any object an upload cut off part-way left under a
|
||||
// temporary `.partial` name. After this returns successfully the bucket
|
||||
|
||||
@@ -185,6 +185,39 @@ func TestRemoteInfo_SkipsNonConformingMetadataName(t *testing.T) {
|
||||
assert.NotContains(t, doc, "unreadable_manifests")
|
||||
}
|
||||
|
||||
// TestRemoteInfo_DoesNotCountNonBlobFileUnderBlobs checks that a file
|
||||
// under blobs/ whose name is not a blob hash is not counted as a blob.
|
||||
// prune never deletes such a file, so counting it as an orphaned blob
|
||||
// would advise a prune that removes nothing.
|
||||
func TestRemoteInfo_DoesNotCountNonBlobFileUnderBlobs(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
env.addRemote(t, listRemoteID, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
require.NoError(t, env.store.Put(context.Background(),
|
||||
"blobs/"+testBlobHashA[:2]+"/"+testBlobHashA[2:4]+"/.DS_Store",
|
||||
bytes.NewReader([]byte("junk"))))
|
||||
|
||||
require.NoError(t, env.v.RemoteInfo(false))
|
||||
|
||||
text := env.stdout.String()
|
||||
assert.Contains(t, text, "Total blobs on remote: 1 (")
|
||||
assert.Contains(t, text, "Orphaned (unreferenced): 0 (")
|
||||
assert.NotContains(t, text, "vaultik prune")
|
||||
|
||||
env.stdout.Reset()
|
||||
require.NoError(t, env.v.RemoteInfo(true))
|
||||
|
||||
var doc map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &doc))
|
||||
assert.InDelta(t, 1, doc["total_blob_count"], 0)
|
||||
assert.InDelta(t, 0, doc["orphaned_blob_count"], 0)
|
||||
assert.InDelta(t, 0, doc["orphaned_blob_size"], 0)
|
||||
}
|
||||
|
||||
// TestRemoteInfo_DirectoryWithoutManifestLeavesOrphansKnown checks that
|
||||
// a directory under metadata/ holding no manifest.json.zst, such as one
|
||||
// left by a backup interrupted before its manifest upload, leaves the
|
||||
|
||||
@@ -28,8 +28,14 @@ var (
|
||||
errNoAgeRecipients = errors.New(
|
||||
"creating a snapshot needs at least one public key in " +
|
||||
"age_recipients (generate a keypair with: age-keygen)")
|
||||
errKeepNewerThanNeedsPrune = errors.New("--keep-newer-than requires --prune")
|
||||
)
|
||||
|
||||
// ErrPostBackupPruneFailed wraps a failure of the post-backup prune,
|
||||
// which runs only after every snapshot was created and uploaded, so the
|
||||
// caller can report it apart from a failed backup.
|
||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||
|
||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||
// local index for listing and purge operations.
|
||||
const listRecentLimit = 10000
|
||||
@@ -45,6 +51,26 @@ type SnapshotCreateOptions struct {
|
||||
Snapshots []string // Optional list of snapshot names to process (empty = all)
|
||||
}
|
||||
|
||||
// Validate fails when KeepNewerThan is set without Prune or is not a
|
||||
// valid duration. Only the post-backup prune reads KeepNewerThan, after
|
||||
// the backup, so the caller checks it before starting one.
|
||||
func (opts *SnapshotCreateOptions) Validate() error {
|
||||
if opts.KeepNewerThan == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.Prune {
|
||||
return errKeepNewerThanNeedsPrune
|
||||
}
|
||||
|
||||
_, err := parseDuration(opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--keep-newer-than: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSnapshot executes the snapshot creation operation
|
||||
func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
// config.Load accepts an empty list, since listing, verifying and
|
||||
@@ -123,7 +149,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
if opts.Prune {
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post-backup prune: %w", err)
|
||||
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user