Add snapshot files to list a snapshot's files without restoring it (closes #435)
check / check (push) Successful in 12m11s
check / check (push) Successful in 12m11s
`vaultik snapshot files <snapshot-id> [path] [--json]` downloads and decrypts the snapshot database, as restore does, and prints mode, size, modification time and path for every file at or under the path. A path that matches nothing is an error. It needs the private key. The mode is in Go's file-mode notation, which the README spells out. Named `files` because `ls` is already the alias of `snapshot list`. The missing-key error no longer says "for restore", since this command and deep verify return it too. The README opening now states the private-key rule first. Model: opus-5-5
This commit is contained in:
1 parent
5ca0d46932
commit
600f3a9592
8 files changed
+457
-41
No files matched your search
+6
-5
@@ -38,10 +38,11 @@ const (
|
||||
// prune, remote nuke) write the local index or the remote store. They
|
||||
// hold the PID lock so that at most one runs at a time.
|
||||
mutating lockMode = iota
|
||||
// readOnly commands (info, snapshot list, snapshot verify, remote info,
|
||||
// snapshot restore) do not write the local index or the remote store,
|
||||
// so they run without the lock and are never blocked by a running
|
||||
// mutator. restore writes only to the target directory it is given.
|
||||
// readOnly commands (info, snapshot list, snapshot files, snapshot
|
||||
// verify, remote info, snapshot restore) do not write the local index
|
||||
// or the remote store, so they run without the lock and are never
|
||||
// blocked by a running mutator. restore writes only to the target
|
||||
// directory it is given.
|
||||
readOnly
|
||||
)
|
||||
|
||||
@@ -310,7 +311,7 @@ func RunOperation(
|
||||
}
|
||||
|
||||
// runVaultikApp runs the standard single-operation command lifecycle
|
||||
// shared by the snapshot list/purge/remove and remote nuke subcommands:
|
||||
// shared by the snapshot list/files/purge/remove and remote nuke subcommands:
|
||||
// resolve the config, then run op against the Vaultik instance through
|
||||
// RunOperation, reporting a failure prefixed with failMsg on stderr. mode
|
||||
// says whether the command takes the PID lock. jsonOutput marks a command
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the backup config helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// cmdFiles is the word of `snapshot files`, used to build argument
|
||||
// vectors below.
|
||||
const cmdFiles = "files"
|
||||
|
||||
// TestEntrySnapshotFilesListsOnlyThePath backs up two files and runs
|
||||
// `snapshot files` with the snapshot ID and the path of one of them,
|
||||
// which must be the only file listed.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotFilesListsOnlyThePath(t *testing.T) {
|
||||
configPath, _ := writeBackupConfigWithPrivateKey(t)
|
||||
|
||||
// writeBackupConfig puts file.txt in the source directory beside the
|
||||
// config file.
|
||||
sourceDir := filepath.Join(filepath.Dir(configPath), "source")
|
||||
listed := filepath.Join(sourceDir, "file.txt")
|
||||
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(sourceDir, "other.txt"), []byte("other"), 0o600))
|
||||
|
||||
code, _, stderr := runEntry(t,
|
||||
flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||
require.Equal(t, 0, code, stderr)
|
||||
|
||||
code, stdout, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot,
|
||||
cmdFiles, onlySnapshotID(t, configPath), listed, flagJSON)
|
||||
require.Equal(t, 0, code, stderr)
|
||||
|
||||
var entries []vaultik.ListedFile
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &entries))
|
||||
require.Len(t, entries, 1, "only the path given may be listed")
|
||||
assert.Equal(t, listed, entries[0].Path)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestCLIEntry(t *testing.T) {
|
||||
} else {
|
||||
// Check snapshot subcommands
|
||||
expectedSubCommands := []string{
|
||||
"create", "list", "purge", "verify", "remove", "restore",
|
||||
"create", "list", "files", "purge", "verify", "remove", "restore",
|
||||
}
|
||||
for _, expected := range expectedSubCommands {
|
||||
found := false
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// filesMaxArgs is the maximum positional argument count of
|
||||
// `snapshot files <snapshot-id> [path]`.
|
||||
const filesMaxArgs = 2
|
||||
|
||||
var (
|
||||
errSnapshotIDRequired = errors.New("snapshot ID required")
|
||||
errWrongArgCount = errors.New("wrong argument count")
|
||||
@@ -52,6 +56,7 @@ func NewSnapshotCommand() *cobra.Command {
|
||||
// Add subcommands
|
||||
cmd.AddCommand(newSnapshotCreateCommand())
|
||||
cmd.AddCommand(newSnapshotListCommand())
|
||||
cmd.AddCommand(newSnapshotFilesCommand())
|
||||
cmd.AddCommand(newSnapshotPurgeCommand())
|
||||
cmd.AddCommand(newSnapshotVerifyCommand())
|
||||
cmd.AddCommand(newSnapshotRemoveCommand())
|
||||
@@ -172,6 +177,44 @@ func newSnapshotListCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newSnapshotFilesCommand creates the 'snapshot files' subcommand
|
||||
func newSnapshotFilesCommand() *cobra.Command {
|
||||
var jsonOutput bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "files <snapshot-id> [path]",
|
||||
Short: "List the files in a snapshot",
|
||||
Long: `Lists the path, size, mode and modification time of every file in a
|
||||
snapshot, without restoring it. With a path, lists only that path and
|
||||
what is under it; a path that matches nothing is an error.
|
||||
|
||||
The snapshot may be named by its ID or, on a host with no local index,
|
||||
by the remote key that 'snapshot list' prints for a remote-only snapshot
|
||||
(an unambiguous leading part is enough).
|
||||
|
||||
The file list is only in the snapshot's encrypted database, so this
|
||||
downloads and decrypts it, and requires the age private key in the
|
||||
VAULTIK_AGE_SECRET_KEY environment variable, as 'snapshot restore' does.`,
|
||||
Args: cobra.RangeArgs(1, filesMaxArgs),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := ""
|
||||
if len(args) == filesMaxArgs {
|
||||
path = args[1]
|
||||
}
|
||||
|
||||
return runVaultikApp(cmd, readOnly, jsonOutput,
|
||||
"Failed to list snapshot files",
|
||||
func(v *vaultik.Vaultik) error {
|
||||
return v.ListSnapshotFiles(args[0], path, jsonOutput)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
||||
func newSnapshotPurgeCommand() *cobra.Command {
|
||||
opts := &vaultik.SnapshotPurgeOptions{}
|
||||
|
||||
+17
-13
@@ -31,7 +31,7 @@ var (
|
||||
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
||||
errFilesFailedVerify = errors.New("files failed verification")
|
||||
errDecryptionKeyRequired = errors.New(
|
||||
"decryption key required for restore\n\n" +
|
||||
"decryption key required\n\n" +
|
||||
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
||||
"age private key file:\n" +
|
||||
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
||||
@@ -153,18 +153,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := tempDB.Close()
|
||||
if err != nil {
|
||||
log.Debug("Failed to close temp database", "error", err)
|
||||
}
|
||||
// Remove the whole private directory, so the decrypted database
|
||||
// and any SQLite side files it produced are gone on every path.
|
||||
err = v.Fs.RemoveAll(tempDir)
|
||||
if err != nil {
|
||||
log.Debug("Failed to remove temp database directory", "error", err)
|
||||
}
|
||||
}()
|
||||
defer v.closeSnapshotDB(tempDB, tempDir)
|
||||
|
||||
repos := database.NewRepositories(tempDB)
|
||||
|
||||
@@ -880,6 +869,21 @@ func (v *Vaultik) downloadSnapshotDB(
|
||||
return db, tempDir, nil
|
||||
}
|
||||
|
||||
// closeSnapshotDB closes a database returned by downloadSnapshotDB and
|
||||
// removes its private directory, so the decrypted database and any SQLite
|
||||
// side files it produced are gone.
|
||||
func (v *Vaultik) closeSnapshotDB(db *database.DB, tempDir string) {
|
||||
err := db.Close()
|
||||
if err != nil {
|
||||
log.Debug("Failed to close temp database", "error", err)
|
||||
}
|
||||
|
||||
err = v.Fs.RemoveAll(tempDir)
|
||||
if err != nil {
|
||||
log.Debug("Failed to remove temp database directory", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySnapshotDBIdentity confirms the decrypted metadata database really
|
||||
// is the snapshot named by remoteKey. age decryption proves the database
|
||||
// is readable, not that the object served at
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package vaultik
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
)
|
||||
|
||||
// ListedFile is one entry printed by `snapshot files`. Mode is the Go
|
||||
// file mode string, which shows the entry type and the permission bits
|
||||
// (for example -rw-r--r-- or drwxr-xr-x).
|
||||
type ListedFile struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
MTime time.Time `json:"mtime"`
|
||||
}
|
||||
|
||||
// ListSnapshotFiles prints the path, size, mode and modification time of
|
||||
// every file the snapshot holds at or under path, or of every file when
|
||||
// path is empty. The file list is only in the snapshot's encrypted
|
||||
// database, so this downloads and decrypts it as restore does, and needs
|
||||
// the private key.
|
||||
func (v *Vaultik) ListSnapshotFiles(snapshotID, path string, jsonOutput bool) error {
|
||||
identities, err := v.restoreIdentities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, tempDir, err := v.downloadSnapshotDB(snapshotID, remoteKey, identities)
|
||||
if errors.Is(err, errWrongPrivateKey) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||
}
|
||||
|
||||
defer v.closeSnapshotDB(db, tempDir)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
var files []*database.File
|
||||
|
||||
if path == "" {
|
||||
files, err = repos.Files.ListAll(v.ctx)
|
||||
} else {
|
||||
files, err = repos.Files.ListUnderPath(v.ctx, filepath.Clean(path))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing files: %w", err)
|
||||
}
|
||||
|
||||
if path != "" && len(files) == 0 {
|
||||
return fmt.Errorf("%w: %s", errPathsNotInSnapshot, path)
|
||||
}
|
||||
|
||||
entries := make([]ListedFile, 0, len(files))
|
||||
|
||||
for _, file := range files {
|
||||
entries = append(entries, ListedFile{
|
||||
Path: file.Path.String(),
|
||||
Size: file.Size,
|
||||
Mode: os.FileMode(file.Mode).String(),
|
||||
MTime: file.MTime,
|
||||
})
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
encoder := json.NewEncoder(v.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
return encoder.Encode(entries)
|
||||
}
|
||||
|
||||
if v.UI.Quiet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v.printListedFileTable(entries)
|
||||
}
|
||||
|
||||
// printListedFileTable writes the entries as a plain table to stdout.
|
||||
func (v *Vaultik) printListedFileTable(entries []ListedFile) error {
|
||||
w := tabwriter.NewWriter(v.Stdout, 0, 0, tabPadding, ' ', 0)
|
||||
|
||||
_, err := fmt.Fprintln(w, "MODE\tSIZE\tMODIFIED\tPATH")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
_, err = fmt.Fprintf(w, "%s\t%d\t%s\t%s\n",
|
||||
entry.Mode, entry.Size,
|
||||
entry.MTime.Format(time.RFC3339), entry.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/435:
|
||||
// `snapshot files` lists what a snapshot holds without restoring it.
|
||||
|
||||
// backUpForFileListing backs up the fault source tree and returns a
|
||||
// reader whose stdout is out, the snapshot ID, and the source directory.
|
||||
func backUpForFileListing(
|
||||
t *testing.T, out *bytes.Buffer,
|
||||
) (*vaultik.Vaultik, string, string) {
|
||||
t.Helper()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := faultTestConfig()
|
||||
|
||||
writeFaultSourceTree(t, fs, dataDir)
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
id := fullFaultBackup(ctx, t, fs, store, cfg,
|
||||
database.NewRepositories(db), dataDir, dbPath, "files")
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
v := newReaderVaultik(ctx, cfg, store, nil, fs)
|
||||
v.Stdout = out
|
||||
|
||||
return v, id, dataDir
|
||||
}
|
||||
|
||||
func TestSnapshotFilesJSONListsEveryFileWithItsMetadata(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v, id, dataDir := backUpForFileListing(t, &out)
|
||||
|
||||
require.NoError(t, v.ListSnapshotFiles(id, "", true))
|
||||
|
||||
var entries []vaultik.ListedFile
|
||||
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
|
||||
|
||||
byPath := make(map[string]vaultik.ListedFile, len(entries))
|
||||
for _, entry := range entries {
|
||||
byPath[entry.Path] = entry
|
||||
}
|
||||
|
||||
for _, rel := range []string{"small.txt", "a.bin", "sub", "sub/b.bin", "sub/c.bin"} {
|
||||
path := filepath.Join(dataDir, rel)
|
||||
|
||||
entry, ok := byPath[path]
|
||||
require.True(t, ok, "%s must be listed", path)
|
||||
|
||||
info, err := afero.NewOsFs().Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, info.Mode().String(), entry.Mode, path)
|
||||
assert.True(t, info.ModTime().Equal(entry.MTime),
|
||||
"%s: mtime %v, listed %v", path, info.ModTime(), entry.MTime)
|
||||
|
||||
if !info.IsDir() {
|
||||
assert.Equal(t, info.Size(), entry.Size, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotFilesListsOnlyWhatIsUnderThePath(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v, id, dataDir := backUpForFileListing(t, &out)
|
||||
|
||||
require.NoError(t, v.ListSnapshotFiles(id, filepath.Join(dataDir, "sub"), false))
|
||||
|
||||
text := out.String()
|
||||
assert.Contains(t, text, filepath.Join(dataDir, "sub", "b.bin"))
|
||||
assert.Contains(t, text, filepath.Join(dataDir, "sub", "c.bin"))
|
||||
assert.NotContains(t, text, filepath.Join(dataDir, "small.txt"))
|
||||
assert.True(t, strings.HasPrefix(text, "MODE"), "the table must have a header")
|
||||
}
|
||||
|
||||
// TestSnapshotFilesQuietSuppressesTableNotJSON is the --quiet contract
|
||||
// for `snapshot files`: the table is silenced, but the --json document a
|
||||
// script depends on is still written.
|
||||
func TestSnapshotFilesQuietSuppressesTableNotJSON(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v, id, _ := backUpForFileListing(t, &out)
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
require.NoError(t, v.ListSnapshotFiles(id, "", false))
|
||||
require.Empty(t, out.String(), "the table must be silenced under --quiet")
|
||||
|
||||
require.NoError(t, v.ListSnapshotFiles(id, "", true))
|
||||
|
||||
var entries []vaultik.ListedFile
|
||||
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
|
||||
assert.NotEmpty(t, entries,
|
||||
"the --json document must still be written under --quiet")
|
||||
}
|
||||
|
||||
func TestSnapshotFilesFailsOnAPathThatMatchesNothing(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v, id, dataDir := backUpForFileListing(t, &out)
|
||||
missing := filepath.Join(dataDir, "typo.txt")
|
||||
|
||||
err := v.ListSnapshotFiles(id, missing, false)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), missing, "the error must name the path")
|
||||
assert.Empty(t, out.String(), "nothing must be printed")
|
||||
}
|
||||
|
||||
func TestSnapshotFilesRequiresThePrivateKey(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v, id, _ := backUpForFileListing(t, &out)
|
||||
v.Config.AgeSecretKey = ""
|
||||
|
||||
err := v.ListSnapshotFiles(id, "", false)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "VAULTIK_AGE_SECRET_KEY")
|
||||
}
|
||||
Reference in new issue
Block a user