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" }