Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4124f9f077 |
@@ -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
|
||||
|
||||
@@ -22,15 +22,6 @@ the tag exists and is exercised; what is left is merging `next` to
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-13: Made an S3 listing that the server cuts short an error
|
||||
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)). A page
|
||||
marked truncated with no continuation token, as a server answering V2
|
||||
listings V1-style sends, ended the listing without an error, so
|
||||
`prune` saw only the snapshots on the first page and could delete
|
||||
blobs the rest still use. Such a page is now a listing error, and
|
||||
`prune`, `snapshot list` and `remote nuke` handle it as a destination
|
||||
that cannot be listed.
|
||||
|
||||
- 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)
|
||||
})
|
||||
|
||||
+2
-29
@@ -18,13 +18,6 @@ import (
|
||||
"github.com/aws/smithy-go/logging"
|
||||
)
|
||||
|
||||
// ErrNoContinuationToken is returned by ListObjects and ListObjectsStream
|
||||
// when the server marks a page of a listing truncated but sends no
|
||||
// continuation token to fetch the rest with, as a server answering V2
|
||||
// listings V1-style does.
|
||||
var ErrNoContinuationToken = errors.New(
|
||||
"server marked the listing truncated but sent no continuation token")
|
||||
|
||||
// Client wraps the AWS S3 client for vaultik operations.
|
||||
// It provides a simplified interface for S3 operations with automatic
|
||||
// prefix handling and connection management. All operations are performed
|
||||
@@ -221,7 +214,7 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
||||
})
|
||||
|
||||
for paginator.HasMorePages() {
|
||||
page, err := nextListPage(ctx, paginator)
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -242,26 +235,6 @@ func (c *Client) ListObjects(ctx context.Context, prefix string) ([]string, erro
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// nextListPage fetches the next page of a listing. The SDK paginator takes
|
||||
// a page marked truncated without a continuation token for the last page
|
||||
// and stops without an error, so such a page is ErrNoContinuationToken
|
||||
// here instead.
|
||||
func nextListPage(
|
||||
ctx context.Context, paginator *s3.ListObjectsV2Paginator,
|
||||
) (*s3.ListObjectsV2Output, error) {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
truncated := aws.ToBool(page.IsTruncated)
|
||||
if truncated && aws.ToString(page.NextContinuationToken) == "" {
|
||||
return nil, ErrNoContinuationToken
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// HeadObject checks if an object exists in S3.
|
||||
// Returns true if the object exists, false otherwise.
|
||||
// The key is automatically prefixed with the configured prefix.
|
||||
@@ -327,7 +300,7 @@ func (c *Client) ListObjectsStream(
|
||||
})
|
||||
|
||||
for paginator.HasMorePages() {
|
||||
page, err := nextListPage(ctx, paginator)
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
ch <- ObjectInfo{Err: err}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
@@ -23,16 +22,13 @@ import (
|
||||
// s3TestBucket is the bucket created for each in-process S3 server.
|
||||
const s3TestBucket = "test-bucket"
|
||||
|
||||
// Credentials for the tests' S3 clients. The test servers accept any.
|
||||
// Credentials for the tests that build a storer from a config.Config. The
|
||||
// in-process S3 server accepts any.
|
||||
const (
|
||||
s3TestAccessKeyID = "key"
|
||||
s3TestSecretAccessKey = "secret"
|
||||
)
|
||||
|
||||
// s3TestRegion is the region of the S3 clients built here with
|
||||
// s3.NewClient. The test servers ignore it.
|
||||
const s3TestRegion = "us-east-1"
|
||||
|
||||
// newS3Storer builds an s3:// backend backed by a fresh in-process
|
||||
// S3 server. It reuses the same in-memory S3 harness (gofakes3 + s3mem
|
||||
// over httptest) that internal/s3 and the not-found regression test use,
|
||||
@@ -59,7 +55,7 @@ func newS3Storer(t *testing.T) storage.Storer {
|
||||
Bucket: s3TestBucket,
|
||||
AccessKeyID: "test",
|
||||
SecretAccessKey: "test",
|
||||
Region: s3TestRegion,
|
||||
Region: "us-east-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
@@ -97,68 +93,6 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
||||
// listing page marked truncated that carries no continuation token, as a
|
||||
// server answering V2 listings V1-style sends it. Taken as the last page,
|
||||
// it would hide every later key, and prune would treat the blobs of the
|
||||
// snapshots listed there as unreferenced.
|
||||
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
prefix = "metadata/"
|
||||
truncatedPage = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>test-bucket</Name>
|
||||
<Prefix>metadata/</Prefix>
|
||||
<KeyCount>1</KeyCount>
|
||||
<MaxKeys>1</MaxKeys>
|
||||
<IsTruncated>true</IsTruncated>
|
||||
<Contents>
|
||||
<Key>metadata/snap/manifest.json.zst</Key>
|
||||
<Size>8</Size>
|
||||
</Contents>
|
||||
</ListBucketResult>`
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, truncatedPage)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client, err := s3.NewClient(context.Background(), s3.Config{
|
||||
Endpoint: srv.URL,
|
||||
Bucket: s3TestBucket,
|
||||
AccessKeyID: s3TestAccessKeyID,
|
||||
SecretAccessKey: s3TestSecretAccessKey,
|
||||
Region: s3TestRegion,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new client: %v", err)
|
||||
}
|
||||
|
||||
storer := storage.NewS3Storer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
keys, err := storer.List(ctx, prefix)
|
||||
if !errors.Is(err, s3.ErrNoContinuationToken) {
|
||||
t.Errorf("List = %q, %v; want ErrNoContinuationToken", keys, err)
|
||||
}
|
||||
|
||||
var streamErr error
|
||||
|
||||
for obj := range storer.ListStream(ctx, prefix) {
|
||||
if obj.Err != nil {
|
||||
streamErr = obj.Err
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.Is(streamErr, s3.ErrNoContinuationToken) {
|
||||
t.Errorf("ListStream error = %v, want ErrNoContinuationToken", streamErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
||||
// writes: the README's remote storage layout, with the prefix joined to
|
||||
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
||||
|
||||
@@ -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