Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6a392ccd | ||
|
|
ea72697992 |
+14
-7
@@ -40,21 +40,28 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
# The VERSION build arg when one is given, otherwise
|
||||||
# `git describe --tags --always` on the .git in the build context. With
|
# `git describe --tags --always` on the .git in the build context. The
|
||||||
# .git present, a version that is still empty, dev or unknown fails the
|
# commit and its date always come from that .git. With .git present, a
|
||||||
# build: git is missing or could not read the checkout. The commit and
|
# version that is still empty, dev or unknown, or a commit or date that
|
||||||
# its date always come from that .git. A context without .git, such as
|
# is unknown, fails the build: git is missing or could not read the
|
||||||
# a source export, stamps "dev" and an "unknown" commit and date.
|
# checkout, as when .git is a file pointing outside the context. A
|
||||||
|
# context without .git, such as a source export, stamps "dev" and an
|
||||||
|
# "unknown" commit and date.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
RUN VERSION="${VERSION:-$(git describe --tags --always || echo dev)}"; \
|
||||||
|
commit="$(git rev-parse HEAD || echo unknown)"; \
|
||||||
|
commit_date="$(git show -s --format=%cs HEAD || echo unknown)"; \
|
||||||
if [ -e .git ]; then \
|
if [ -e .git ]; then \
|
||||||
case "$VERSION" in ""|dev|unknown) \
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
echo "version is '$VERSION' although .git is present" >&2; \
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
exit 1 ;; \
|
exit 1 ;; \
|
||||||
esac; \
|
esac; \
|
||||||
|
if [ "$commit" = unknown ] || [ "$commit_date" = unknown ]; then \
|
||||||
|
echo "commit is '$commit' and its date '$commit_date'" \
|
||||||
|
"although .git is present" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
commit="$(git rev-parse HEAD || echo unknown)"; \
|
|
||||||
commit_date="$(git show -s --format=%cs HEAD || echo unknown)"; \
|
|
||||||
globals=sneak.berlin/go/vaultik/internal/globals; \
|
globals=sneak.berlin/go/vaultik/internal/globals; \
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags="-s -w -X ${globals}.Version=${VERSION} \
|
-ldflags="-s -w -X ${globals}.Version=${VERSION} \
|
||||||
|
|||||||
@@ -818,7 +818,8 @@ them. We provide:
|
|||||||
find out whether the tree is clean.
|
find out whether the tree is clean.
|
||||||
* `script/fmt` — format all code (writes)
|
* `script/fmt` — format all code (writes)
|
||||||
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
|
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
|
||||||
the host, over the Go files git lists.
|
the host, over the Go files git lists, and fails outside a git
|
||||||
|
checkout.
|
||||||
* `script/check` — run `script/test`, `script/lint`, and
|
* `script/check` — run `script/test`, `script/lint`, and
|
||||||
`script/fmt-check`.
|
`script/fmt-check`.
|
||||||
* `script/docker` — build the Docker image tagged via
|
* `script/docker` — build the Docker image tagged via
|
||||||
@@ -859,7 +860,8 @@ file. It is `git describe --tags --always --dirty`, which
|
|||||||
A `docker build .` of a clone, with no build arguments, runs the same
|
A `docker build .` of a clone, with no build arguments, runs the same
|
||||||
`git describe` (without `--dirty`) on the `.git` in its build context,
|
`git describe` (without `--dirty`) on the `.git` in its build context,
|
||||||
so it stamps the same value for a clean commit; the build fails if the
|
so it stamps the same value for a clean commit; the build fails if the
|
||||||
context carries `.git` and no version comes out. A binary built without
|
context carries `.git` and no version, commit or commit date comes out.
|
||||||
|
A binary built without
|
||||||
git metadata reports `dev`, or `unknown` when `script/docker` or
|
git metadata reports `dev`, or `unknown` when `script/docker` or
|
||||||
`script/cibuild` built it outside a git checkout.
|
`script/cibuild` built it outside a git checkout.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
is v2.14.0, with its new findings fixed in the code, and the rules in
|
is v2.14.0, with its new findings fixed in the code, and the rules in
|
||||||
`CLAUDE.md` now live in `AGENTS.md`.
|
`CLAUDE.md` now live in `AGENTS.md`.
|
||||||
|
|
||||||
|
- 2026-10-06: Made a `file://` destination whose directory is missing
|
||||||
|
count as one that cannot be listed
|
||||||
|
([issue #220](https://git.eeqj.de/sneak/vaultik/issues/220)). The file
|
||||||
|
backend listed a missing directory as an empty store, so with the
|
||||||
|
quickstart's USB stick unplugged `snapshot list` reported every local
|
||||||
|
snapshot as missing from the store, `snapshot remove` claimed to have
|
||||||
|
removed metadata it never reached, and `prune` dropped every local
|
||||||
|
snapshot record. Listing a missing destination directory is now an
|
||||||
|
error; a first backup still creates the directory.
|
||||||
|
|
||||||
- 2026-10-06: Made `--older-than` and `--keep-newer-than` reject a
|
- 2026-10-06: Made `--older-than` and `--keep-newer-than` reject a
|
||||||
duration with characters outside its number-and-unit parts
|
duration with characters outside its number-and-unit parts
|
||||||
([issue #215](https://git.eeqj.de/sneak/vaultik/issues/215)). The
|
([issue #215](https://git.eeqj.de/sneak/vaultik/issues/215)). The
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ func TestProductDockerfileTakesVersionAsBuildArg(t *testing.T) {
|
|||||||
// no VERSION, such as a plain `docker build .` of a clone, takes it from
|
// no VERSION, such as a plain `docker build .` of a clone, takes it from
|
||||||
// `git describe` of the .git in its context, stamps "dev" when the
|
// `git describe` of the .git in its context, stamps "dev" when the
|
||||||
// context has no .git, and fails rather than stamp "dev" when that .git
|
// context has no .git, and fails rather than stamp "dev" when that .git
|
||||||
// yields no version. The commit and its date come from the same .git.
|
// yields no version. The commit and its date come from the same .git,
|
||||||
|
// and the build fails rather than stamp them "unknown" when it is
|
||||||
|
// present.
|
||||||
func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
|
func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -66,6 +68,10 @@ func TestProductDockerfileDerivesVersionFromGit(t *testing.T) {
|
|||||||
"%s must stamp the commit from git", productDockerfile)
|
"%s must stamp the commit from git", productDockerfile)
|
||||||
assert.Contains(t, found[buildAt], "git show -s --format=%cs HEAD",
|
assert.Contains(t, found[buildAt], "git show -s --format=%cs HEAD",
|
||||||
"%s must stamp the commit date from git", productDockerfile)
|
"%s must stamp the commit date from git", productDockerfile)
|
||||||
|
assert.Contains(t, found[buildAt],
|
||||||
|
`[ "$commit" = unknown ] || [ "$commit_date" = unknown ]`,
|
||||||
|
"%s must fail when the context carries .git but yields no commit"+
|
||||||
|
" or date", productDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
// TestDockerScriptComputesVersionOnTheHost fails unless script/docker
|
||||||
|
|||||||
@@ -172,9 +172,9 @@ func TestBannerSuppressedInArgs(t *testing.T) {
|
|||||||
|
|
||||||
// hermeticConfig is a complete, valid config that needs no network and
|
// hermeticConfig is a complete, valid config that needs no network and
|
||||||
// no credentials: file:// storage is exempt from the S3 credential
|
// no credentials: file:// storage is exempt from the S3 credential
|
||||||
// checks, and FileStorer over a directory that does not exist lists
|
// checks. A test that lists the destination must create its directory
|
||||||
// zero objects without erroring. Chunk, blob and compression settings
|
// first, because listing a directory that does not exist is an error.
|
||||||
// are filled in by config.Load.
|
// Chunk, blob and compression settings are filled in by config.Load.
|
||||||
const hermeticConfig = `age_recipients:
|
const hermeticConfig = `age_recipients:
|
||||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj
|
||||||
snapshots:
|
snapshots:
|
||||||
@@ -196,22 +196,25 @@ hostname: test-host
|
|||||||
// `snapshot list` is the command chosen because it is the only --json
|
// `snapshot list` is the command chosen because it is the only --json
|
||||||
// command that reaches its document without a populated destination
|
// command that reaches its document without a populated destination
|
||||||
// store: it reads the local index, streams `metadata/` (empty here),
|
// store: it reads the local index, streams `metadata/` (empty here),
|
||||||
// and treats a barren destination as an empty list rather than a
|
// and treats an empty destination directory as an empty list rather
|
||||||
// failure.
|
// than a failure.
|
||||||
//
|
//
|
||||||
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
|
||||||
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|
||||||
contents := fmt.Sprintf(hermeticConfig,
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
filepath.Join(dir, "source"),
|
filepath.Join(dir, "source"),
|
||||||
filepath.Join(dir, "store"),
|
storeDir,
|
||||||
filepath.Join(dir, "index.sqlite"))
|
filepath.Join(dir, "index.sqlite"))
|
||||||
|
|
||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
// package init; point it at the temp dir so the test neither
|
// package init; point it at the temp dir so the test neither
|
||||||
// touches nor collides with the real one.
|
// touches nor collides with the real one.
|
||||||
|
|||||||
@@ -98,25 +98,30 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeHermeticPruneConfig builds a config over a temp directory and, if
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
// seedStale is set, creates the index database up front with one
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
// snapshot record that has no counterpart on the destination store.
|
// index database up front with one snapshot record that has no
|
||||||
// Returns the config path.
|
// counterpart on the destination store. Returns the config path.
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
indexPath := filepath.Join(dir, "index.sqlite")
|
indexPath := filepath.Join(dir, "index.sqlite")
|
||||||
|
storeDir := filepath.Join(dir, "store")
|
||||||
|
|
||||||
contents := fmt.Sprintf(hermeticConfig,
|
contents := fmt.Sprintf(hermeticConfig,
|
||||||
filepath.Join(dir, "source"),
|
filepath.Join(dir, "source"),
|
||||||
filepath.Join(dir, "store"),
|
storeDir,
|
||||||
indexPath)
|
indexPath)
|
||||||
|
|
||||||
require.NoError(t,
|
require.NoError(t,
|
||||||
os.WriteFile(configPath, []byte(contents), configFileMode))
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
||||||
|
|
||||||
|
// prune fails on a destination directory that does not exist, so the
|
||||||
|
// empty store is created here rather than left to a first backup.
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
// The PID lock lives under xdg.DataHome, which xdg resolves at
|
||||||
// package init; point it at the temp dir so the test neither
|
// package init; point it at the temp dir so the test neither
|
||||||
// touches nor collides with the real one.
|
// touches nor collides with the real one.
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ type FileStorer struct {
|
|||||||
//
|
//
|
||||||
// Construction is intentionally cheap and does not touch the filesystem.
|
// Construction is intentionally cheap and does not touch the filesystem.
|
||||||
// The basePath is recorded; the directory is created lazily on first
|
// The basePath is recorded; the directory is created lazily on first
|
||||||
// write. Reads (Get/Stat/List) tolerate a missing basePath — a missing
|
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
||||||
// or unmounted destination during `snapshot list` should NOT block the
|
|
||||||
// command, it should degrade to "no remote snapshots reachable" with a
|
|
||||||
// warning. Write operations (Put/PutWithProgress) call MkdirAll for the
|
|
||||||
// per-blob parent directory, which also covers basePath on first use.
|
// per-blob parent directory, which also covers basePath on first use.
|
||||||
|
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||||
|
// List and ListStream fail on a missing basePath, because listing it as
|
||||||
|
// an empty store would make `prune` drop every local snapshot record.
|
||||||
//
|
//
|
||||||
// Uses the real OS filesystem by default; call SetFilesystem to
|
// Uses the real OS filesystem by default; call SetFilesystem to
|
||||||
// override for testing.
|
// override for testing.
|
||||||
@@ -119,13 +119,20 @@ func (f *FileStorer) Delete(_ context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all keys with the given prefix.
|
// List returns all keys with the given prefix. It fails when the
|
||||||
|
// destination directory is missing; a missing prefix under it is an
|
||||||
|
// empty listing.
|
||||||
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("checking destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if base path exists
|
// Check if the prefix exists
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
exists, err := afero.Exists(f.fs, basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("checking path: %w", err)
|
return nil, fmt.Errorf("checking path: %w", err)
|
||||||
@@ -167,16 +174,24 @@ func (f *FileStorer) List(ctx context.Context, prefix string) ([]string, error)
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets.
|
// ListStream returns a channel of ObjectInfo for large result sets. Like
|
||||||
|
// List, it sends an error when the destination directory is missing.
|
||||||
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan ObjectInfo {
|
||||||
ch := make(chan ObjectInfo)
|
ch := make(chan ObjectInfo)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
|
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if err != nil {
|
||||||
|
ch <- ObjectInfo{Err: fmt.Errorf("checking destination directory: %w", err)}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
basePath := f.fullPath(prefix)
|
basePath := f.fullPath(prefix)
|
||||||
|
|
||||||
// Check if base path exists
|
// Check if the prefix exists
|
||||||
exists, err := afero.Exists(f.fs, basePath)
|
exists, err := afero.Exists(f.fs, basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
ch <- ObjectInfo{Err: fmt.Errorf("checking path: %w", err)}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package storage_test
|
package storage_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
@@ -25,3 +29,34 @@ func TestFileStorer(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
runStorerConformance(t, newFileStorer)
|
runStorerConformance(t, newFileStorer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFileStorerListMissingDestination checks that List and ListStream
|
||||||
|
// fail when the destination directory does not exist, instead of
|
||||||
|
// reporting an empty store.
|
||||||
|
func TestFileStorerListMissingDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(filepath.Join(t.TempDir(), "unmounted"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := s.List(ctx, "metadata/")
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("List = %v, %v; want a not-exist error", keys, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
for object := range s.ListStream(ctx, "metadata/") {
|
||||||
|
if object.Err != nil {
|
||||||
|
streamErr = object.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(streamErr, fs.ErrNotExist) {
|
||||||
|
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -301,6 +301,10 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
|
|
||||||
writeFaultSourceTree(t, fs, dataDir)
|
writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
// No upload succeeds, so nothing creates the destination directory.
|
||||||
|
// It must exist for the listing below to show that no blob survived.
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(storeDir)
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/220: a
|
||||||
|
// file:// destination whose directory is missing, such as a USB stick
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// backUpToFileDestination backs up the snapshot named "first" to a
|
||||||
|
// file:// destination at storeDir, which need not exist yet. Everything
|
||||||
|
// the returned Vaultik prints after the backup goes to the returned
|
||||||
|
// buffer.
|
||||||
|
func backUpToFileDestination(
|
||||||
|
ctx context.Context, t *testing.T, storeDir string,
|
||||||
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
osFs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
writeFaultSourceTree(t, osFs, dataDir)
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
|
||||||
|
|
||||||
|
require.NoError(t, backUp(v, "first"))
|
||||||
|
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
v.Stdout = out
|
||||||
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
|
return v, repos, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// backUpThenUnplug backs up to a file:// destination, then moves the
|
||||||
|
// destination directory away, as unplugging the volume it lives on would.
|
||||||
|
func backUpThenUnplug(
|
||||||
|
ctx context.Context, t *testing.T,
|
||||||
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "usbstick")
|
||||||
|
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
||||||
|
|
||||||
|
return v, repos, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
||||||
|
// to a destination directory that does not exist yet creates it, and
|
||||||
|
// that the destination can be listed afterwards.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||||
|
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
||||||
|
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
|
|
||||||
|
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||||
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||||
|
// warns and shows the local index alone, without reporting the local
|
||||||
|
// snapshot as missing from the destination.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||||
|
func TestListSnapshotsWarnsWhenDestinationMissing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpThenUnplug(ctx, t)
|
||||||
|
id := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), "Could not list backup destination store")
|
||||||
|
assert.Contains(t, out.String(), "Showing snapshots from the local index only.")
|
||||||
|
assert.Contains(t, out.String(), id)
|
||||||
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshotWarnsWhenDestinationMissing checks that snapshot
|
||||||
|
// remove warns that the metadata could not be removed from the
|
||||||
|
// destination, instead of reporting that it was.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||||
|
func TestRemoveSnapshotWarnsWhenDestinationMissing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpThenUnplug(ctx, t)
|
||||||
|
|
||||||
|
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
|
||||||
|
&vaultik.RemoveOptions{Force: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, result.RemoteRemoved)
|
||||||
|
assert.Contains(t, out.String(),
|
||||||
|
"Could not remove snapshot metadata from remote")
|
||||||
|
assert.NotContains(t, out.String(),
|
||||||
|
"Removed snapshot metadata from remote storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneKeepsLocalRecordsWhenDestinationMissing checks that prune
|
||||||
|
// fails on a destination it cannot list and deletes no local snapshot
|
||||||
|
// record.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installs the global logger via log.Initialize
|
||||||
|
func TestPruneKeepsLocalRecordsWhenDestinationMissing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _ := backUpThenUnplug(ctx, t)
|
||||||
|
|
||||||
|
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "listing remote snapshots")
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "prune must delete no local snapshot record")
|
||||||
|
}
|
||||||
+9
-1
@@ -12,12 +12,20 @@ main() {
|
|||||||
# The Go files git lists, which leaves out the sources of that
|
# The Go files git lists, which leaves out the sources of that
|
||||||
# toolchain in the ignored .tool/go. git still lists a tracked file
|
# toolchain in the ignored .tool/go. git still lists a tracked file
|
||||||
# whose deletion is not staged yet, and gofmt fails on a missing file.
|
# whose deletion is not staged yet, and gofmt fails on a missing file.
|
||||||
|
listed="$(git ls-files --cached --others --exclude-standard '*.go')" || {
|
||||||
|
echo "fmt-check: git ls-files failed; run it in a git checkout" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
files=""
|
files=""
|
||||||
for f in $(git ls-files --cached --others --exclude-standard '*.go'); do
|
for f in $listed; do
|
||||||
if [ -e "$f" ]; then
|
if [ -e "$f" ]; then
|
||||||
files="$files $f"
|
files="$files $f"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
# gofmt with no file arguments reads standard input.
|
||||||
|
if [ -z "$files" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
# shellcheck disable=SC2086 # one argument per file name
|
# shellcheck disable=SC2086 # one argument per file name
|
||||||
unformatted="$(gofmt -l $files)"
|
unformatted="$(gofmt -l $files)"
|
||||||
if [ -n "$unformatted" ]; then
|
if [ -n "$unformatted" ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user