Compare commits
4
Commits
3037bf9059
...
39ca0e3111
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ca0e3111 | ||
|
|
8d5e6f3399 | ||
|
|
abad8161bc | ||
|
|
05a2c86d65 |
@@ -323,7 +323,11 @@ read from the manifest.
|
|||||||
|
|
||||||
Snapshots in the local index with no counterpart on the destination
|
Snapshots in the local index with no counterpart on the destination
|
||||||
store are reported below the table as drift, with the `vaultik prune`
|
store are reported below the table as drift, with the `vaultik prune`
|
||||||
invocation that reconciles them.
|
invocation that reconciles them. If the local index is bound to a
|
||||||
|
different destination than the configured one (after a change to
|
||||||
|
`storage_url`, or `s3.endpoint`, `s3.bucket` or `s3.prefix`), the
|
||||||
|
command says so instead and gives the same two ways out that `prune`
|
||||||
|
gives: revert the configuration, or run `vaultik database delete`.
|
||||||
|
|
||||||
If the destination store cannot be listed (unmounted volume,
|
If the destination store cannot be listed (unmounted volume,
|
||||||
permission denied, network down), the command warns, falls back to the
|
permission denied, network down), the command warns, falls back to the
|
||||||
|
|||||||
+2
-1
@@ -302,7 +302,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking
|
# Average chunk size for content-defined chunking
|
||||||
# Smaller chunks = better deduplication but more metadata
|
# Smaller chunks = better deduplication but more metadata
|
||||||
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc.
|
# Minimum 1MB
|
||||||
|
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
#chunk_size: 10MB
|
#chunk_size: 10MB
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ storage_url: ""
|
|||||||
|
|
||||||
# Average chunk size for content-defined chunking (FastCDC).
|
# Average chunk size for content-defined chunking (FastCDC).
|
||||||
# Smaller = better deduplication but more metadata overhead.
|
# Smaller = better deduplication but more metadata overhead.
|
||||||
# Accepts: 1MB, 10M, 64KB, etc.
|
# Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
|
||||||
# Default: 10MB
|
# Default: 10MB
|
||||||
# chunk_size: 10MB
|
# chunk_size: 10MB
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,15 @@ const appName = "vaultik"
|
|||||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||||
|
|
||||||
// Defaults and validation bounds for tunable settings.
|
// Defaults and validation bounds for tunable settings. MB and GB are
|
||||||
|
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
||||||
|
// limits, which are 1024-based (MiB, GiB).
|
||||||
const (
|
const (
|
||||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
||||||
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB
|
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
||||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||||
defaultCompressionLevel = 3
|
defaultCompressionLevel = 3
|
||||||
minChunkSize = 1024 * 1024 // 1MB
|
minChunkSize = 1000 * 1000 // 1MB
|
||||||
minCompressionLevel = 1
|
minCompressionLevel = 1
|
||||||
maxCompressionLevel = 19
|
maxCompressionLevel = 19
|
||||||
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
||||||
|
|||||||
@@ -337,6 +337,81 @@ func TestLoadS3PartSize(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeDefaults checks that the defaults are the documented
|
||||||
|
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
|
||||||
|
// (1000-based), so writing a default out in the config file changes nothing.
|
||||||
|
func TestDocumentedSizeDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ChunkSize != defaultChunkSize {
|
||||||
|
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
|
||||||
|
cfg.ChunkSize, defaultChunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
|
||||||
|
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
|
||||||
|
cfg.BlobSizeLimit, defaultBlobSizeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
|
||||||
|
// every size the README, config.example.yml and the config init template
|
||||||
|
// list as an example is accepted.
|
||||||
|
func TestDocumentedSizeExamples(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutSizes = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"chunk_size: 1MB", nil},
|
||||||
|
{"chunk_size: 999999", errChunkSizeTooSmall},
|
||||||
|
{"chunk_size: 5M", nil},
|
||||||
|
{"chunk_size: 10M", nil},
|
||||||
|
{"chunk_size: 10MB", nil},
|
||||||
|
{"chunk_size: 64MiB", nil},
|
||||||
|
{"chunk_size: 1GB", nil},
|
||||||
|
// Four times the default chunk_size, the documented minimum.
|
||||||
|
{"blob_size_limit: 40MB", nil},
|
||||||
|
{"blob_size_limit: 500MB", nil},
|
||||||
|
{"blob_size_limit: 1GB", nil},
|
||||||
|
{"blob_size_limit: 1GiB", nil},
|
||||||
|
{"blob_size_limit: 10G", nil},
|
||||||
|
{"blob_size_limit: 10GB", nil},
|
||||||
|
{"s3: {part_size: 5MiB}", nil},
|
||||||
|
{"s3: {part_size: 10MB}", nil},
|
||||||
|
{"s3: {part_size: 16MiB}", nil},
|
||||||
|
{"s3: {part_size: 100MiB}", nil},
|
||||||
|
{"s3: {part_size: 5GiB}", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
// relative path fails at load. Accepted, it would resolve against the
|
// relative path fails at load. Accepted, it would resolve against the
|
||||||
// working directory, so a backup run from another directory would write
|
// working directory, so a backup run from another directory would write
|
||||||
|
|||||||
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
|
|||||||
require.Equal(t, 1, result.FilesScanned,
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
"**/ file pattern should match at the top level and nested")
|
"**/ file pattern should match at the top level and nested")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, /**/ also matches directly under the source dir
|
||||||
|
fs := setupAnchoredTestFS(t)
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// /**/projectname should exclude BOTH:
|
||||||
|
// - /backup/projectname/file.txt
|
||||||
|
// - /backup/otherproject/projectname/file.txt
|
||||||
|
// Total files: 4, excluded: 2
|
||||||
|
require.Equal(t, 2, result.FilesScanned,
|
||||||
|
"/**/ pattern should match at the top level and nested")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
|
||||||
|
// is a glob: with a plain name the glob library already matched a/b.
|
||||||
|
fs := &afero.MemMapFs{}
|
||||||
|
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
|
||||||
|
|
||||||
|
for _, path := range files {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
createSnapshotRecord(ctx, t, repos, "test-snapshot")
|
||||||
|
|
||||||
|
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
|
||||||
|
// Total files: 3, excluded: 2
|
||||||
|
require.Equal(t, 1, result.FilesScanned,
|
||||||
|
"a/**/*.log should match a/x.log as well as a/c/y.log")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2035,33 +2035,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
|||||||
pattern = strings.TrimSuffix(pattern, "/")
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
|
||||||
// A leading "**/" demands a "/" before the name and so misses an
|
// A leading "**/" demands a "/" before the name and so misses an
|
||||||
// entry directly under the source path. An unanchored pattern
|
// entry directly under the source path. It means "at every depth",
|
||||||
// already matches at every depth, so drop the prefix: as in
|
// which is what an unanchored pattern already does, so drop the
|
||||||
// gitignore, "**/x" then also matches a top-level "x".
|
// prefix and match unanchored: as in gitignore, "**/x" and
|
||||||
if !anchored {
|
// "/**/x" then also match a top-level "x".
|
||||||
pattern = strings.TrimPrefix(pattern, "**/")
|
if rest, found := strings.CutPrefix(pattern, "**/"); found {
|
||||||
|
pattern = rest
|
||||||
|
anchored = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile the glob pattern
|
// Likewise "/**/" inside a pattern demands two slashes, so
|
||||||
// For patterns without path separators, we need to match them as components
|
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
|
||||||
// e.g., ".git" should match ".git" anywhere in the path
|
// zero or more directories, so also compile the pattern with it
|
||||||
g, err := glob.Compile(pattern, '/')
|
// made a single "/". The glob library's {a,b} is no help: with a
|
||||||
if err != nil {
|
// "**" inside it in mid-pattern, it misses paths it should match.
|
||||||
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
for _, variant := range doubleStarVariants(pattern) {
|
||||||
|
// For patterns without path separators, we need to match them as components
|
||||||
|
// e.g., ".git" should match ".git" anywhere in the path
|
||||||
|
g, err := glob.Compile(variant, '/')
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
|
||||||
|
|
||||||
continue
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled = append(compiled, compiledPattern{
|
||||||
|
pattern: g,
|
||||||
|
anchored: anchored,
|
||||||
|
original: p,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
compiled = append(compiled, compiledPattern{
|
|
||||||
pattern: g,
|
|
||||||
anchored: anchored,
|
|
||||||
original: p,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doubleStarVariants returns pattern once for every way of keeping each
|
||||||
|
// "/**/" in it or making it a single "/".
|
||||||
|
func doubleStarVariants(pattern string) []string {
|
||||||
|
const doubleStar = "/**/"
|
||||||
|
|
||||||
|
before, after, found := strings.Cut(pattern, doubleStar)
|
||||||
|
if !found {
|
||||||
|
return []string{pattern}
|
||||||
|
}
|
||||||
|
|
||||||
|
var variants []string
|
||||||
|
|
||||||
|
for _, rest := range doubleStarVariants(after) {
|
||||||
|
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
}
|
||||||
|
|
||||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||||
// filePath is the full path to the file
|
// filePath is the full path to the file
|
||||||
// rootPath is the root of the backup source directory
|
// rootPath is the root of the backup source directory
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/config"
|
||||||
"github.com/rclone/rclone/fs/config/configfile"
|
"github.com/rclone/rclone/fs/config/configfile"
|
||||||
"github.com/rclone/rclone/fs/operations"
|
"github.com/rclone/rclone/fs/operations"
|
||||||
|
|
||||||
@@ -33,6 +35,15 @@ type RcloneStorer struct {
|
|||||||
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
||||||
// The path parameter is the path within the remote.
|
// The path parameter is the path within the remote.
|
||||||
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
||||||
|
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
|
||||||
|
// which vaultik does not have, so it is applied here as that flag would.
|
||||||
|
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
|
||||||
|
err := config.SetConfigPath(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Install the default config file handler
|
// Install the default config file handler
|
||||||
configfile.Install()
|
configfile.Install()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/config"
|
||||||
"github.com/rclone/rclone/fs/config/configmap"
|
"github.com/rclone/rclone/fs/config/configmap"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -390,3 +391,41 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
|
|||||||
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
|
||||||
|
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
|
||||||
|
// would.
|
||||||
|
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
|
||||||
|
// rclone keeps the config file path in a process-global variable, which
|
||||||
|
// NewRcloneStorer leaves pointing at this test's file.
|
||||||
|
previous := config.GetConfigPath()
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
|
||||||
|
|
||||||
|
confFile := filepath.Join(t.TempDir(), "rclone.conf")
|
||||||
|
|
||||||
|
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing rclone config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("RCLONE_CONFIG", confFile)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRcloneStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, testBlobKey))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("object is not in the remote's directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ const tabPadding = 3
|
|||||||
// attempt is made to recover or fabricate the human ID.
|
// attempt is made to recover or fabricate the human ID.
|
||||||
//
|
//
|
||||||
// Snapshots in the local index with no counterpart on the destination
|
// Snapshots in the local index with no counterpart on the destination
|
||||||
// store are reported as drift below the table.
|
// store are reported as drift below the table, unless the local index
|
||||||
|
// is bound to a different destination than the configured one: that is
|
||||||
|
// reported instead.
|
||||||
//
|
//
|
||||||
// If remote listing fails (unmounted volume, permission denied,
|
// If remote listing fails (unmounted volume, permission denied,
|
||||||
// network), we degrade to local-only with a warning. List never fails
|
// network), we degrade to local-only with a warning. List never fails
|
||||||
@@ -125,8 +127,22 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stored, configured, err := v.storageBinding()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A local index bound to another destination describes that one, so
|
||||||
|
// its records are expected to be missing here, and prune refuses to
|
||||||
|
// run. The binding is reported instead of advising prune.
|
||||||
|
boundElsewhere := stored != "" && stored != configured
|
||||||
|
if boundElsewhere {
|
||||||
|
v.UI.Warningf("%v\n%s", errStorageBindingMismatch,
|
||||||
|
buildBindingMismatchMessage(stored, configured))
|
||||||
|
}
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportListDrift(snapshots, listing)
|
v.reportListDrift(snapshots, listing, boundElsewhere)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -432,7 +448,9 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// reportListDrift prints the reconciliation notes the merged table
|
// reportListDrift prints the reconciliation notes the merged table
|
||||||
// cannot express on its own: local records with no counterpart on the
|
// cannot express on its own: local records with no counterpart on the
|
||||||
// destination store, plus counts of remote snapshots that were
|
// destination store, plus counts of remote snapshots that were
|
||||||
// unreadable or omitted.
|
// unreadable or omitted. Local records are not reported when
|
||||||
|
// boundElsewhere is set: ListSnapshots has already said why they are
|
||||||
|
// missing.
|
||||||
//
|
//
|
||||||
// This is what remains of the old reportRemoteDrift, and it no longer
|
// This is what remains of the old reportRemoteDrift, and it no longer
|
||||||
// touches the destination store. Its remote-only half collapsed into
|
// touches the destination store. Its remote-only half collapsed into
|
||||||
@@ -441,6 +459,7 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// so `snapshot list` lists the destination exactly once per invocation.
|
// so `snapshot list` lists the destination exactly once per invocation.
|
||||||
func (v *Vaultik) reportListDrift(
|
func (v *Vaultik) reportListDrift(
|
||||||
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
||||||
|
boundElsewhere bool,
|
||||||
) {
|
) {
|
||||||
var localOnly []string
|
var localOnly []string
|
||||||
|
|
||||||
@@ -450,7 +469,7 @@ func (v *Vaultik) reportListDrift(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(localOnly) > 0 {
|
if len(localOnly) > 0 && !boundElsewhere {
|
||||||
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
||||||
"destination store:", len(localOnly))
|
"destination store:", len(localOnly))
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,41 @@ func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
|
|||||||
assert.NotContains(t, out, "snapshot cleanup")
|
assert.NotContains(t, out, "snapshot cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListSnapshots_BoundElsewhereDoesNotAdvisePrune covers a changed
|
||||||
|
// storage_url: the local index is still bound to the old destination,
|
||||||
|
// so its records are missing from the new one. prune refuses to run in
|
||||||
|
// that state, so the listing must give the choices prune gives instead
|
||||||
|
// of advising it.
|
||||||
|
func TestListSnapshots_BoundElsewhereDoesNotAdvisePrune(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
oldURL = "file:///mnt/backups/old"
|
||||||
|
newURL = "file:///mnt/backups/new"
|
||||||
|
)
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
env.v.Config.StorageURL = newURL
|
||||||
|
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||||
|
database.LocalMetaKeyStorageURL, oldURL))
|
||||||
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
err := env.v.ListSnapshots(false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out := env.stdout.String()
|
||||||
|
|
||||||
|
assert.Contains(t, out, listLocalID)
|
||||||
|
assert.Contains(t, out,
|
||||||
|
"local index is bound to a different backup destination")
|
||||||
|
assert.Contains(t, out, oldURL)
|
||||||
|
assert.Contains(t, out, newURL)
|
||||||
|
assert.Contains(t, out, "revert storage_url")
|
||||||
|
assert.Contains(t, out, "vaultik database delete")
|
||||||
|
assert.NotContains(t, out, "vaultik prune")
|
||||||
|
}
|
||||||
|
|
||||||
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
||||||
// doc comment: an unreachable destination is a warning plus local-only
|
// doc comment: an unreachable destination is a warning plus local-only
|
||||||
// output, never a failure.
|
// output, never a failure.
|
||||||
|
|||||||
@@ -45,17 +45,9 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
configured := v.Config.StorageURL
|
stored, configured, err := v.storageBinding()
|
||||||
if configured == "" {
|
|
||||||
// The s3.* form: bind on its endpoint, bucket and prefix,
|
|
||||||
// written the way storage_url would name the same destination.
|
|
||||||
s3 := v.Config.S3
|
|
||||||
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading local storage binding: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if stored == "" {
|
if stored == "" {
|
||||||
@@ -78,6 +70,27 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
|||||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageBinding returns the destination the local index is bound to
|
||||||
|
// ("" when no binding is recorded yet), then the configured destination
|
||||||
|
// written the same way. It records nothing, so a read-only command can
|
||||||
|
// call it.
|
||||||
|
func (v *Vaultik) storageBinding() (string, string, error) {
|
||||||
|
configured := v.Config.StorageURL
|
||||||
|
if configured == "" {
|
||||||
|
// The s3.* form: bind on its endpoint, bucket and prefix,
|
||||||
|
// written the way storage_url would name the same destination.
|
||||||
|
s3 := v.Config.S3
|
||||||
|
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("reading local storage binding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored, configured, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildBindingMismatchMessage assembles the multi-line explanation
|
// buildBindingMismatchMessage assembles the multi-line explanation
|
||||||
// shown when the local index is bound to a different destination than
|
// shown when the local index is bound to a different destination than
|
||||||
// the currently-configured one (the first line lives in the
|
// the currently-configured one (the first line lives in the
|
||||||
|
|||||||
Reference in New Issue
Block a user