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
129 lines
4.8 KiB
Go
129 lines
4.8 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")
|
|
|
|
// checkStorageBinding refuses when the local index database is bound to
|
|
// a different destination than the configured one, with an error that
|
|
// tells the user how to recover (revert the config, or run `vaultik
|
|
// database delete`). Every mutating command calls it 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 a mismatched pair 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).
|
|
//
|
|
// 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
|
|
// destination store without any binding state.
|
|
func (v *Vaultik) checkStorageBinding() error {
|
|
if v.Repositories == nil || v.Config == nil {
|
|
// NewForTesting builds a Vaultik with no DB or config;
|
|
// there's 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 == "" || stored == configured {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("%w\n%s",
|
|
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
|
}
|
|
|
|
// 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 {
|
|
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 fmt.Errorf("%w\n%s",
|
|
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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"
|
|
}
|