Compare commits
2
Commits
713b09346a
...
f35f1e8d98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f35f1e8d98 | ||
|
|
e459a66099 |
No files matched your search
@@ -359,7 +359,8 @@ on the destination in one go, use `vaultik remote nuke --force`.
|
|||||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||||
* `--dry-run`: Show what would be deleted without deleting
|
* `--dry-run`: Show what would be deleted without deleting
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output result as JSON
|
* `--json`: Output result as JSON. Also skips the confirmation prompt, as
|
||||||
|
`--force` does.
|
||||||
|
|
||||||
**`snapshot restore`**: Restore files from a backup snapshot.
|
**`snapshot restore`**: Restore files from a backup snapshot.
|
||||||
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable
|
||||||
@@ -383,7 +384,8 @@ manifests — network cost scales with the number of snapshots. `snapshot
|
|||||||
create --prune` runs the same cleanup automatically; this is the
|
create --prune` runs the same cleanup automatically; this is the
|
||||||
manual entry point for the same work.
|
manual entry point for the same work.
|
||||||
* `--force`: Skip confirmation prompt
|
* `--force`: Skip confirmation prompt
|
||||||
* `--json`: Output stats as JSON
|
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
|
||||||
|
`--force` does.
|
||||||
|
|
||||||
**`info`**: Display system configuration, storage settings, encryption
|
**`info`**: Display system configuration, storage settings, encryption
|
||||||
recipients, and local database statistics.
|
recipients, and local database statistics.
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-07: Documented that `--json` skips the confirmation prompt of
|
||||||
|
`snapshot remove` and `prune`
|
||||||
|
([issue #268](https://git.eeqj.de/sneak/vaultik/issues/268)). Both
|
||||||
|
commands delete without asking under `--json`, since a prompt on stdout
|
||||||
|
would break the JSON document, but the help and the README described
|
||||||
|
only `--force` as skipping it. The `--json` help of both commands and
|
||||||
|
their README entries now say so.
|
||||||
|
|
||||||
|
- 2026-10-07: Made a symlink whose target cannot be read stop the backup
|
||||||
|
([issue #269](https://git.eeqj.de/sneak/vaultik/issues/269)). It was
|
||||||
|
left out of the snapshot with only a debug log line, even without
|
||||||
|
`--skip-errors`. It now aborts the run, or with `--skip-errors` is
|
||||||
|
skipped with the `Failed to access` error line that any other entry
|
||||||
|
the scan cannot read gets.
|
||||||
|
|
||||||
- 2026-10-07: Stopped a killed rclone upload from leaving a truncated
|
- 2026-10-07: Stopped a killed rclone upload from leaving a truncated
|
||||||
object under its key
|
object under its key
|
||||||
([issue #266](https://git.eeqj.de/sneak/vaultik/issues/266)). The
|
([issue #266](https://git.eeqj.de/sneak/vaultik/issues/266)). The
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJSONHelpSaysConfirmationPromptIsSkipped checks that the --json help
|
||||||
|
// of `snapshot remove` and `prune` says the flag skips the confirmation
|
||||||
|
// prompt. Both delete without asking under --json, since a prompt on
|
||||||
|
// stdout would break the JSON document.
|
||||||
|
func TestJSONHelpSaysConfirmationPromptIsSkipped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, cmd := range []*cobra.Command{
|
||||||
|
newSnapshotRemoveCommand(),
|
||||||
|
NewPruneCommand(),
|
||||||
|
} {
|
||||||
|
assert.Contains(t, cmd.Flags().Lookup("json").Usage,
|
||||||
|
"skips the confirmation prompt", cmd.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,8 @@ referenced.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output pruning stats as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output pruning stats as JSON; skips the confirmation prompt, as --force does")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -280,7 +280,8 @@ nuke --force' — it is the single supported entry point for that.`,
|
|||||||
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip confirmation prompt")
|
||||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
"Show what would be removed without removing")
|
"Show what would be removed without removing")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output result as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false,
|
||||||
|
"Output result as JSON; skips the confirmation prompt, as --force does")
|
||||||
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
cmd.Flags().BoolVar(&opts.LocalOnly, "local-only", false,
|
||||||
"Skip remote cleanup; only touch the local index")
|
"Skip remote cleanup; only touch the local index")
|
||||||
|
|
||||||
|
|||||||
@@ -889,9 +889,10 @@ func (s *Scanner) scanPhase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle symlinks and directories
|
// Handle symlinks and directories
|
||||||
if handled := s.recordSpecialEntry(
|
handled, err := s.recordSpecialEntry(
|
||||||
filePath, info, existingFiles, collector, result); handled {
|
filePath, info, existingFiles, collector, result)
|
||||||
return nil
|
if handled {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip other non-regular files (devices, sockets, etc.)
|
// Skip other non-regular files (devices, sockets, etc.)
|
||||||
@@ -933,22 +934,25 @@ func (s *Scanner) scanPhase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recordSpecialEntry records symlinks and directories (which have no
|
// recordSpecialEntry records symlinks and directories (which have no
|
||||||
// data to chunk) and reports whether it handled the entry.
|
// data to chunk) and reports whether it handled the entry. For a symlink
|
||||||
|
// whose target cannot be read it returns handleWalkError's result.
|
||||||
func (s *Scanner) recordSpecialEntry(
|
func (s *Scanner) recordSpecialEntry(
|
||||||
filePath string, info os.FileInfo,
|
filePath string, info os.FileInfo,
|
||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
collector *scanCollector, result *ScanResult,
|
collector *scanCollector, result *ScanResult,
|
||||||
) bool {
|
) (bool, error) {
|
||||||
// Handle symlinks
|
// Handle symlinks
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
file := s.buildSymlinkEntry(filePath, info)
|
file, err := s.buildSymlinkEntry(filePath, info)
|
||||||
if file != nil {
|
if err != nil {
|
||||||
existingFiles[filePath] = struct{}{}
|
return true, s.handleWalkError(filePath, err)
|
||||||
collector.addToProcess(filePath, info, file)
|
|
||||||
s.updateScanEntryStats(result, true, info)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
existingFiles[filePath] = struct{}{}
|
||||||
|
collector.addToProcess(filePath, info, file)
|
||||||
|
s.updateScanEntryStats(result, true, info)
|
||||||
|
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle directories (record for permission/ownership preservation
|
// Handle directories (record for permission/ownership preservation
|
||||||
@@ -958,10 +962,10 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
collector.addToProcess(filePath, info, file)
|
collector.addToProcess(filePath, info, file)
|
||||||
|
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWalkError deals with a filesystem error surfaced by the walk:
|
// handleWalkError deals with a filesystem error surfaced by the walk:
|
||||||
@@ -1111,13 +1115,12 @@ func (s *Scanner) printScanProgressLine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSymlinkEntry creates a File record for a symlink.
|
// buildSymlinkEntry creates a File record for a symlink.
|
||||||
// Returns nil if the link target cannot be read.
|
func (s *Scanner) buildSymlinkEntry(
|
||||||
func (s *Scanner) buildSymlinkEntry(path string, info os.FileInfo) *database.File {
|
path string, info os.FileInfo,
|
||||||
|
) (*database.File, error) {
|
||||||
target, err := os.Readlink(path)
|
target, err := os.Readlink(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug("Cannot read symlink target", "path", path, "error", err)
|
return nil, err
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid, gid uint32
|
var uid, gid uint32
|
||||||
@@ -1136,7 +1139,7 @@ func (s *Scanner) buildSymlinkEntry(path string, info os.FileInfo) *database.Fil
|
|||||||
UID: uid,
|
UID: uid,
|
||||||
GID: gid,
|
GID: gid,
|
||||||
LinkTarget: types.FilePath(target),
|
LinkTarget: types.FilePath(target),
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDirectoryEntry creates a File record for a directory.
|
// buildDirectoryEntry creates a File record for a directory.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package snapshot_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
// errSimTempFail is the one-time temp-file creation failure blobTempFailFs
|
||||||
@@ -80,6 +82,30 @@ func (f *readFailFs) Open(name string) (afero.File, error) {
|
|||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linkRemovedAfterLstatFs is the real filesystem, except that the symlink at
|
||||||
|
// target is removed right after the walk lstats it, as happens when a link is
|
||||||
|
// deleted during a backup. The scanner's readlink of it then fails.
|
||||||
|
type linkRemovedAfterLstatFs struct {
|
||||||
|
afero.OsFs
|
||||||
|
|
||||||
|
t *testing.T
|
||||||
|
target string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *linkRemovedAfterLstatFs) LstatIfPossible(
|
||||||
|
name string,
|
||||||
|
) (os.FileInfo, bool, error) {
|
||||||
|
info, lstatCalled, err := f.OsFs.LstatIfPossible(name)
|
||||||
|
if err == nil && name == f.target {
|
||||||
|
rmErr := os.Remove(name)
|
||||||
|
if rmErr != nil {
|
||||||
|
f.t.Errorf("removing %s: %v", name, rmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, lstatCalled, err
|
||||||
|
}
|
||||||
|
|
||||||
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
// writeSkipErrorTestFile writes one file into fs with a fixed mtime.
|
||||||
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -102,10 +128,11 @@ func writeSkipErrorTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSkipErrorScan scans /source on fs with the given skip-errors setting and
|
// runSkipErrorScan scans source on fs with the given skip-errors setting,
|
||||||
// returns the repositories (for inspection) and the scan error.
|
// printing user-facing messages to uiw (nil discards them), and returns the
|
||||||
|
// repositories (for inspection) and the scan error.
|
||||||
func runSkipErrorScan(
|
func runSkipErrorScan(
|
||||||
t *testing.T, fs afero.Fs, skipErrors bool,
|
t *testing.T, fs afero.Fs, source string, skipErrors bool, uiw *ui.Writer,
|
||||||
) (*database.Repositories, error) {
|
) (*database.Repositories, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -130,6 +157,7 @@ func runSkipErrorScan(
|
|||||||
MaxBlobSize: int64(1024 * 1024),
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
CompressionLevel: 3,
|
CompressionLevel: 3,
|
||||||
AgeRecipients: []string{testAgePublicKey},
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
UI: uiw,
|
||||||
SkipErrors: skipErrors,
|
SkipErrors: skipErrors,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,7 +165,7 @@ func runSkipErrorScan(
|
|||||||
snapshotID := "test-snapshot-skip-errors"
|
snapshotID := "test-snapshot-skip-errors"
|
||||||
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
_, err = scanner.Scan(ctx, "/source", snapshotID)
|
_, err = scanner.Scan(ctx, source, snapshotID)
|
||||||
|
|
||||||
return repos, err
|
return repos, err
|
||||||
}
|
}
|
||||||
@@ -157,7 +185,7 @@ func TestScannerPackingFailureAbortsUnderSkipErrors(t *testing.T) {
|
|||||||
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
writeSkipErrorTestFile(t, fs, "/source/file1.txt", "first file content")
|
||||||
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
writeSkipErrorTestFile(t, fs, "/source/file2.txt", "second file content")
|
||||||
|
|
||||||
repos, err := runSkipErrorScan(t, fs, true)
|
repos, err := runSkipErrorScan(t, fs, "/source", true, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected scan to abort on the packer error, got nil")
|
t.Fatal("expected scan to abort on the packer error, got nil")
|
||||||
}
|
}
|
||||||
@@ -184,7 +212,7 @@ func TestScannerReadErrorAbortsWithoutSkipErrors(t *testing.T) {
|
|||||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||||
|
|
||||||
_, err := runSkipErrorScan(t, fs, false)
|
_, err := runSkipErrorScan(t, fs, "/source", false, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected scan to fail on the read error, got nil")
|
t.Fatal("expected scan to fail on the read error, got nil")
|
||||||
}
|
}
|
||||||
@@ -200,7 +228,7 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
|||||||
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
fs := &readFailFs{Fs: afero.NewMemMapFs(), target: target}
|
||||||
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
writeSkipErrorTestFile(t, fs, target, "content that cannot be read")
|
||||||
|
|
||||||
repos, err := runSkipErrorScan(t, fs, true)
|
repos, err := runSkipErrorScan(t, fs, "/source", true, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -214,3 +242,63 @@ func TestScannerReadErrorSkippedWithSkipErrors(t *testing.T) {
|
|||||||
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
t.Fatalf("expected unreadable file skipped, got %d chunks", len(chunks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeSymlinkSource creates a source directory on disk holding one symlink
|
||||||
|
// and returns the directory and the symlink's path.
|
||||||
|
func writeSymlinkSource(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sourceDir := t.TempDir()
|
||||||
|
linkPath := filepath.Join(sourceDir, "link")
|
||||||
|
|
||||||
|
err := os.Symlink("target.txt", linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceDir, linkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerUnreadableSymlinkAbortsWithoutSkipErrors checks that a symlink
|
||||||
|
// whose target cannot be read aborts the run when --skip-errors is not set.
|
||||||
|
func TestScannerUnreadableSymlinkAbortsWithoutSkipErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sourceDir, linkPath := writeSymlinkSource(t)
|
||||||
|
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||||
|
|
||||||
|
_, err := runSkipErrorScan(t, fs, sourceDir, false, nil)
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("expected scan to fail on the removed symlink, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScannerUnreadableSymlinkSkippedWithSkipErrors checks that a symlink
|
||||||
|
// whose target cannot be read is skipped with an error line, and the run
|
||||||
|
// completes, when --skip-errors is set.
|
||||||
|
func TestScannerUnreadableSymlinkSkippedWithSkipErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sourceDir, linkPath := writeSymlinkSource(t)
|
||||||
|
fs := &linkRemovedAfterLstatFs{t: t, target: linkPath}
|
||||||
|
uiw := ui.NewWithColor(io.Discard, false)
|
||||||
|
|
||||||
|
repos, err := runSkipErrorScan(t, fs, sourceDir, true, uiw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected scan to complete with --skip-errors, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uiw.ErrorCount() != 1 {
|
||||||
|
t.Fatalf("expected one error line for the symlink, got %d",
|
||||||
|
uiw.ErrorCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := repos.Files.GetByPath(context.Background(), linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getting %s: %v", linkPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
t.Fatalf("expected %s not to be recorded", linkPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPruneBlobs_JSONDeletesWithoutAsking checks that prune with --json
|
||||||
|
// and without --force deletes an unreferenced blob without the
|
||||||
|
// confirmation prompt. Stdin is empty, so a prompt would read no answer
|
||||||
|
// and cancel, and its text would come before the JSON document.
|
||||||
|
func TestPruneBlobs_JSONDeletesWithoutAsking(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
// The store holds no manifest, so nothing references this blob.
|
||||||
|
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||||
|
|
||||||
|
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||||
|
"/" + testBlobHashA
|
||||||
|
assert.False(t, env.store.hasKey(blobKey),
|
||||||
|
"the unreferenced blob must be deleted")
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||||
|
"stdout must hold only the JSON document, got:\n%s",
|
||||||
|
env.stdout.String())
|
||||||
|
assert.Equal(t, 1, result.BlobsDeleted)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user