Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40ff647c3 | ||
|
|
88a2aa7953 |
No files matched your search
@@ -483,9 +483,11 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|||||||
|
|
||||||
keys := strings.Split(key, ".")
|
keys := strings.Split(key, ".")
|
||||||
|
|
||||||
_, err = config.SettingType(keys)
|
if keys[0] != config.EnvSection {
|
||||||
if err != nil {
|
_, err = config.SettingType(keys)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = yamlPathSet(root, keys, value)
|
err = yamlPathSet(root, keys, value)
|
||||||
|
|||||||
@@ -343,6 +343,39 @@ func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigSetWritesEnvSection checks that config set writes a key under
|
||||||
|
// the top-level env section, whose keys are not settings, on a config that
|
||||||
|
// loads.
|
||||||
|
func TestConfigSetWritesEnvSection(t *testing.T) {
|
||||||
|
const name = "VAULTIK_TEST_CONFIG_SET_ENV"
|
||||||
|
|
||||||
|
// Loading the changed config sets the variable; t.Setenv restores it
|
||||||
|
// after the test.
|
||||||
|
t.Setenv(name, "")
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
|
||||||
|
path, "env."+name, "value")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config set env.%s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(data), "env:\n "+name+": value\n") {
|
||||||
|
t.Errorf("config set env.%s wrote:\n%s", name, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
||||||
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
||||||
// config.Load returns each one unchanged.
|
// config.Load returns each one unchanged.
|
||||||
|
|||||||
@@ -300,7 +300,8 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = checkKeys(&root, reflect.TypeFor[Config](), "", map[*yaml.Node]bool{})
|
err = checkKeys(&root, reflect.TypeFor[Config](), "",
|
||||||
|
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-13
@@ -14,16 +14,23 @@ import (
|
|||||||
// of the map it names to the map it sits in.
|
// of the map it names to the map it sits in.
|
||||||
const yamlMergeTag = "!!merge"
|
const yamlMergeTag = "!!merge"
|
||||||
|
|
||||||
// envSection is the top-level key of a map of environment variables that
|
// EnvSection is the top-level key of a map of environment variables that
|
||||||
// smartconfig sets while it loads the config. It is not a vaultik setting,
|
// smartconfig sets while it loads the config. It is not a vaultik setting,
|
||||||
// so its keys are not checked.
|
// so its keys are not checked.
|
||||||
const envSection = "env"
|
const EnvSection = "env"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errUnknownKey = errors.New("unknown config key")
|
errUnknownKey = errors.New("unknown config key")
|
||||||
errAnchorContainsItself = errors.New("anchor contains itself")
|
errAnchorContainsItself = errors.New("anchor contains itself")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nodeAndType is a node of a parsed config and the setting type it is
|
||||||
|
// checked against.
|
||||||
|
type nodeAndType struct {
|
||||||
|
node *yaml.Node
|
||||||
|
typ reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
// SettingType returns the type of the setting that the dotted key names in
|
// SettingType returns the type of the setting that the dotted key names in
|
||||||
// Config, following the fields' yaml tags, as for s3.bucket or
|
// Config, following the fields' yaml tags, as for s3.bucket or
|
||||||
// snapshots.home.exclude.0. A snapshot name or a list index may be any
|
// snapshots.home.exclude.0. A snapshot name or a list index may be any
|
||||||
@@ -67,14 +74,16 @@ func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
|||||||
// gives the key's line in the file, when it has one. A value of the wrong
|
// gives the key's line in the file, when it has one. A value of the wrong
|
||||||
// type is left for the YAML decoder to report. following holds the aliases
|
// type is left for the YAML decoder to report. following holds the aliases
|
||||||
// the walk is inside. Meeting one of them again means its anchor contains
|
// the walk is inside. Meeting one of them again means its anchor contains
|
||||||
// itself, which is an error.
|
// itself, which is an error. checked holds each anchored node the walk has
|
||||||
|
// checked completely, with the type it was checked against. An alias to
|
||||||
|
// that node is not followed again for that type.
|
||||||
func checkKeys(node *yaml.Node, typ reflect.Type, path string,
|
func checkKeys(node *yaml.Node, typ reflect.Type, path string,
|
||||||
following map[*yaml.Node]bool,
|
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
|
||||||
) error {
|
) error {
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case yaml.DocumentNode:
|
case yaml.DocumentNode:
|
||||||
for _, child := range node.Content {
|
for _, child := range node.Content {
|
||||||
err := checkKeys(child, typ, path, following)
|
err := checkKeys(child, typ, path, following, checked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -85,23 +94,34 @@ func checkKeys(node *yaml.Node, typ reflect.Type, path string,
|
|||||||
errAnchorContainsItself, node.Value, node.Line)
|
errAnchorContainsItself, node.Value, node.Line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target := nodeAndType{node.Alias, typ}
|
||||||
|
if checked[target] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
following[node] = true
|
following[node] = true
|
||||||
defer delete(following, node)
|
defer delete(following, node)
|
||||||
|
|
||||||
return checkKeys(node.Alias, typ, path, following)
|
err := checkKeys(node.Alias, typ, path, following, checked)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
checked[target] = true
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
if typ.Kind() != reflect.Slice {
|
if typ.Kind() != reflect.Slice {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, child := range node.Content {
|
for i, child := range node.Content {
|
||||||
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)), following)
|
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)),
|
||||||
|
following, checked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
return checkMappingKeys(node, typ, path, following)
|
return checkMappingKeys(node, typ, path, following, checked)
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
// A scalar has no keys.
|
// A scalar has no keys.
|
||||||
}
|
}
|
||||||
@@ -120,12 +140,13 @@ func checkFilledKeys(data map[string]any) error {
|
|||||||
return fmt.Errorf("failed to marshal config data: %w", err)
|
return fmt.Errorf("failed to marshal config data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkKeys(&filled, reflect.TypeFor[Config](), "", map[*yaml.Node]bool{})
|
return checkKeys(&filled, reflect.TypeFor[Config](), "",
|
||||||
|
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMappingKeys is checkKeys for a map.
|
// checkMappingKeys is checkKeys for a map.
|
||||||
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
||||||
following map[*yaml.Node]bool,
|
following map[*yaml.Node]bool, checked map[nodeAndType]bool,
|
||||||
) error {
|
) error {
|
||||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||||
key, value := node.Content[i], node.Content[i+1]
|
key, value := node.Content[i], node.Content[i+1]
|
||||||
@@ -138,7 +159,7 @@ func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range merged {
|
for _, m := range merged {
|
||||||
err := checkKeys(m, typ, path, following)
|
err := checkKeys(m, typ, path, following, checked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -148,7 +169,7 @@ func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyPath := joinKey(path, key.Value)
|
keyPath := joinKey(path, key.Value)
|
||||||
if keyPath == envSection {
|
if keyPath == EnvSection {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +195,7 @@ func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := checkKeys(value, valueType, keyPath, following)
|
err := checkKeys(value, valueType, keyPath, following, checked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package config //nolint:testpackage // checks the unexported errUnknownKey
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -84,6 +87,32 @@ func TestParseRefusesAnchorThatContainsItself(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseRefusesExcessiveAliasing checks that Parse returns an error for
|
||||||
|
// a file whose merge keys (<<) fan out. Each of eight snapshots merges the
|
||||||
|
// one before it ten times. A key check that followed every alias would walk
|
||||||
|
// the first snapshot over 10^8 times and run past the package's test
|
||||||
|
// timeout.
|
||||||
|
func TestParseRefusesExcessiveAliasing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const snapshots, merges = 8, 10
|
||||||
|
|
||||||
|
var data strings.Builder
|
||||||
|
|
||||||
|
data.WriteString(strings.Replace(keysTestConfig, " test:", " test: &s0", 1))
|
||||||
|
|
||||||
|
for i := 1; i <= snapshots; i++ {
|
||||||
|
aliases := slices.Repeat([]string{"*s" + strconv.Itoa(i-1)}, merges)
|
||||||
|
fmt.Fprintf(&data, " s%d: &s%d\n <<: [%s]\n",
|
||||||
|
i, i, strings.Join(aliases, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Parse([]byte(data.String()))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Parse() returned no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestParseAcceptsKnownKeys checks that a config with a ${...} reference
|
// TestParseAcceptsKnownKeys checks that a config with a ${...} reference
|
||||||
// and a YAML merge key (<<) still parses.
|
// and a YAML merge key (<<) still parses.
|
||||||
func TestParseAcceptsKnownKeys(t *testing.T) {
|
func TestParseAcceptsKnownKeys(t *testing.T) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -24,19 +25,28 @@ import (
|
|||||||
// that is not plugged in, cannot be listed. It is not an empty store, so
|
// that is not plugged in, cannot be listed. It is not an empty store, so
|
||||||
// no command may conclude from it that the local snapshots are gone.
|
// no command may conclude from it that the local snapshots are gone.
|
||||||
|
|
||||||
// newFileDestinationVaultik returns a Vaultik that backs up the
|
// newFileDestinationVaultik writes the fault source tree and returns a
|
||||||
// snapshot named "first" to a file:// destination at storeDir.
|
// Vaultik that backs it up to a file:// destination at storeDir.
|
||||||
func newFileDestinationVaultik(
|
func newFileDestinationVaultik(
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories) {
|
) (*vaultik.Vaultik, *database.Repositories) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
osFs := afero.NewOsFs()
|
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
|
||||||
|
|
||||||
writeFaultSourceTree(t, osFs, dataDir)
|
return openFileDestinationVaultik(ctx, t, storeDir, dataDir,
|
||||||
|
filepath.Join(tempDir, "index.sqlite"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFileDestinationVaultik returns a Vaultik that backs up dataDir to a
|
||||||
|
// file:// destination at storeDir, with its local index at dbPath. The
|
||||||
|
// index is closed when the test ends.
|
||||||
|
func openFileDestinationVaultik(
|
||||||
|
ctx context.Context, t *testing.T, storeDir, dataDir, dbPath string,
|
||||||
|
) (*vaultik.Vaultik, *database.Repositories) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(storeDir)
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -48,22 +58,84 @@ func newFileDestinationVaultik(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
cfg := changedFileConfig(dataDir, dbPath)
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
|
|
||||||
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
return newBackupVaultik(ctx, cfg, store, repos, db, afero.NewOsFs()), repos
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstBackup is one backup of the snapshot named "first" to a file://
|
||||||
|
// destination. dir holds its source tree in src, its store in store and
|
||||||
|
// its closed local index in index/index.sqlite. The first test that
|
||||||
|
// needs it makes it, and TestMain removes it. Every test that copies it
|
||||||
|
// backs up the same source tree, so no test may change that tree.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // made once and shared by the tests that copy it
|
||||||
|
var firstBackup struct {
|
||||||
|
once sync.Once
|
||||||
|
dir string
|
||||||
|
ready bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMain removes firstBackup once every test has run.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
_ = os.RemoveAll(firstBackup.dir)
|
||||||
|
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeFirstBackup writes the fault source tree and backs it up as the
|
||||||
|
// snapshot named "first", all in a new directory that it records in
|
||||||
|
// firstBackup.
|
||||||
|
func makeFirstBackup(ctx context.Context, t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp("", "vaultik-first-backup-")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
firstBackup.dir = dir
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
dataDir := filepath.Join(dir, "src")
|
||||||
|
indexDir := filepath.Join(dir, "index")
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
require.NoError(t, os.Mkdir(indexDir, 0o750))
|
||||||
|
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
|
||||||
|
|
||||||
|
v, _ := openFileDestinationVaultik(ctx, t, storeDir, dataDir,
|
||||||
|
filepath.Join(indexDir, "index.sqlite"))
|
||||||
|
require.NoError(t, backUp(v, "first"))
|
||||||
|
require.NoError(t, v.DB.Close())
|
||||||
|
|
||||||
|
firstBackup.ready = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// backUpToFileDestination creates the directory storeDir, as a user must
|
// backUpToFileDestination creates the directory storeDir, as a user must
|
||||||
// before the first backup, and backs up the snapshot named "first" to a
|
// before the first backup, and returns a Vaultik whose file://
|
||||||
// file:// destination there. Everything the returned Vaultik prints
|
// destination there holds a backup of the snapshot named "first". The
|
||||||
// after the backup goes to the returned buffer.
|
// store and the local index are copies of firstBackup's, which is faster
|
||||||
|
// than backing up in every test. The returned Vaultik backs up a source
|
||||||
|
// tree shared with every other caller, so no test may change that tree.
|
||||||
|
// Everything the returned Vaultik prints after the backup goes to the
|
||||||
|
// returned buffer.
|
||||||
func backUpToFileDestination(
|
func backUpToFileDestination(
|
||||||
ctx context.Context, t *testing.T, storeDir string,
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
firstBackup.once.Do(func() { makeFirstBackup(ctx, t) })
|
||||||
|
require.True(t, firstBackup.ready, "another test failed to make firstBackup")
|
||||||
|
|
||||||
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
||||||
require.NoError(t, backUp(v, "first"))
|
require.NoError(t, os.CopyFS(storeDir,
|
||||||
|
os.DirFS(filepath.Join(firstBackup.dir, "store"))))
|
||||||
|
|
||||||
|
indexDir := t.TempDir()
|
||||||
|
require.NoError(t, os.CopyFS(indexDir,
|
||||||
|
os.DirFS(filepath.Join(firstBackup.dir, "index"))))
|
||||||
|
|
||||||
|
v, repos := openFileDestinationVaultik(ctx, t, storeDir,
|
||||||
|
filepath.Join(firstBackup.dir, "src"),
|
||||||
|
filepath.Join(indexDir, "index.sqlite"))
|
||||||
|
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
v.Stdout = out
|
v.Stdout = out
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ import (
|
|||||||
// slice out a few KB — currently the dominant cost in restore.
|
// slice out a few KB — currently the dominant cost in restore.
|
||||||
//
|
//
|
||||||
// The test deliberately constructs an adversarial scenario: three
|
// The test deliberately constructs an adversarial scenario: three
|
||||||
// blobs A/B/C of ~6 MB each, nine files distributed across them, and
|
// blobs A/B/C of five 64 KiB chunks each, nine files distributed across
|
||||||
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2,
|
// them, and path-ordered names that interleave the blobs (a1, b1, c1,
|
||||||
// c2, …) so naive path-order processing would touch every blob before
|
// a2, b2, c2, …) so naive path-order processing would touch every blob
|
||||||
// finishing any of them.
|
// before finishing any of them.
|
||||||
// localitySource is one 1 MiB fixture file used by the locality test.
|
// localitySource is one 64 KiB fixture file used by the locality test.
|
||||||
type localitySource struct {
|
type localitySource struct {
|
||||||
path string
|
path string
|
||||||
data []byte
|
data []byte
|
||||||
@@ -65,10 +65,10 @@ type localityCopy struct {
|
|||||||
|
|
||||||
// localitySourceBytes is the size of each source file of the locality
|
// localitySourceBytes is the size of each source file of the locality
|
||||||
// fixture.
|
// fixture.
|
||||||
const localitySourceBytes = 1024 * 1024
|
const localitySourceBytes = 64 * 1024
|
||||||
|
|
||||||
// buildLocalityFixture writes the adversarial source layout described
|
// buildLocalityFixture writes the adversarial source layout described
|
||||||
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
// in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
|
||||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||||
// (3 per blob group).
|
// (3 per blob group).
|
||||||
func buildLocalityFixture(
|
func buildLocalityFixture(
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ import (
|
|||||||
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
|
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
|
||||||
// disk cache eviction during restore.
|
// disk cache eviction during restore.
|
||||||
//
|
//
|
||||||
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
|
// The scenario: 30 unique 128 KiB random files plus 10 duplicates of
|
||||||
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
|
// those (40 files total, 3.75 MiB of unique content) get backed up with
|
||||||
// blob_size_limit. After backup the snapshot's encrypted blobs are
|
// a 1 MiB blob_size_limit. After backup the snapshot's encrypted blobs
|
||||||
// restored through Vaultik.Restore, and per-key Get counts on the
|
// are restored through Vaultik.Restore, and per-key Get counts on the
|
||||||
// storage layer are recorded. Each blob in the snapshot MUST be
|
// storage layer are recorded. Each blob in the snapshot MUST be
|
||||||
// downloaded exactly once — re-downloads would mean the sweeper either
|
// downloaded exactly once — re-downloads would mean the sweeper either
|
||||||
// evicted a blob that was still needed (LRU regression) or that the
|
// evicted a blob that was still needed (LRU regression) or that the
|
||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
// original AND every duplicate referencing its chunks have been
|
// original AND every duplicate referencing its chunks have been
|
||||||
// restored.
|
// restored.
|
||||||
// buildSweeperFixture writes the source layout for the sweeper test: 30
|
// buildSweeperFixture writes the source layout for the sweeper test: 30
|
||||||
// unique 1 MB random files plus 10 duplicates of a random subset. The
|
// unique 128 KiB random files plus 10 duplicates of a random subset. The
|
||||||
// PRNG seed is fixed so failures are reproducible; the entropy is what
|
// PRNG seed is fixed so failures are reproducible; the entropy is what
|
||||||
// matters here — the FastCDC chunker needs realistic-looking data to
|
// matters here — the FastCDC chunker needs realistic-looking data to
|
||||||
// pick chunk boundaries naturally. Returns the expected content by
|
// pick chunk boundaries naturally. Returns the expected content by
|
||||||
@@ -51,7 +51,7 @@ func buildSweeperFixture(
|
|||||||
) map[string][]byte {
|
) map[string][]byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const fileSize = 1 * 1024 * 1024
|
const fileSize = 128 * 1024
|
||||||
|
|
||||||
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
|
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ func runSweeperBackup(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
|
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
|
||||||
require.Greater(t, scanResult.BlobsCreated, 1,
|
require.Greater(t, scanResult.BlobsCreated, 1,
|
||||||
"30 MB of unique data at 10 MB blob size should yield multiple blobs")
|
"3.75 MiB of unique data at 1 MiB blob size should yield multiple blobs")
|
||||||
|
|
||||||
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
||||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||||
@@ -199,8 +199,8 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
|
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(16 * 1024)
|
||||||
maxBlobSize := int64(10 * 1024 * 1024)
|
maxBlobSize := int64(1024 * 1024)
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ func (e *listEnv) addRemoteRawTimestamp(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||||
|
e.putRemoteManifest(t, remoteKey,
|
||||||
|
encodeRemoteManifest(t, remoteKey, timestamp))
|
||||||
|
|
||||||
|
return remoteKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeRemoteManifest returns the encoded manifest of a one-blob
|
||||||
|
// snapshot whose remote key is remoteKey.
|
||||||
|
func encodeRemoteManifest(t *testing.T, remoteKey, timestamp string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
manifest := &snapshot.Manifest{
|
manifest := &snapshot.Manifest{
|
||||||
// Note: the hashed key, never the human ID. That is precisely
|
// Note: the hashed key, never the human ID. That is precisely
|
||||||
// why a remote-only snapshot cannot be named.
|
// why a remote-only snapshot cannot be named.
|
||||||
@@ -202,11 +213,17 @@ func (e *listEnv) addRemoteRawTimestamp(
|
|||||||
data, err := snapshot.EncodeManifest(manifest, 3)
|
data, err := snapshot.EncodeManifest(manifest, 3)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = e.store.Put(context.Background(),
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// putRemoteManifest stores an encoded manifest on the destination store
|
||||||
|
// under remoteKey.
|
||||||
|
func (e *listEnv) putRemoteManifest(t *testing.T, remoteKey string, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := e.store.Put(context.Background(),
|
||||||
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
|
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return remoteKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixtures shared across the listing tests.
|
// Fixtures shared across the listing tests.
|
||||||
@@ -791,9 +808,17 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
|
|||||||
|
|
||||||
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
|
||||||
|
|
||||||
// One past the cap, so exactly one snapshot is omitted.
|
// One past the cap, so exactly one snapshot is omitted. The listing
|
||||||
|
// names a snapshot by the key its manifest is stored under, never by
|
||||||
|
// the manifest's own ID. So all of them share one encoded manifest:
|
||||||
|
// encoding 1001 took seconds.
|
||||||
|
data := encodeRemoteManifest(t,
|
||||||
|
snapshot.RemoteSnapshotKey("otherhost_bulk"),
|
||||||
|
timestamp.Format(time.RFC3339))
|
||||||
|
|
||||||
for i := range maxRemoteOnlyRowsForTest + 1 {
|
for i := range maxRemoteOnlyRowsForTest + 1 {
|
||||||
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
|
env.putRemoteManifest(t, snapshot.RemoteSnapshotKey(
|
||||||
|
fmt.Sprintf("otherhost_bulk_%04d", i)), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
stderr := captureProcessStderr(t, func() {
|
stderr := captureProcessStderr(t, func() {
|
||||||
|
|||||||
Reference in new issue
Block a user