check / check (push) Failing after 7m57s
The README's steps for restoring on another machine failed at the first command: `config init` wrote a placeholder recipient, and `config.Load` rejects any recipient that does not parse. `config init` now writes an empty `age_recipients` list, `config.Load` accepts an empty list, and `snapshot create` refuses to start without a recipient. A malformed recipient is still rejected at load. The recovery-host test now builds its config with `config init` and `config set` and reads it through `config.Load`, so it imports `internal/cli`. On a fresh file, `config set age_recipients.0` writes the list in flow style (`[age1...]`). Model: opus-5-5
200 lines
7.0 KiB
Go
200 lines
7.0 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/cli"
|
|
"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 written by `config init` and `config set storage_url`, as in
|
|
// the README's steps for restoring on another machine, which 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,
|
|
filepath.Join(tempDir, "config.yml"), "file://"+storeDir)
|
|
|
|
// 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)
|
|
|
|
// With no public key configured, a backup must refuse to start.
|
|
err = recovery.CreateSnapshot(&vaultik.SnapshotCreateOptions{Cron: true})
|
|
require.ErrorContains(t, err, "age_recipients")
|
|
}
|
|
|
|
// 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, and
|
|
// only the secret key plus the shared storer. Its config is written by
|
|
// `config init` and `config set storage_url` at configPath and read by
|
|
// config.Load, as every command reads it, so it has no age_recipients.
|
|
// 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,
|
|
configPath, storageURL string,
|
|
) (*vaultik.Vaultik, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
runVaultikCommand(t, "--config", configPath, "config", "init")
|
|
runVaultikCommand(t, "--config", configPath,
|
|
"config", "set", "storage_url", storageURL)
|
|
|
|
cfg, err := config.Load(configPath)
|
|
require.NoError(t, err)
|
|
|
|
// Set directly rather than through VAULTIK_AGE_SECRET_KEY, which a
|
|
// parallel test cannot change.
|
|
cfg.AgeSecretKey = testAgeSecretKey
|
|
cfg.Hostname = "recovery-host"
|
|
|
|
recoveryDB, err := database.New(ctx, ":memory:")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = recoveryDB.Close() })
|
|
|
|
stdout := &bytes.Buffer{}
|
|
|
|
recovery := &vaultik.Vaultik{
|
|
Config: cfg,
|
|
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
|
|
}
|
|
|
|
// runVaultikCommand runs one vaultik command line in-process and fails the
|
|
// test if it returns an error.
|
|
func runVaultikCommand(t *testing.T, args ...string) {
|
|
t.Helper()
|
|
|
|
cmd := cli.NewRootCommand()
|
|
cmd.SetArgs(args)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
require.NoError(t, cmd.Execute())
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|