1 Commits
Author SHA1 Message Date
clawbot 62719f4bd1 Bind the local index only after a backup writes the marker (closes #420)
check / check (push) Successful in 9m27s
A first backup that failed before writing anything (a mistyped bucket,
a refused credential) bound the local index to that destination, and
the corrected config was then refused.

A backup now checks for a different binding first, writes the
destination marker, and only then records the configured destination.
Purge, prune, snapshot remove and remote nuke still refuse a different
binding but no longer record one: only a backup puts data in the local
index, so an unbound local index holds nothing from any destination.

Model: opus-5-5
2026-11-11 01:30:14 +00:00
3 changed files with 96 additions and 33 deletions

No files matched your search

+6 -6
View File
@@ -83,12 +83,12 @@ func (v *Vaultik) checkDestinationMarker() error {
return nil
}
// markDestination checks the backup destination store with
// checkStorageBinding and checkDestinationMarker, writes the marker, and
// then binds the local index to the destination with
// EnsureStorageBinding. A first backup marks its destination and a later
// one writes the same text again. Binding after the write means a backup
// that cannot write to the destination leaves the local index unbound.
// markDestination refuses a local index bound to a different
// destination, checks the backup destination store for its marker,
// writes the marker, and then records the binding. A first backup marks
// its destination and a later one writes the same text again. The
// binding is recorded last, so a first backup that cannot write to the
// destination leaves the local index unbound.
func (v *Vaultik) markDestination() error {
err := v.checkStorageBinding()
if err != nil {
+22 -21
View File
@@ -24,9 +24,9 @@ var errStorageBindingMismatch = errors.New(
// snapshots whose manifests reference blobs that aren't on the new
// destination).
//
// It records nothing: only a backup binds the local index, through
// EnsureStorageBinding, and only once it has written to the destination.
// An unbound local index therefore holds nothing from any destination.
// It records nothing. Only a backup binds the local index, through
// EnsureStorageBinding, and it does so before adding anything to it. An
// unbound local index therefore holds nothing from any destination.
//
// Read-only inspection commands (remote info, snapshot list, etc.)
// deliberately don't call this: they can be run against a bare
@@ -53,31 +53,32 @@ func (v *Vaultik) checkStorageBinding() error {
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
}
// EnsureStorageBinding refuses as checkStorageBinding does and, when the
// local index is not bound yet, records the configured destination
// (storage_url, or the s3.* endpoint, bucket and prefix) as its binding.
// A backup calls it right after writing the destination marker, its
// first write to the destination, so a backup that fails before then
// (a mistyped bucket, a refused credential) leaves the local index
// unbound and a corrected config is enough to go on.
// EnsureStorageBinding records the configured destination (storage_url,
// or the s3.* endpoint, bucket and prefix) as the binding of an unbound
// local index. It refuses as checkStorageBinding does when the local
// index is bound to a different destination.
func (v *Vaultik) EnsureStorageBinding() error {
err := v.checkStorageBinding()
if err != nil || v.Repositories == nil || v.Config == nil {
return err
}
stored, configured, err := v.storageBinding()
if err != nil || stored != "" {
if err != nil {
return err
}
err = v.Repositories.LocalMeta.Set(
v.ctx, database.LocalMetaKeyStorageURL, configured)
if err != nil {
return fmt.Errorf("recording local storage binding: %w", err)
if stored == "" {
err = v.Repositories.LocalMeta.Set(
v.ctx, database.LocalMetaKeyStorageURL, configured)
if err != nil {
return fmt.Errorf("recording local storage binding: %w", err)
}
log.Info("Bound local index to storage destination", "storage_url", configured)
return nil
}
log.Info("Bound local index to storage destination", "storage_url", configured)
if stored != configured {
return fmt.Errorf("%w\n%s",
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
}
return nil
}
+68 -6
View File
@@ -10,6 +10,7 @@ import (
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -55,17 +56,78 @@ func TestFailedFirstBackupLeavesIndexUnbound(t *testing.T) {
assert.Equal(t, "file://"+storeDir, storageBindingOf(ctx, t, repos))
}
// TestPruneLeavesIndexUnbound checks that a command other than a backup
// does not bind an unbound local index.
func TestPruneLeavesIndexUnbound(t *testing.T) {
// TestBackupToOtherDestinationWritesNoMarker checks that a backup
// refuses a local index bound to a different destination before it
// writes the marker to the configured one.
func TestBackupToOtherDestinationWritesNoMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos := newFileDestinationVaultik(ctx, t, t.TempDir())
firstDir := t.TempDir()
v, _ := newFileDestinationVaultik(ctx, t, firstDir)
v.Config.StorageURL = "file://" + firstDir
require.NoError(t, v.Prune(&vaultik.PruneOptions{Force: true}))
assert.Empty(t, storageBindingOf(ctx, t, repos))
require.NoError(t, backUp(v, "first"))
secondDir := t.TempDir()
secondStore, err := storage.NewFileStorer(secondDir)
require.NoError(t, err)
v.Storage = secondStore
v.Config.StorageURL = "file://" + secondDir
require.ErrorContains(t, backUp(v, "first"),
"local index is bound to a different backup destination")
assert.NoFileExists(t, filepath.Join(secondDir, markerFile))
}
// TestCommandsOtherThanBackupLeaveIndexUnbound checks that commands
// other than a backup do not bind an unbound local index, whether or not
// they fail. Each runs on a fresh local index.
func TestCommandsOtherThanBackupLeaveIndexUnbound(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
tests := []struct {
name string
run func(v *vaultik.Vaultik) error
}{
{"prune", func(v *vaultik.Vaultik) error {
return v.Prune(&vaultik.PruneOptions{Force: true})
}},
{"snapshot purge", func(v *vaultik.Vaultik) error {
return v.PurgeSnapshotsWithOptions(
&vaultik.SnapshotPurgeOptions{KeepLatest: true, Force: true})
}},
{"snapshot remove", func(v *vaultik.Vaultik) error {
_, err := v.RemoveSnapshot("host_first_2026-01-02T03:04:05Z",
&vaultik.RemoveOptions{Force: true})
return err
}},
{"snapshot remove --all", func(v *vaultik.Vaultik) error {
_, err := v.RemoveAllSnapshots(&vaultik.RemoveOptions{Force: true})
return err
}},
{"remote nuke", func(v *vaultik.Vaultik) error {
return v.NukeRemote(true)
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
v, repos := newFileDestinationVaultik(ctx, t, t.TempDir())
_ = tt.run(v)
assert.Empty(t, storageBindingOf(ctx, t, repos))
})
}
}
// buildBindTestVaultik returns a minimal Vaultik wired with a real