check / check (push) Waiting to run
The local index was bound to its destination only through storage_url. With the s3.* form nothing was checked, so after s3.bucket or s3.prefix changed the next backup treated every chunk as already stored, uploaded nothing, and completed a snapshot that could not be restored. Without storage_url, the binding now records the destination the s3.* fields name, written as an s3:// URL from s3.endpoint, s3.bucket and s3.prefix, and a change is refused with the same error and advice. The advice line now names the s3.* fields as well as storage_url. Judgement call: the fields are compared as written, so an equivalent spelling (a trailing slash on the prefix, a scheme on the endpoint) is also refused, as it already is for storage_url. Model: opus-5-5
103 lines
4.1 KiB
Go
103 lines
4.1 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// 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"
|
|
}
|