A machine restoring after the original is gone has no local index and cannot know a snapshot's human ID; snapshot list shows such snapshots only by their remote key, but restore and verify accepted only the human ID, so recovery could not be done as documented. Restore and verify now also accept a remote key, or an unambiguous leading part of it as snapshot list prints it, resolved against the store's metadata listing. Human IDs are never pure hex, which tells the two forms apart. Deep verify reads the single snapshot in the downloaded per-snapshot database. A new README section walks the recovery end to end; a test backs up, then lists, restores and deep-verifies with an empty index, another hostname and no age_recipients. model: claude-opus-4-8 (implementation, review); claude-fable-5-1 (merge)
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
package vaultik_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/storage"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
|
)
|
|
|
|
// TestRestoreOnAnotherMachine proves the disaster-recovery path: a host
|
|
// that has only the vaultik binary, the age secret key, and the storage
|
|
// credentials — no local index, a different hostname, and no
|
|
// age_recipients configured — can list, restore, and verify a snapshot
|
|
// straight from the destination store.
|
|
//
|
|
// The backup half writes a snapshot with one index and hostname. The
|
|
// restore half throws that index away entirely: a fresh, empty index and
|
|
// a config that shares nothing with the original but the storage location
|
|
// and the secret key. If restore or verify needed the original local
|
|
// index — or the human snapshot ID that only that index holds — this test
|
|
// could not run, because the recovery host can know neither.
|
|
func TestRestoreOnAnotherMachine(t *testing.T) {
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
fs := afero.NewOsFs()
|
|
tempDir := t.TempDir()
|
|
|
|
dataDir := filepath.Join(tempDir, "source")
|
|
storeDir := filepath.Join(tempDir, "remote")
|
|
restoreDir := filepath.Join(tempDir, "restored")
|
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
|
|
|
chunkSize := int64(64 * 1024)
|
|
maxBlobSize := int64(512 * 1024)
|
|
|
|
sourceFiles := writeRecoverySourceTree(t, fs, dataDir, chunkSize)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Backup host: one index, hostname test-host, age_recipients set.
|
|
// runFileStorageBackup closes the index before returning, so nothing
|
|
// below can lean on it.
|
|
_, storer, originalID := runFileStorageBackup(
|
|
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
|
|
|
// Recovery host: a fresh empty index, a different hostname, and no
|
|
// age_recipients — only the secret key and the same storage location.
|
|
recovery, stdout := newRecoveryHost(ctx, t, fs, storer)
|
|
|
|
// The recovery index really is empty. This is the assertion that makes
|
|
// the test a guard against restore quietly depending on the original
|
|
// index: if it did, an empty index would make restore fail.
|
|
localSnaps, err := recovery.Repositories.Snapshots.ListRecent(ctx, 100)
|
|
require.NoError(t, err)
|
|
require.Empty(t, localSnaps, "recovery host must start with no local index")
|
|
|
|
// List: the snapshot shows up as remote-only, identified by its remote
|
|
// key, with no recoverable human ID.
|
|
require.NoError(t, recovery.ListSnapshots(true))
|
|
|
|
rows := decodeListJSON(t, stdout.String())
|
|
require.Len(t, rows, 1)
|
|
|
|
remote := rows[0]
|
|
assert.False(t, remote.LocallyTracked, "snapshot must be remote-only here")
|
|
assert.Empty(t, remote.ID, "the human ID is unknown to the recovery host")
|
|
require.Len(t, remote.RemoteKey, 64)
|
|
assert.Equal(t, snapshot.RemoteSnapshotKey(originalID), remote.RemoteKey,
|
|
"the listed key is the hashed snapshot ID")
|
|
|
|
// Restore driven by the abbreviated identifier the table prints (the
|
|
// first 12 hex of the remote key), then deep-verify from the store
|
|
// keyed by the full remote key. Both are what a recovery host can know.
|
|
require.NoError(t, recovery.Restore(&vaultik.RestoreOptions{
|
|
SnapshotID: remote.RemoteKey[:12],
|
|
TargetDir: restoreDir,
|
|
Verify: true,
|
|
}))
|
|
require.NoError(t, recovery.RunDeepVerify(
|
|
remote.RemoteKey, &vaultik.VerifyOptions{Deep: true}))
|
|
|
|
assertRestoredTreeMatches(t, fs, restoreDir, sourceFiles)
|
|
}
|
|
|
|
// writeRecoverySourceTree writes a small source tree spanning several
|
|
// chunks (so restore reassembles real multi-chunk files) and returns the
|
|
// content keyed by absolute path.
|
|
func writeRecoverySourceTree(
|
|
t *testing.T, fs afero.Fs, dataDir string, chunkSize int64,
|
|
) map[string][]byte {
|
|
t.Helper()
|
|
|
|
sourceFiles := map[string][]byte{
|
|
filepath.Join(dataDir, "notes.txt"): []byte("recover me"),
|
|
filepath.Join(dataDir, "sub", "big.bin"): bytesPattern("big-", int(chunkSize*3)),
|
|
filepath.Join(dataDir, "sub", "small.bin"): bytesPattern("small-", 128),
|
|
}
|
|
|
|
for path, content := range sourceFiles {
|
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
|
}
|
|
|
|
return sourceFiles
|
|
}
|
|
|
|
// newRecoveryHost builds the Vaultik a replacement machine would run: an
|
|
// empty in-memory index, a hostname different from the backup host, no
|
|
// age_recipients, and only the secret key plus the shared storer. It
|
|
// returns the instance and the buffer its stdout is wired to.
|
|
func newRecoveryHost(
|
|
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
|
) (*vaultik.Vaultik, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
recoveryDB, err := database.New(ctx, ":memory:")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = recoveryDB.Close() })
|
|
|
|
stdout := &bytes.Buffer{}
|
|
|
|
recovery := &vaultik.Vaultik{
|
|
Config: &config.Config{
|
|
AgeSecretKey: testAgeSecretKey,
|
|
Hostname: "recovery-host",
|
|
},
|
|
Storage: storer,
|
|
Fs: fs,
|
|
Repositories: database.NewRepositories(recoveryDB),
|
|
DB: recoveryDB,
|
|
Stdout: stdout,
|
|
Stderr: io.Discard,
|
|
UI: ui.NewWithColor(io.Discard, false),
|
|
}
|
|
recovery.SetContext(ctx)
|
|
|
|
return recovery, stdout
|
|
}
|
|
|
|
// assertRestoredTreeMatches byte-compares every restored file against its
|
|
// source content.
|
|
func assertRestoredTreeMatches(
|
|
t *testing.T, fs afero.Fs, restoreDir string, sourceFiles map[string][]byte,
|
|
) {
|
|
t.Helper()
|
|
|
|
for origPath, expected := range sourceFiles {
|
|
restored := filepath.Join(restoreDir, origPath)
|
|
got, err := afero.ReadFile(fs, restored)
|
|
require.NoErrorf(t, err, "restored file missing: %s", restored)
|
|
require.Truef(t, bytes.Equal(got, expected),
|
|
"byte mismatch for %s", origPath)
|
|
}
|
|
}
|