Files
vaultik/internal/vaultik/storage_bind.go
T
clawbot 65036bba1b
check / check (push) Waiting to run
Make snapshot list report a changed destination instead of advising prune (closes #317)
When the local index is bound to a different destination than the
configured one, snapshot list now gives the warning prune, snapshot
purge and snapshot remove refuse with: both destinations and the two
ways out (revert the configuration, or run vaultik database delete).
Below the table it replaces the local-only note and its prune advice.
With --json it goes to stderr through the logger, so stdout stays the
document, whose local rows show remote_present false.

Reading the binding moves out of EnsureStorageBinding into
storageBinding, which records nothing, so the read-only list can call
it without binding a fresh index.

Model: opus-5-5
2026-10-14 04:29:36 +02:00

116 lines
4.5 KiB
Go

package vaultik
import (
"errors"
"fmt"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
)
// errStorageBindingMismatch is returned when the local index database is
// bound to a different destination than the configured one.
var errStorageBindingMismatch = errors.New(
"local index is bound to a different backup destination")
// EnsureStorageBinding guarantees that the local index database is
// bound to the currently-configured storage destination. Every mutating
// command must call this before touching either the local index or the
// destination store, because the two live in lockstep: the local index
// records which chunks/blobs already exist at the destination, and
// mismatched destination + local index produces silent corruption (the
// scanner sees "known" chunks and skips uploads, then writes snapshots
// whose manifests reference blobs that aren't on the new destination).
//
// Behaviour:
// - On first use (empty stored value), record the configured
// destination (storage_url, or the s3.* endpoint, bucket and
// prefix) and log the binding.
// - When the stored value matches the configured destination, do
// nothing and return nil.
// - When the two differ, refuse with an error that tells the user
// how to recover (revert the config, or run `vaultik database
// purge` to discard the local index and rebuild against the new
// destination on the next backup).
//
// Read-only inspection commands (remote info, snapshot list, etc.)
// deliberately don't call this: they can be run against a bare
// destination store without any binding state.
func (v *Vaultik) EnsureStorageBinding() error {
if v.Repositories == nil || v.Config == nil {
// NewForTesting builds a Vaultik with no DB or config;
// there's nothing to bind and no binding to check. Callers
// exercising the bind path use a real DB and populate Config
// explicitly (see storage_bind_test.go).
return nil
}
stored, configured, err := v.storageBinding()
if err != nil {
return 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
}
if stored == configured {
return nil
}
return fmt.Errorf("%w\n%s",
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
// shown when the local index is bound to a different destination than
// the currently-configured one (the first line lives in the
// errStorageBindingMismatch sentinel). Kept as a separate function so
// the multi-line text is expressed as a plain string literal rather
// than a fmt.Errorf argument (staticcheck ST1005 disallows trailing
// punctuation on error format strings).
func buildBindingMismatchMessage(stored, configured string) string {
return " local index bound to: " + stored + "\n" +
" currently configured: " + configured + "\n" +
"\n" +
"The local index database tracks which chunks and blobs already exist at the\n" +
"destination store. Using it against a different destination would silently\n" +
"skip uploads (the scanner would treat every chunk as already present), leaving\n" +
"future snapshots referencing blobs that don't exist at the new destination.\n" +
"\n" +
"To proceed, either:\n" +
" - revert storage_url (or the s3.* endpoint, bucket and prefix) in your\n" +
" config to the bound destination, or\n" +
" - run 'vaultik database delete' to discard the local index and rebuild it\n" +
" from a fresh full backup against the new destination"
}