Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecfe12e78 | ||
|
|
1aea50d254 | ||
|
|
83981783b0 | ||
|
|
79fccfecce | ||
|
|
252db3259a | ||
|
|
a52a8a781c | ||
|
|
b6f8e860e1 | ||
|
|
64c31f890b | ||
|
|
8f34718a0f | ||
|
|
8654d9b7f5 | ||
|
|
63f8d4df59 | ||
|
|
b6a2c61f82 | ||
|
|
43c9b2c39b | ||
|
|
de9ef62073 | ||
|
|
5abacbc5c1 | ||
|
|
ff50a137dd | ||
|
|
e9b17cc5fd | ||
|
|
850e2de25a | ||
|
|
6a57750a7b | ||
|
|
2590f49583 | ||
|
|
5b8d84e38a | ||
|
|
7a950ea2aa | ||
|
|
16b03dfda5 | ||
|
|
2125a9d451 | ||
|
|
a0de8bfdb7 | ||
|
|
291c39174f | ||
|
|
65036bba1b | ||
|
|
54ad279dd6 | ||
|
|
d156a02c49 | ||
|
|
aeac897805 | ||
|
|
d8f4f62f41 |
+342
-333
@@ -1,392 +1,401 @@
|
|||||||
# Vaultik Architecture
|
# vaultik architecture
|
||||||
|
|
||||||
This document describes the internal architecture of Vaultik, focusing on the data model, type instantiation, and the relationships between core modules.
|
This document describes how the `vaultik` code is organised. It covers three
|
||||||
|
things:
|
||||||
|
|
||||||
## Overview
|
- the packages
|
||||||
|
- how they are wired together
|
||||||
|
- what happens during a backup, a restore, a verify and a prune
|
||||||
|
|
||||||
Vaultik is a backup system that uses content-defined chunking for deduplication and packs chunks into large, compressed, encrypted blobs for efficient cloud storage. The system is built around dependency injection using [uber-go/fx](https://github.com/uber-go/fx).
|
Two other documents cover the stored data:
|
||||||
|
|
||||||
## Data Flow
|
- [docs/DATAMODEL.md](docs/DATAMODEL.md) describes the local index.
|
||||||
|
- [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md) describes the layout of the
|
||||||
|
destination.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
||||||
|
- **Chunk**: a piece of a file's content. A chunk is named by the SHA-256 hash
|
||||||
|
of its content.
|
||||||
|
- **Blob**: one object at the destination. It holds many chunks, compressed and
|
||||||
|
then encrypted.
|
||||||
|
- **Local index**: the SQLite database on the backed-up machine.
|
||||||
|
- **Destination**: the place the backups are stored.
|
||||||
|
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
|
||||||
|
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
|
||||||
|
at the destination.
|
||||||
|
|
||||||
|
## How a backup moves data
|
||||||
|
|
||||||
```
|
```
|
||||||
Source Files
|
source files
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────┐
|
Scanner walks the paths and finds the files that changed
|
||||||
│ Scanner │ Walks directories, detects changed files
|
│
|
||||||
└────────┬────────┘
|
▼
|
||||||
│
|
Chunker cuts each changed file into chunks
|
||||||
▼
|
│
|
||||||
┌─────────────────┐
|
▼
|
||||||
│ Chunker │ Splits files into variable-size chunks (FastCDC)
|
Packer packs new chunks into a blob, compressed and encrypted
|
||||||
└────────┬────────┘
|
│
|
||||||
│
|
▼
|
||||||
▼
|
Storer uploads the blob to the destination
|
||||||
┌─────────────────┐
|
|
||||||
│ Packer │ Accumulates chunks, compresses (zstd), encrypts (age)
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ S3 Client │ Uploads blobs to remote storage
|
|
||||||
└─────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Model
|
## Packages
|
||||||
|
|
||||||
### Core Entities
|
- `cmd/vaultik`: the `main` function. It starts optional profiling and calls
|
||||||
|
`cli.Entry`.
|
||||||
|
- `internal/cli`: the commands, their flags and their help text. It builds the
|
||||||
|
application, takes the lock and handles interrupts.
|
||||||
|
- `internal/vaultik`: the code behind each command. `Vaultik` holds every
|
||||||
|
dependency a command uses.
|
||||||
|
- `internal/snapshot`: the `Scanner`, which backs up one path, and the
|
||||||
|
`SnapshotManager`, which creates snapshot records and uploads their metadata.
|
||||||
|
- `internal/chunker`: cuts a stream into chunks with the FastCDC algorithm.
|
||||||
|
- `internal/blob`: the `Packer`, which collects chunks into blobs.
|
||||||
|
- `internal/blobgen`: the writer that hashes, compresses and encrypts a blob,
|
||||||
|
and the reader that reverses it.
|
||||||
|
- `internal/database`: the local index. It holds the schema and the repository
|
||||||
|
types that read and write the tables.
|
||||||
|
[docs/DATAMODEL.md](docs/DATAMODEL.md#code) says which type serves which
|
||||||
|
table.
|
||||||
|
- `internal/storage`: the `Storer` interface and its three implementations, for
|
||||||
|
S3, a local directory and rclone.
|
||||||
|
- `internal/s3`: the S3 client that the S3 `Storer` uses.
|
||||||
|
- `internal/config`: loads and checks the config file.
|
||||||
|
- `internal/ui`: formats the status lines, warnings and errors a user sees.
|
||||||
|
- `internal/log`: the logger. It writes to stderr.
|
||||||
|
- `internal/pidlock`: the lock that keeps two data-changing commands from
|
||||||
|
running at once.
|
||||||
|
- `internal/globals`: the version, commit and commit date, set when the binary
|
||||||
|
is built.
|
||||||
|
- `internal/types`: distinct types for IDs, hashes and paths.
|
||||||
|
|
||||||
The database tracks five primary entities and their relationships:
|
## Wiring
|
||||||
|
|
||||||
```
|
The application is assembled with [uber-go/fx](https://github.com/uber-go/fx), a
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
dependency injection library. `cli.NewApp` lists the providers:
|
||||||
│ Snapshot │────▶│ File │────▶│ Chunk │
|
|
||||||
└──────────────┘ └──────────────┘ └──────────────┘
|
|
||||||
│ │
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────┐ ┌──────────────┐
|
|
||||||
│ Blob │◀─────────────────────────│ BlobChunk │
|
|
||||||
└──────────────┘ └──────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entity Descriptions
|
|
||||||
|
|
||||||
#### File (`database.File`)
|
|
||||||
Represents a file, directory, or symlink in the backup system. Stores metadata needed for restoration:
|
|
||||||
- Path, mtime
|
|
||||||
- Size, mode, ownership (uid, gid)
|
|
||||||
- Symlink target (if applicable)
|
|
||||||
|
|
||||||
It also stores `source_path`, the source directory the scan found it under, made absolute and with symlinks resolved. Restore does not read it.
|
|
||||||
|
|
||||||
#### Chunk (`database.Chunk`)
|
|
||||||
A content-addressed unit of data. Files are split into variable-size chunks using the FastCDC algorithm:
|
|
||||||
- `ChunkHash`: SHA256 hash of chunk content (primary key)
|
|
||||||
- `Size`: Chunk size in bytes
|
|
||||||
|
|
||||||
Chunk sizes vary between `avgChunkSize/4` and `avgChunkSize*4` (2.5MB-40MB for the 10MB default average).
|
|
||||||
|
|
||||||
#### FileChunk (`database.FileChunk`)
|
|
||||||
Maps files to their constituent chunks:
|
|
||||||
- `FileID`: Reference to the file
|
|
||||||
- `Idx`: Position of this chunk within the file (0-indexed)
|
|
||||||
- `ChunkHash`: Reference to the chunk
|
|
||||||
|
|
||||||
#### Blob (`database.Blob`)
|
|
||||||
The final storage unit uploaded to S3. Contains many compressed and encrypted chunks:
|
|
||||||
- `ID`: UUID assigned at creation
|
|
||||||
- `Hash`: `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (see [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs-directory-blobs))
|
|
||||||
- `UncompressedSize`: Total raw chunk data before compression
|
|
||||||
- `CompressedSize`: Size after zstd compression and age encryption
|
|
||||||
- `CreatedTS`, `FinishedTS`, `UploadedTS`: Lifecycle timestamps
|
|
||||||
|
|
||||||
Blob creation process:
|
|
||||||
1. Chunks are accumulated (up to MaxBlobSize, typically 10GB)
|
|
||||||
2. As each chunk is added, its uncompressed bytes are fed to a running SHA-256
|
|
||||||
3. Concurrently, the same bytes are compressed with zstd, then encrypted with age (recipients configured in config), and written to a temporary file
|
|
||||||
4. On finalize, the blob's name is the double SHA-256 of the uncompressed contents — `hex(SHA256(SHA256(...)))` — not a hash of the compressed, encrypted bytes
|
|
||||||
5. The finished file is uploaded to `blobs/{hash[0:2]}/{hash[2:4]}/{hash}` and then deleted
|
|
||||||
|
|
||||||
A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp`.
|
|
||||||
|
|
||||||
#### BlobChunk (`database.BlobChunk`)
|
|
||||||
Maps chunks to their position within blobs:
|
|
||||||
- `BlobID`: Reference to the blob
|
|
||||||
- `ChunkHash`: Reference to the chunk
|
|
||||||
- `Offset`: Byte offset within the uncompressed blob
|
|
||||||
- `Length`: Chunk size
|
|
||||||
|
|
||||||
#### Snapshot (`database.Snapshot`)
|
|
||||||
Represents a point-in-time backup:
|
|
||||||
- `ID`: Format is `{hostname}_{snapshot-name}_{RFC3339}` (e.g. `server1_home_2025-06-01T12:00:00Z`)
|
|
||||||
- Tracks file count, chunk count, blob count, sizes, compression ratio
|
|
||||||
- `CompletedAt`: Null until snapshot finishes successfully
|
|
||||||
|
|
||||||
#### SnapshotFile / SnapshotBlob
|
|
||||||
Join tables linking snapshots to their files and blobs.
|
|
||||||
|
|
||||||
### Relationship Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
Snapshot 1──────────▶ N SnapshotFile N ◀────────── 1 File
|
|
||||||
Snapshot 1──────────▶ N SnapshotBlob N ◀────────── 1 Blob
|
|
||||||
File 1──────────▶ N FileChunk N ◀────────── 1 Chunk
|
|
||||||
Blob 1──────────▶ N BlobChunk N ◀────────── 1 Chunk
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Instantiation
|
|
||||||
|
|
||||||
### Application Startup
|
|
||||||
|
|
||||||
The CLI uses fx for dependency injection. Here's the instantiation order:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// cli/app.go: NewApp()
|
fx.Supply(config.Path(opts.ConfigPath)),
|
||||||
fx.New(
|
fx.Supply(opts.LogOptions),
|
||||||
fx.Supply(config.Path(opts.ConfigPath)), // 1. Config path
|
fx.Provide(globals.New),
|
||||||
fx.Supply(opts.LogOptions), // 2. Log options
|
fx.Provide(log.New),
|
||||||
fx.Provide(globals.New), // 3. Globals
|
config.Module,
|
||||||
fx.Provide(log.New), // 4. Logger config
|
database.Module,
|
||||||
config.Module, // 5. Config
|
log.Module,
|
||||||
database.Module, // 6. Database + Repositories
|
storage.Module,
|
||||||
log.Module, // 7. Logger initialization
|
snapshot.Module,
|
||||||
storage.Module, // 8. Storage backend (S3/file/rclone)
|
fx.Provide(vaultik.New),
|
||||||
snapshot.Module, // 9. SnapshotManager + ScannerFactory
|
fx.Invoke(setupGlobals),
|
||||||
fx.Provide(vaultik.New), // 10. Vaultik orchestrator
|
fx.NopLogger,
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Type Instantiation Points
|
Each module provides these:
|
||||||
|
|
||||||
#### 1. Config (`config.Config`)
|
- `config.Module` provides `*config.Config`, from `config.Load`.
|
||||||
- **Created by**: `config.Module` via `config.LoadConfig()`
|
- `database.Module` provides `*database.DB` and `*database.Repositories`. It
|
||||||
- **When**: Application startup (fx DI)
|
creates the index file with mode `0600` when it does not exist.
|
||||||
- **Contains**: All configuration from YAML file (S3 credentials, encryption keys, paths, etc.)
|
- `storage.Module` provides the `storage.Storer` that the config selects.
|
||||||
|
- `snapshot.Module` provides the `snapshot.ScannerFactory` and the
|
||||||
|
`*snapshot.SnapshotManager`.
|
||||||
|
- `vaultik.New` provides `*vaultik.Vaultik`.
|
||||||
|
|
||||||
#### 2. Database (`database.DB`)
|
A command runs through `cli.RunOperation`. It works like this:
|
||||||
- **Created by**: `database.Module` via `database.New()`
|
|
||||||
- **When**: Application startup (fx DI)
|
|
||||||
- **Contains**: SQLite connection, path reference
|
|
||||||
|
|
||||||
#### 3. Repositories (`database.Repositories`)
|
- `RunWithApp` takes the lock when the command changes data, and then starts the
|
||||||
- **Created by**: `database.Module` via `database.NewRepositories()`
|
application.
|
||||||
- **When**: Application startup (fx DI)
|
- The command's function runs in its own goroutine.
|
||||||
- **Contains**: All repository interfaces (Files, Chunks, Blobs, Snapshots, etc.)
|
- When the function returns, it asks the application to shut down.
|
||||||
|
- On `SIGINT` or `SIGTERM`, the stop hook cancels the `Vaultik` context. It then
|
||||||
|
waits for the function to return, so that its temporary files are removed.
|
||||||
|
- The wait ends after 30 seconds at most.
|
||||||
|
|
||||||
#### 4. Vaultik (`vaultik.Vaultik`)
|
The `config` commands, `database delete`, `completion` and `version` do not
|
||||||
- **Created by**: `vaultik.New(VaultikParams)`
|
build the application.
|
||||||
- **When**: Application startup (fx DI)
|
|
||||||
- **Contains**: All dependencies for backup operations
|
|
||||||
|
|
||||||
```go
|
## Backup
|
||||||
type Vaultik struct {
|
|
||||||
Globals *globals.Globals
|
|
||||||
Config *config.Config
|
|
||||||
DB *database.DB
|
|
||||||
Repositories *database.Repositories
|
|
||||||
Storage storage.Storer
|
|
||||||
ScannerFactory snapshot.ScannerFactory
|
|
||||||
SnapshotManager *snapshot.SnapshotManager
|
|
||||||
Shutdowner fx.Shutdowner
|
|
||||||
Fs afero.Fs
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. SnapshotManager (`snapshot.SnapshotManager`)
|
`Vaultik.CreateSnapshot` runs a backup. It does these steps in order:
|
||||||
- **Created by**: `snapshot.Module` via `snapshot.NewSnapshotManager()`
|
|
||||||
- **When**: Application startup (fx DI)
|
|
||||||
- **Responsibility**: Creates/completes snapshots, exports metadata to S3
|
|
||||||
|
|
||||||
#### 6. Scanner (`snapshot.Scanner`)
|
- `EnsureStorageBinding` checks that the local index belongs to the configured
|
||||||
- **Created by**: `ScannerFactory(ScannerParams)`
|
destination.
|
||||||
- **When**: Each `CreateSnapshot()` call
|
- `markDestination` checks the marker file and then writes it.
|
||||||
- **Contains**: Chunker, Packer, progress reporter
|
- `PruneDatabase` removes unfinished snapshots and unused records from the
|
||||||
|
local index.
|
||||||
|
- `createNamedSnapshot` runs once for each snapshot name.
|
||||||
|
- With `--prune`, `runPostBackupPrune` removes old snapshots and unused data.
|
||||||
|
|
||||||
```go
|
`CreateSnapshot` calls `skipPostBackupPrune` instead of `runPostBackupPrune` in
|
||||||
// vaultik/snapshot.go: CreateSnapshot()
|
these cases:
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
|
||||||
EnableProgress: !opts.Cron,
|
|
||||||
Fs: v.Fs,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7. Chunker (`chunker.Chunker`)
|
- `--skip-errors` skipped a file or directory that could not be read.
|
||||||
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
- A configured path held nothing.
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
- An earlier snapshot of the same name found a mount point that this backup did
|
||||||
- **Configuration**:
|
not find.
|
||||||
- `avgChunkSize`: From config (default 10MB)
|
|
||||||
- `minChunkSize`: avgChunkSize / 4
|
|
||||||
- `maxChunkSize`: avgChunkSize * 4
|
|
||||||
|
|
||||||
#### 8. Packer (`blob.Packer`)
|
`PruneDatabase` must succeed before anything is scanned. An unfinished snapshot
|
||||||
- **Created by**: `blob.NewPacker(PackerConfig)`
|
left in the index would make the backup skip files that were never fully
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
stored.
|
||||||
- **Configuration**:
|
|
||||||
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
|
||||||
- `CompressionLevel`: zstd level (1-19)
|
|
||||||
- `Recipients`: age public keys for encryption
|
|
||||||
|
|
||||||
```go
|
### One snapshot
|
||||||
// snapshot/scanner.go: NewScanner()
|
|
||||||
packerCfg := blob.PackerConfig{
|
|
||||||
MaxBlobSize: cfg.MaxBlobSize,
|
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
|
||||||
Recipients: cfg.AgeRecipients,
|
|
||||||
Repositories: cfg.Repositories,
|
|
||||||
Fs: cfg.FS,
|
|
||||||
}
|
|
||||||
packer, err := blob.NewPacker(packerCfg)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Module Responsibilities
|
`createNamedSnapshot` does these steps:
|
||||||
|
|
||||||
### `internal/cli`
|
- `resolveSnapshotPaths` makes each configured path absolute and resolves
|
||||||
Entry point for fx application. Combines all modules and handles signal interrupts.
|
symlinks.
|
||||||
|
- `SnapshotManager.CreateSnapshotWithName` creates the snapshot record. The
|
||||||
|
record is not yet marked complete.
|
||||||
|
- `Scanner.Scan` backs up each path in turn.
|
||||||
|
- `finalizeSnapshotMetadata` uploads the snapshot's metadata and marks the
|
||||||
|
snapshot complete.
|
||||||
|
|
||||||
Key functions:
|
A new `Scanner` is created for each snapshot, from `ScannerFactory`. It owns a
|
||||||
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
`Chunker` and a `Packer`.
|
||||||
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
|
||||||
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
|
||||||
|
|
||||||
### `internal/vaultik`
|
### Scanning one path
|
||||||
Main orchestrator containing all dependencies and command implementations.
|
|
||||||
|
|
||||||
Key methods:
|
`Scanner.Scan` works in these steps:
|
||||||
- `New(VaultikParams)` → Constructor (fx DI)
|
|
||||||
- `CreateSnapshot(opts)` → Main backup operation
|
|
||||||
- `ListSnapshots(jsonOutput)` → List available snapshots
|
|
||||||
- `VerifySnapshot(id, deep)` → Verify snapshot integrity
|
|
||||||
- `PurgeSnapshots(...)` → Remove old snapshots
|
|
||||||
|
|
||||||
### `internal/chunker`
|
- `repairInterruptedBlobs` deletes the records of blobs whose upload never
|
||||||
Content-defined chunking using FastCDC algorithm.
|
finished. Their data is then stored again.
|
||||||
|
- `loadDatabaseState` loads the known files and chunks into memory.
|
||||||
|
- `scanPhase` walks the path. It sorts each entry into changed or unchanged.
|
||||||
|
- `detectDeletedFiles` counts the files that the previous snapshot of this name
|
||||||
|
had and that are now gone.
|
||||||
|
- `batchAddFilesToSnapshot` records the unchanged files in the new snapshot.
|
||||||
|
- `recordDirectories` records every directory, and the mount points found.
|
||||||
|
- `processPhase` reads each changed file and stores it.
|
||||||
|
|
||||||
Key types:
|
The walk handles each kind of entry differently:
|
||||||
- `Chunk` → Hash, Data, Offset, Size
|
|
||||||
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
|
||||||
|
|
||||||
Key methods:
|
- A regular file or a symlink is compared with its record in the index.
|
||||||
- `NewChunker(avgChunkSize)` → Constructor
|
- A directory is recorded with its metadata. It has no content to store.
|
||||||
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
|
- A device node, a named pipe or a socket is skipped.
|
||||||
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
|
- An entry that matches an exclude pattern is skipped. For a directory, so is
|
||||||
|
everything in it.
|
||||||
|
|
||||||
### `internal/blob`
|
### Deduplication
|
||||||
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
|
||||||
|
|
||||||
Key types:
|
Data is stored once at two levels.
|
||||||
- `Packer` → Thread-safe blob accumulator
|
|
||||||
- `ChunkRef` → Hash + Data for adding to packer
|
|
||||||
- `FinishedBlob` → Completed blob ready for upload
|
|
||||||
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
|
||||||
|
|
||||||
Key methods:
|
- **Files.** A file is not read again when its record in the index still matches
|
||||||
- `NewPacker(PackerConfig)` → Constructor
|
it.
|
||||||
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
- **Chunks.** A chunk is named by the SHA-256 hash of its content. A chunk whose
|
||||||
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
hash is already in the index is not stored again.
|
||||||
- `Flush()` → Finalize any in-progress blob
|
|
||||||
- `SetBlobHandler(func)` → Set callback for upload
|
|
||||||
|
|
||||||
### `internal/snapshot`
|
A file's record matches when all of these are unchanged:
|
||||||
|
|
||||||
#### Scanner
|
- the size
|
||||||
Orchestrates the backup process for a directory.
|
- the modification time
|
||||||
|
- the mode
|
||||||
|
- the owner and group
|
||||||
|
- the link target
|
||||||
|
|
||||||
Key methods:
|
One case overrides the file check. A file is read again when it lists a chunk
|
||||||
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
that no uploaded blob holds. This happens when the only snapshot that used the
|
||||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
blob holding that chunk is removed. `loadFilesToRechunk` finds these files.
|
||||||
|
|
||||||
Scan phases:
|
### Chunks
|
||||||
1. **Phase 0**: Detect deleted files from previous snapshots
|
|
||||||
2. **Phase 1**: Walk directory, identify files needing processing
|
|
||||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
|
||||||
|
|
||||||
#### SnapshotManager
|
`chunker.Chunker` cuts a file with the FastCDC algorithm. The cut points depend
|
||||||
Manages snapshot lifecycle and metadata export.
|
on the content, so an insertion early in a file moves only nearby chunks.
|
||||||
|
|
||||||
Key methods:
|
`chunk_size` sets the average chunk size. No chunk is larger than four times
|
||||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
this size. Only a file's last chunk can be smaller than a quarter of it. With
|
||||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
the default of 10 MB, those limits are 40 MB and 2.5 MB.
|
||||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
|
||||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
|
||||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
|
||||||
|
|
||||||
### `internal/database`
|
`ChunkReaderStreaming` passes each chunk to a callback as it is found. The whole
|
||||||
SQLite database for local index. Single-writer mode for thread safety.
|
file is never held in memory.
|
||||||
|
|
||||||
Key types:
|
### Blobs
|
||||||
- `DB` → Database connection wrapper
|
|
||||||
- `Repositories` → Collection of all repository interfaces
|
|
||||||
|
|
||||||
Repository interfaces:
|
`blob.Packer` collects new chunks into a blob. For each blob it does this:
|
||||||
- `FilesRepository` → CRUD for File records
|
|
||||||
- `ChunksRepository` → CRUD for Chunk records
|
|
||||||
- `BlobsRepository` → CRUD for Blob records
|
|
||||||
- `SnapshotsRepository` → CRUD for Snapshot records
|
|
||||||
- Plus join table repositories (FileChunks, BlobChunks, etc.)
|
|
||||||
|
|
||||||
## Snapshot Creation Flow
|
- It gives the blob a UUID and creates a temporary file.
|
||||||
|
- It adds each chunk's bytes to a running SHA-256 hash.
|
||||||
|
- In the same pass it compresses the bytes with zstd and encrypts them with
|
||||||
|
age. The result goes to the temporary file.
|
||||||
|
- It records each chunk's offset and length within the blob.
|
||||||
|
- It closes the blob when the next chunk would pass `blob_size_limit`, and at
|
||||||
|
the end of each path.
|
||||||
|
|
||||||
```
|
The blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. It is computed
|
||||||
CreateSnapshot(opts)
|
from the chunk data before compression and encryption.
|
||||||
│
|
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs) says what follows from
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
that.
|
||||||
│
|
|
||||||
├─► SnapshotManager.CreateSnapshot() // Create DB record
|
|
||||||
│
|
|
||||||
├─► For each source directory:
|
|
||||||
│ │
|
|
||||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 0: detectDeletedFiles()
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 1: scanPhase()
|
|
||||||
│ │ │ Walk directory
|
|
||||||
│ │ │ Check file metadata changes
|
|
||||||
│ │ │ Build list of files to process
|
|
||||||
│ │ │
|
|
||||||
│ │ └─► Phase 2: processPhase()
|
|
||||||
│ │ For each file:
|
|
||||||
│ │ chunker.ChunkReaderStreaming()
|
|
||||||
│ │ For each chunk:
|
|
||||||
│ │ packer.AddChunk()
|
|
||||||
│ │ If blob full → FinalizeBlob()
|
|
||||||
│ │ → handleBlobReady()
|
|
||||||
│ │ → s3Client.PutObjectWithProgress()
|
|
||||||
│ │ packer.Flush() // Final blob
|
|
||||||
│ │
|
|
||||||
│ └─► Accumulate statistics
|
|
||||||
│
|
|
||||||
├─► SnapshotManager.PopulateSnapshotBlobs() // record referenced blobs
|
|
||||||
│
|
|
||||||
├─► SnapshotManager.UpdateSnapshotStatsExtended()
|
|
||||||
│
|
|
||||||
├─► SnapshotManager.ExportSnapshotMetadata()
|
|
||||||
│ │
|
|
||||||
│ ├─► Copy database to temp file
|
|
||||||
│ ├─► Clean to only current snapshot data (VACUUM)
|
|
||||||
│ ├─► Compress binary SQLite with zstd
|
|
||||||
│ ├─► Encrypt with age
|
|
||||||
│ ├─► Upload db.zst.age to storage
|
|
||||||
│ └─► Upload manifest.json.zst to storage
|
|
||||||
│
|
|
||||||
└─► SnapshotManager.MarkSnapshotComplete() // only after the export succeeds
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deduplication Strategy
|
When a blob is closed, `Scanner.handleBlobReady` stores it:
|
||||||
|
|
||||||
1. **File-level**: Files unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
|
- It looks the blob up at the destination. A blob that is already there is not
|
||||||
|
uploaded again.
|
||||||
|
- Otherwise it uploads the temporary file to
|
||||||
|
`blobs/<hash[0:2]>/<hash[2:4]>/<hash>`.
|
||||||
|
- It records the upload in the index and links the blob to the snapshot.
|
||||||
|
- It deletes the temporary file.
|
||||||
|
|
||||||
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
|
A file's record is written to the index only after every blob that holds its
|
||||||
|
chunks is uploaded.
|
||||||
|
|
||||||
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
|
### Snapshot metadata
|
||||||
|
|
||||||
## Storage Layout in S3
|
`finalizeSnapshotMetadata` does these steps in order:
|
||||||
|
|
||||||
```
|
- `PopulateSnapshotBlobs` records every blob the snapshot uses.
|
||||||
bucket/
|
- `UpdateSnapshotStatsExtended` stores the snapshot's totals.
|
||||||
├── blobs/
|
- `ExportSnapshotMetadata` uploads the metadata.
|
||||||
│ └── {hash[0:2]}/
|
- `MarkSnapshotComplete` marks the snapshot complete.
|
||||||
│ └── {hash[2:4]}/
|
|
||||||
│ └── {full-hash} # Compressed+encrypted blob
|
|
||||||
│
|
|
||||||
└── metadata/
|
|
||||||
└── {remote-key}/
|
|
||||||
├── db.zst.age # Encrypted binary SQLite database
|
|
||||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
|
||||||
```
|
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
Completion is recorded last on purpose. A backup that stops during the upload
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
leaves the snapshot unfinished, and the next backup removes it and stores its
|
||||||
written to the store as a directory name. See
|
data again.
|
||||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
|
||||||
derivation and a worked example.
|
|
||||||
|
|
||||||
## Thread Safety
|
`ExportSnapshotMetadata` works on a copy of the index, never on the index
|
||||||
|
itself:
|
||||||
|
|
||||||
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
- It copies the index into a temporary directory with `VACUUM INTO`.
|
||||||
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
|
- It deletes the rows of every other snapshot from the copy. It also deletes the
|
||||||
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
|
files and blobs that this snapshot does not use. It then deletes each chunk
|
||||||
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
that no remaining file and no remaining blob uses.
|
||||||
|
- It keeps the `local_meta` and `schema_migrations` tables as they are. The
|
||||||
|
uploaded database therefore names the destination, and lists the migrations
|
||||||
|
that have run.
|
||||||
|
- It runs `VACUUM`, so that no deleted row is left in the file.
|
||||||
|
- It compresses the copy with zstd and encrypts it with age.
|
||||||
|
- It builds the manifest from the copy.
|
||||||
|
- It uploads `metadata/<remote-key>/db.zst.age`, and then
|
||||||
|
`metadata/<remote-key>/manifest.json.zst`.
|
||||||
|
|
||||||
|
The manifest is uploaded last. A directory under `metadata/` with no manifest
|
||||||
|
therefore marks a backup that did not finish.
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
`Vaultik.Restore` does these steps:
|
||||||
|
|
||||||
|
- `restoreIdentities` parses the age private key. A missing or malformed key
|
||||||
|
stops the restore before anything is downloaded.
|
||||||
|
- `downloadSnapshotDB` downloads and decrypts the snapshot's database into a
|
||||||
|
temporary directory.
|
||||||
|
- `getFilesToRestore` selects the files, from the paths given.
|
||||||
|
- `restoreAllFiles` downloads blobs and writes the files.
|
||||||
|
- With `--verify`, `verifyRestoredFiles` reads each restored file and checks its
|
||||||
|
chunk hashes.
|
||||||
|
|
||||||
|
The restore does not read the local index or the manifest. It finds each file's
|
||||||
|
chunks and blobs through the downloaded database.
|
||||||
|
|
||||||
|
### Checks on the downloaded database
|
||||||
|
|
||||||
|
Decryption shows that a database is readable. It does not show that the
|
||||||
|
database is honest. The restore therefore checks it:
|
||||||
|
|
||||||
|
- `database.OpenReadOnly` opens the file read-only. It refuses a file that has a
|
||||||
|
trigger, a view or a virtual table, or that lacks an expected table.
|
||||||
|
- `verifySnapshotDBIdentity` checks that the snapshot in the database hashes to
|
||||||
|
the remote key that was asked for.
|
||||||
|
- `containedRestorePath` refuses a stored path that would leave the target
|
||||||
|
directory. It also refuses to write through a symlink below the target.
|
||||||
|
- A blob is not decompressed beyond the size that the database gives for it.
|
||||||
|
- Each downloaded blob is hashed. The hash must match the blob's name.
|
||||||
|
|
||||||
|
### Order of work
|
||||||
|
|
||||||
|
`restorePlan` orders the files so that few blobs are on disk at once:
|
||||||
|
|
||||||
|
- Downloaded blobs are kept decrypted in a cache directory.
|
||||||
|
- A file is restored once every blob it needs is in the cache.
|
||||||
|
- When no file is ready, the restore downloads the blobs for the file that
|
||||||
|
needs the fewest.
|
||||||
|
- `restoreSweeper` deletes a cached blob once no remaining file needs it.
|
||||||
|
|
||||||
|
A regular file is created with mode `0600` and gets its stored mode after its
|
||||||
|
content is written. A directory gets its stored mode, owner and modification
|
||||||
|
time after the whole restore.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
`Vaultik.VerifySnapshotWithOptions` checks a snapshot without the private key:
|
||||||
|
|
||||||
|
- It downloads the manifest.
|
||||||
|
- It checks that the snapshot's encrypted database is present.
|
||||||
|
- It checks that each listed blob is present with the listed size.
|
||||||
|
|
||||||
|
`Vaultik.RunDeepVerify` needs the private key. It does these steps:
|
||||||
|
|
||||||
|
- It downloads the manifest and the database. It checks the database as a
|
||||||
|
restore does.
|
||||||
|
- `verifyManifestAgainstDatabase` checks that both list the same blobs with the
|
||||||
|
same sizes.
|
||||||
|
- `verifyBlobExistenceFromDB` checks that every blob is present with the
|
||||||
|
recorded size.
|
||||||
|
- `verifyBlob` downloads each blob, checks the hash of each chunk in it, and
|
||||||
|
checks the hash that names the blob.
|
||||||
|
|
||||||
|
## Prune
|
||||||
|
|
||||||
|
`Vaultik.Prune` does three steps:
|
||||||
|
|
||||||
|
- `CleanupLocalSnapshots` drops from the local index each snapshot whose
|
||||||
|
manifest is gone from the destination.
|
||||||
|
- `PruneDatabase` removes unfinished snapshots and unused records from the
|
||||||
|
local index.
|
||||||
|
- `PruneBlobs` deletes unused data at the destination.
|
||||||
|
|
||||||
|
`PruneBlobs` reads every manifest at the destination. It then deletes two kinds
|
||||||
|
of object, once they are older than `prune_grace_period`:
|
||||||
|
|
||||||
|
- a blob that no manifest lists
|
||||||
|
- a directory under `metadata/` that has no manifest, and whose snapshot is not
|
||||||
|
in the local index
|
||||||
|
|
||||||
|
It deletes nothing when any manifest cannot be read.
|
||||||
|
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#pruning) explains why this is
|
||||||
|
safe, and where it is not.
|
||||||
|
|
||||||
|
## The destination
|
||||||
|
|
||||||
|
`storage.Storer` is the only way the code reaches the destination. It has these
|
||||||
|
methods:
|
||||||
|
|
||||||
|
- `Put` and `PutWithProgress` store an object.
|
||||||
|
- `Get` reads an object.
|
||||||
|
- `Stat` reports an object's size.
|
||||||
|
- `Delete` removes an object.
|
||||||
|
- `List` and `ListStream` list the objects under a prefix.
|
||||||
|
- `DeletePartialUploads` removes what an interrupted upload left behind.
|
||||||
|
- `Info` describes the destination.
|
||||||
|
|
||||||
|
`storage.NewStorer` picks the implementation from `storage_url`:
|
||||||
|
|
||||||
|
- `s3://` gives the S3 implementation.
|
||||||
|
- `file://` gives the local directory implementation.
|
||||||
|
- `rclone://` gives the rclone implementation.
|
||||||
|
|
||||||
|
With no `storage_url`, it builds the S3 implementation from the `s3` settings.
|
||||||
|
|
||||||
|
## The marker file and the destination binding
|
||||||
|
|
||||||
|
Two checks keep the local index and the destination in step.
|
||||||
|
|
||||||
|
- `checkDestinationMarker` looks for `vaultik-destination.txt`. It fails when
|
||||||
|
the file is reported missing while the local index holds snapshots.
|
||||||
|
- `EnsureStorageBinding` records the configured destination in the local index
|
||||||
|
on first use. Later it fails when the config names a different destination.
|
||||||
|
|
||||||
|
Every command that changes data calls `EnsureStorageBinding` first.
|
||||||
|
|
||||||
|
## Concurrency
|
||||||
|
|
||||||
|
- The local index is opened with one connection, so its queries run one at a
|
||||||
|
time.
|
||||||
|
- The index runs in SQLite's WAL mode. A second process can therefore read it
|
||||||
|
while a backup writes to it.
|
||||||
|
- `Packer` guards its state with a mutex.
|
||||||
|
- `Scanner` holds `packerMu` while a chunk is added to the packer and while a
|
||||||
|
blob is closed.
|
||||||
|
- `Repositories.WithTx` runs a function in one transaction. It commits when the
|
||||||
|
function succeeds and rolls back when it fails.
|
||||||
|
|||||||
@@ -15,13 +15,246 @@ pre-1.0
|
|||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
The 1.0 work is complete on `next`: the scope settled on
|
The 1.0 work is complete on `next`: the scope settled on
|
||||||
[issue #125](https://git.eeqj.de/sneak/vaultik/issues/125) was the work
|
[issue #125](https://git.eeqj.de/sneak/vaultik/issues/125) has landed,
|
||||||
already planned for 1.0, and all of it has landed. The mechanism to cut
|
and so has every fix from the defect hunt before the release candidate
|
||||||
the tag exists and is exercised; what is left is merging `next` to
|
([issue #288](https://git.eeqj.de/sneak/vaultik/issues/288)). What is
|
||||||
`main` and tagging, both the owner's.
|
left is merging `next` to `main` and tagging the release, both the
|
||||||
|
owner's; a `v*` tag runs the release build in
|
||||||
|
`.gitea/workflows/release.yml`.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-15: Stopped `snapshot create --prune` pruning after a backup
|
||||||
|
in which a volume mounted below a configured path in an earlier
|
||||||
|
snapshot of the same name was not mounted
|
||||||
|
([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a
|
||||||
|
prune could delete every snapshot still holding the volume's files.
|
||||||
|
Each snapshot now records in the local index the directories it found
|
||||||
|
on a different filesystem from their parent and found an entry in that
|
||||||
|
the backup does not exclude.
|
||||||
|
|
||||||
|
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
|
||||||
|
that found nothing in a configured path
|
||||||
|
([issue #388](https://git.eeqj.de/sneak/vaultik/issues/388)), as in
|
||||||
|
the mount point of a volume that is not mounted. Such a prune could
|
||||||
|
delete every snapshot still holding that path's files. A configured
|
||||||
|
path that is a symlink whose target is gone, as one into such a
|
||||||
|
volume, now fails the backup, with or without `--prune`.
|
||||||
|
|
||||||
|
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
|
||||||
|
that skipped files it could not read
|
||||||
|
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Such a
|
||||||
|
prune could delete the only snapshot still holding those files.
|
||||||
|
|
||||||
|
- 2026-10-14: Made `snapshot create --skip-errors` exit non-zero,
|
||||||
|
instead of 0, when it skips anything it cannot read
|
||||||
|
([issue #310](https://git.eeqj.de/sneak/vaultik/issues/310)).
|
||||||
|
|
||||||
|
- 2026-10-14: Kept the local index when `remote nuke` cannot list or
|
||||||
|
find the destination store
|
||||||
|
([issue #292](https://git.eeqj.de/sneak/vaultik/issues/292)). Nuke
|
||||||
|
had cleared the local snapshot records anyway; it now fails before
|
||||||
|
removing anything.
|
||||||
|
|
||||||
|
- 2026-10-14: Stopped counting directories and unchanged symlinks as
|
||||||
|
files to back up
|
||||||
|
([issue #305](https://git.eeqj.de/sneak/vaultik/issues/305)).
|
||||||
|
|
||||||
|
- 2026-10-14: Refused a backup destination that an unplugged volume
|
||||||
|
left empty, as the owner ruled
|
||||||
|
([issue #239](https://git.eeqj.de/sneak/vaultik/issues/239)). Every
|
||||||
|
backup now writes `vaultik-destination.txt` at the destination root,
|
||||||
|
and while the local index holds snapshots stored there, a destination
|
||||||
|
without it is treated as not present.
|
||||||
|
|
||||||
|
- 2026-10-14: Warned about restored ownership only when an entry's
|
||||||
|
owner or group differs from the recorded one
|
||||||
|
([issue #316](https://git.eeqj.de/sneak/vaultik/issues/316)); a
|
||||||
|
restore by a non-root user had warned even when none did.
|
||||||
|
|
||||||
|
- 2026-10-14: Made `snapshot list` report a changed destination instead
|
||||||
|
of advising `prune`, which refuses in that case
|
||||||
|
([issue #317](https://git.eeqj.de/sneak/vaultik/issues/317)).
|
||||||
|
|
||||||
|
- 2026-10-14: Counted a file as deleted by a backup only if the
|
||||||
|
previous snapshot of the same name listed it
|
||||||
|
([issue #304](https://git.eeqj.de/sneak/vaultik/issues/304)); files
|
||||||
|
that snapshot did not list had been counted too.
|
||||||
|
|
||||||
|
- 2026-10-14: Named each prefix once in restore, verify and purge
|
||||||
|
errors, some of which repeated it
|
||||||
|
([issue #327](https://git.eeqj.de/sneak/vaultik/issues/327)). A deep
|
||||||
|
verify log line that named S3 whatever the destination now names
|
||||||
|
storage.
|
||||||
|
|
||||||
|
- 2026-10-14: Refused credentials in a storage URL's endpoint or rclone
|
||||||
|
remote name, which were accepted and could then be printed
|
||||||
|
([issue #322](https://git.eeqj.de/sneak/vaultik/issues/322)).
|
||||||
|
|
||||||
|
- 2026-10-14: Ran `$EDITOR` through the shell in `config edit`, so a
|
||||||
|
value with arguments, such as `code --wait`, no longer fails
|
||||||
|
([issue #315](https://git.eeqj.de/sneak/vaultik/issues/315)).
|
||||||
|
|
||||||
|
- 2026-10-14: Made `snapshot remove` fail, instead of exiting 0, when
|
||||||
|
it cannot delete the metadata of a snapshot that is only on the
|
||||||
|
destination store
|
||||||
|
([issue #340](https://git.eeqj.de/sneak/vaultik/issues/340)). The
|
||||||
|
README and the help now say when it prints the `vaultik prune`
|
||||||
|
follow-up.
|
||||||
|
|
||||||
|
- 2026-10-14: Labelled the totals in the local index cleanup before
|
||||||
|
each backup as totals
|
||||||
|
([issue #306](https://git.eeqj.de/sneak/vaultik/issues/306)). Each
|
||||||
|
was shown as `(N remain)`, which read as rows left to clean up.
|
||||||
|
|
||||||
|
- 2026-10-13: Freed a cached blob once no file still to restore needs
|
||||||
|
it ([issue #324](https://git.eeqj.de/sneak/vaultik/issues/324)); one
|
||||||
|
also used by a file outside the selected paths had stayed in the
|
||||||
|
temporary cache until the restore ended.
|
||||||
|
|
||||||
|
- 2026-10-13: Kept the whole startup error when it contains `): `
|
||||||
|
([issue #320](https://git.eeqj.de/sneak/vaultik/issues/320)). Such
|
||||||
|
an error was cut at its last `): `, so the `blob_size_limit` one lost
|
||||||
|
the setting's name.
|
||||||
|
|
||||||
|
- 2026-10-13: Silenced the status lines of `prune` and snapshot removal
|
||||||
|
under `--cron` and `--quiet`
|
||||||
|
([issue #294](https://git.eeqj.de/sneak/vaultik/issues/294)).
|
||||||
|
|
||||||
|
- 2026-10-13: Read the rclone config file that `RCLONE_CONFIG` names
|
||||||
|
([issue #321](https://git.eeqj.de/sneak/vaultik/issues/321)); a
|
||||||
|
remote defined only there had been reported as not found.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `/**/` in an exclude pattern match zero directories,
|
||||||
|
as in gitignore
|
||||||
|
([issue #338](https://git.eeqj.de/sneak/vaultik/issues/338));
|
||||||
|
`a/**/*.log` had missed `a/x.log`.
|
||||||
|
|
||||||
|
- 2026-10-13: Made the `chunk_size` and `blob_size_limit` defaults and
|
||||||
|
the `chunk_size` minimum 1000-based, like the size parser and the
|
||||||
|
docs ([issue #313](https://git.eeqj.de/sneak/vaultik/issues/313)).
|
||||||
|
|
||||||
|
- 2026-10-13: Kept a snapshot's remote metadata when `snapshot purge`
|
||||||
|
cannot remove it from the local index
|
||||||
|
([issue #337](https://git.eeqj.de/sneak/vaultik/issues/337)); the
|
||||||
|
purge had deleted the metadata anyway and exited 0.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `snapshot restore` fail for a requested path that
|
||||||
|
matches nothing, which it had ignored
|
||||||
|
([issue #309](https://git.eeqj.de/sneak/vaultik/issues/309)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made `remote nuke` delete every object under `metadata/`
|
||||||
|
and `blobs/`
|
||||||
|
([issue #291](https://git.eeqj.de/sneak/vaultik/issues/291)); it had
|
||||||
|
left some, such as a stray `.DS_Store`, while reporting the store
|
||||||
|
empty.
|
||||||
|
|
||||||
|
- 2026-10-13: Made `config set` create a list for an index under a
|
||||||
|
missing key
|
||||||
|
([issue #308](https://git.eeqj.de/sneak/vaultik/issues/308)); it had
|
||||||
|
written a map, after which no command could load the config.
|
||||||
|
|
||||||
|
- 2026-10-13: Listed a `file://` destination through a symlinked
|
||||||
|
`metadata/` or `blobs/`, which had listed as empty
|
||||||
|
([issue #296](https://git.eeqj.de/sneak/vaultik/issues/296)). A link
|
||||||
|
to a missing target now fails the listing
|
||||||
|
([issue #332](https://git.eeqj.de/sneak/vaultik/issues/332)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made `snapshot verify --deep --json` report its blob
|
||||||
|
counts ([issue #314](https://git.eeqj.de/sneak/vaultik/issues/314));
|
||||||
|
a failed run had reported 0 missing and 0 verified.
|
||||||
|
|
||||||
|
- 2026-10-13: Limited and retried the module download in the Docker
|
||||||
|
build ([issue #351](https://git.eeqj.de/sneak/vaultik/issues/351)),
|
||||||
|
which had hung for as long as a stalled connection to the module
|
||||||
|
proxy lasted.
|
||||||
|
|
||||||
|
- 2026-10-13: Recorded each snapshot's uncompressed size when its
|
||||||
|
backup runs
|
||||||
|
([issue #360](https://git.eeqj.de/sneak/vaultik/issues/360)). A later
|
||||||
|
backup could change an older snapshot's UNCOMPRESSED SIZE in
|
||||||
|
`snapshot list`.
|
||||||
|
|
||||||
|
- 2026-10-13: Stopped `snapshot restore --skip-errors --verify`
|
||||||
|
reporting a file that failed to restore again as a checksum failure
|
||||||
|
([issue #323](https://git.eeqj.de/sneak/vaultik/issues/323)).
|
||||||
|
|
||||||
|
- 2026-10-13: Recorded the stored size of a blob a backup finds already
|
||||||
|
stored, not the size of its local copy, which can differ, so
|
||||||
|
`snapshot verify` could fail on a good snapshot
|
||||||
|
([issue #300](https://git.eeqj.de/sneak/vaultik/issues/300)).
|
||||||
|
|
||||||
|
- 2026-10-13: Stopped the local index and the blob upload step ending
|
||||||
|
the process on a failed close of query results or a failed temp file
|
||||||
|
cleanup ([issue #345](https://git.eeqj.de/sneak/vaultik/issues/345));
|
||||||
|
both are now returned as errors.
|
||||||
|
|
||||||
|
- 2026-10-13: Closed the snapshot database's storage reader too when
|
||||||
|
restore or deep verify is interrupted
|
||||||
|
([issue #356](https://git.eeqj.de/sneak/vaultik/issues/356)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made `prune` and `remote nuke` fail when a delete on the
|
||||||
|
backup destination store fails
|
||||||
|
([issue #290](https://git.eeqj.de/sneak/vaultik/issues/290)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made an S3 listing that the server cuts short an error
|
||||||
|
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)); `prune`
|
||||||
|
had taken it as complete and could delete blobs still in use.
|
||||||
|
|
||||||
|
- 2026-10-13: Made shallow `snapshot verify` report a blob it could not
|
||||||
|
check apart from a missing one
|
||||||
|
([issue #318](https://git.eeqj.de/sneak/vaultik/issues/318)); any
|
||||||
|
error checking a blob had counted as missing, its cause dropped.
|
||||||
|
|
||||||
|
- 2026-10-13: Restored the other files when a file's chunk is in no
|
||||||
|
blob under `--skip-errors`
|
||||||
|
([issue #326](https://git.eeqj.de/sneak/vaultik/issues/326)); that
|
||||||
|
file had stopped the restore before anything was restored.
|
||||||
|
|
||||||
|
- 2026-10-13: Recorded each snapshot's new chunk size when its backup
|
||||||
|
runs ([issue #311](https://git.eeqj.de/sneak/vaultik/issues/311)).
|
||||||
|
`snapshot list` could show a NEW CHUNK SIZE of 0 B for a snapshot
|
||||||
|
whose change was a modified file.
|
||||||
|
|
||||||
|
- 2026-10-13: Made a backup interrupted during a local index write exit
|
||||||
|
as interrupted
|
||||||
|
([issue #301](https://git.eeqj.de/sneak/vaultik/issues/301)); it could
|
||||||
|
end with `FATAL: failed to rollback transaction` and exit 1 instead of
|
||||||
|
130.
|
||||||
|
|
||||||
|
- 2026-10-13: Printed `snapshot list` and `snapshot purge` sizes like
|
||||||
|
every other command
|
||||||
|
([issue #312](https://git.eeqj.de/sneak/vaultik/issues/312)). They,
|
||||||
|
and the `bytes` value in terminal log lines, were 1024-based but
|
||||||
|
labelled KB or MB, and now use 1000-based units.
|
||||||
|
|
||||||
|
- 2026-10-13: Closed each blob's storage reader when restore or deep
|
||||||
|
verify is interrupted, which ends a stalled read unless it is stuck in
|
||||||
|
the kernel ([issue #325](https://git.eeqj.de/sneak/vaultik/issues/325)).
|
||||||
|
|
||||||
|
- 2026-10-13: Made a mistyped subcommand under `snapshot`, `config`,
|
||||||
|
`remote`, `database` or `completion` an error
|
||||||
|
([issue #319](https://git.eeqj.de/sneak/vaultik/issues/319)); each had
|
||||||
|
printed its help and exited 0.
|
||||||
|
|
||||||
|
- 2026-10-13: Made an exclude pattern starting with `**/` match
|
||||||
|
directly under the snapshot path too, as in gitignore
|
||||||
|
([issue #303](https://git.eeqj.de/sneak/vaultik/issues/303)).
|
||||||
|
|
||||||
|
- 2026-10-13: Refused a relative `file://` storage URL and decoded its
|
||||||
|
percent-escapes
|
||||||
|
([issue #299](https://git.eeqj.de/sneak/vaultik/issues/299)); a
|
||||||
|
relative path had moved with the working directory.
|
||||||
|
|
||||||
|
- 2026-10-13: Removed the blob temp file when a backup stops part-way
|
||||||
|
([issue #302](https://git.eeqj.de/sneak/vaultik/issues/302)); the
|
||||||
|
half-written blob had stayed in `$TMPDIR`.
|
||||||
|
|
||||||
|
- 2026-10-13: Checked `--keep-newer-than` before a backup starts
|
||||||
|
([issue #307](https://git.eeqj.de/sneak/vaultik/issues/307)); an
|
||||||
|
invalid value had been read only by the prune after the backup, so
|
||||||
|
retention never ran.
|
||||||
|
|
||||||
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
|
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
|
||||||
is not a blob as an orphaned blob
|
is not a blob as an orphaned blob
|
||||||
([issue #295](https://git.eeqj.de/sneak/vaultik/issues/295)). A stray
|
([issue #295](https://git.eeqj.de/sneak/vaultik/issues/295)). A stray
|
||||||
@@ -1083,5 +1316,64 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
Work planned after 1.0 is listed in the README
|
Work planned after 1.0, loosely ordered by priority.
|
||||||
[roadmap](README.md#roadmap).
|
|
||||||
|
## Correctness and operability
|
||||||
|
|
||||||
|
- **Outside security audit.** Before 1.0 the encryption and
|
||||||
|
blob-generation code was reviewed. Every bug the review found was
|
||||||
|
fixed. The risks it accepted are listed in
|
||||||
|
[Accepted risks](docs/REPOSTRUCTURE.md#accepted-risks). No outside
|
||||||
|
audit has been done. age, zstd and content-defined chunking are mostly
|
||||||
|
off-the-shelf pieces. The code that joins them needs an outside read:
|
||||||
|
- key handling
|
||||||
|
- recipient parsing
|
||||||
|
- manifest trust boundary
|
||||||
|
- restore-time identity validation
|
||||||
|
- **Verify restored content end-to-end in CI.** The current integration
|
||||||
|
test does this for a small synthetic snapshot but not at scale. A
|
||||||
|
nightly job against a multi-GB representative snapshot would catch
|
||||||
|
silent regressions in the chunker, packer, or restore planner.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Parallel blob downloads during restore.** A restore downloads one
|
||||||
|
blob at a time now. With a fast S3 endpoint and a multi-core machine,
|
||||||
|
restore speed is bound by the fetch, decryption and decompression of
|
||||||
|
each blob. Running several of those in parallel against the disk
|
||||||
|
cache would close most of the remaining gap. This needs to interact
|
||||||
|
correctly with the locality planner and the sweeper.
|
||||||
|
- **Bandwidth limiting (`--bwlimit`).** For both upload and download.
|
||||||
|
It is useful for backing up over a shared link. It is tricky to make
|
||||||
|
work correctly with parallel downloads.
|
||||||
|
- **Restart of interrupted restore.** Today a restore can be started
|
||||||
|
again, and it then overwrites the partial output. It does not resume
|
||||||
|
from where it stopped, and it does not skip files that are already
|
||||||
|
present. A `--resume` mode that checks targets before fetching blobs
|
||||||
|
would matter for very large restores.
|
||||||
|
- **Daemon mode.** A long-running mode that watches for file changes,
|
||||||
|
so that frequent backups, such as hourly ones, skip the full scan. It
|
||||||
|
adds little for the usual backups from cron every 12 to 36 hours. See
|
||||||
|
[issue #204](https://git.eeqj.de/sneak/vaultik/issues/204).
|
||||||
|
|
||||||
|
## Usability
|
||||||
|
|
||||||
|
- **Man pages and richer `--help` examples.** Cobra generates basic
|
||||||
|
help. Man pages would be a separate target.
|
||||||
|
- **`vaultik snapshot diff <a> <b>`.** Show which files changed between
|
||||||
|
two snapshots, without restoring either.
|
||||||
|
- **Status reporting hook for `--cron`.** When a backup fails silently
|
||||||
|
in cron, the user has no idea. A configurable hook on completion, for
|
||||||
|
both success and failure, would close the loop. It could be a
|
||||||
|
webhook, an email or `notify-send`.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- **Schema migrations after 1.0.** Migrations are supported, but none
|
||||||
|
are added before 1.0 because there is no installed base yet. After
|
||||||
|
1.0, each schema change is a new migration, so an existing local
|
||||||
|
index is migrated when `vaultik` is updated. See
|
||||||
|
[`docs/DATAMODEL.md`](docs/DATAMODEL.md#schema-migrations).
|
||||||
|
- **Storage backend coverage tests.** S3, `file://` and `rclone://` all
|
||||||
|
share the `Storer` interface, but the rclone path is the least
|
||||||
|
exercised in CI.
|
||||||
|
|||||||
+40
-3
@@ -1,16 +1,31 @@
|
|||||||
# vaultik configuration file example
|
# vaultik configuration file example
|
||||||
# This file shows all available configuration options with their default values
|
# This file shows every configuration option. The values are examples, not
|
||||||
# Copy this file and uncomment/modify the values you need
|
# defaults; where an option has a default, its comment gives it.
|
||||||
|
# Copy this file and change the values you need
|
||||||
|
|
||||||
# Age recipient public keys for encryption
|
# Age recipient public keys for encryption
|
||||||
# Backups are encrypted to these public keys. snapshot create needs at least
|
# Backups are encrypted to these public keys. snapshot create needs at least
|
||||||
# one; listing, verifying and restoring do not
|
# one; listing, verifying and restoring do not
|
||||||
# Generate with: age-keygen | grep "public key"
|
# Generate a key pair, and show its public key, with:
|
||||||
|
# age-keygen -o vaultik_backup_private_key.txt
|
||||||
|
# grep 'public key' vaultik_backup_private_key.txt
|
||||||
|
# Keep the private key file: restoring needs it. Do not pipe age-keygen
|
||||||
|
# straight into grep, which would discard the private key
|
||||||
age_recipients:
|
age_recipients:
|
||||||
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
- age1cj2k2addawy294f6k2gr2mf9gps9r3syplryxca3nvxj3daqm96qfp84tz
|
||||||
|
|
||||||
|
# Age private key for decryption
|
||||||
|
# Only snapshot restore and snapshot verify --deep need it. The
|
||||||
|
# VAULTIK_AGE_SECRET_KEY environment variable overrides this setting
|
||||||
|
# Do not set it on a machine you back up: prefer the environment variable,
|
||||||
|
# supplied only on the machine you restore on
|
||||||
|
# Default: not set
|
||||||
|
#age_secret_key: YOUR_AGE_PRIVATE_KEY
|
||||||
|
|
||||||
# Named snapshots - each snapshot can contain multiple paths
|
# Named snapshots - each snapshot can contain multiple paths
|
||||||
# Each snapshot gets its own ID and can have snapshot-specific excludes
|
# Each snapshot gets its own ID and can have snapshot-specific excludes
|
||||||
|
# Required: at least one snapshot with at least one path. Only the config
|
||||||
|
# commands, version and completion run without one
|
||||||
snapshots:
|
snapshots:
|
||||||
testing:
|
testing:
|
||||||
paths:
|
paths:
|
||||||
@@ -258,6 +273,11 @@ exclude:
|
|||||||
|
|
||||||
# Storage URL - use either this OR the s3 section below
|
# Storage URL - use either this OR the s3 section below
|
||||||
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
||||||
|
# An s3:// URL also needs s3.access_key_id and s3.secret_access_key below
|
||||||
|
# A file:// path must be absolute, and the directory must already exist:
|
||||||
|
# vaultik never creates it
|
||||||
|
# An rclone:// URL needs the remote to be defined in rclone's own config
|
||||||
|
# (rclone config)
|
||||||
storage_url: "rclone://myremote/path/to/backups"
|
storage_url: "rclone://myremote/path/to/backups"
|
||||||
|
|
||||||
# S3-compatible storage configuration
|
# S3-compatible storage configuration
|
||||||
@@ -283,6 +303,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# #region: us-east-1
|
# #region: us-east-1
|
||||||
#
|
#
|
||||||
# # Use SSL/TLS for S3 connections
|
# # Use SSL/TLS for S3 connections
|
||||||
|
# # Only read when storage_url is not set and the endpoint has no scheme;
|
||||||
|
# # with an s3:// storage_url, use ?ssl=false in the URL instead
|
||||||
# # Default: true
|
# # Default: true
|
||||||
# #use_ssl: true
|
# #use_ssl: true
|
||||||
#
|
#
|
||||||
@@ -295,6 +317,7 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
|
|
||||||
# Path to local SQLite index database
|
# Path to local SQLite index database
|
||||||
# This database tracks file state for incremental backups
|
# This database tracks file state for incremental backups
|
||||||
|
# The VAULTIK_INDEX_PATH environment variable overrides this setting
|
||||||
# Default: the platform data directory, e.g.
|
# Default: the platform data directory, e.g.
|
||||||
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
||||||
# Linux: ~/.local/share/vaultik/index.sqlite
|
# Linux: ~/.local/share/vaultik/index.sqlite
|
||||||
@@ -328,6 +351,20 @@ storage_url: "rclone://myremote/path/to/backups"
|
|||||||
# Higher = better compression but slower
|
# Higher = better compression but slower
|
||||||
# Default: 3
|
# Default: 3
|
||||||
compression_level: 5
|
compression_level: 5
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it, and
|
||||||
|
# how old every object in a directory under metadata/ with no manifest must
|
||||||
|
# be before prune deletes that directory
|
||||||
|
# A backup uploads its blobs and its db.zst.age before its manifest, so on a
|
||||||
|
# destination shared between hosts or users this must be longer than the
|
||||||
|
# longest backup run
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that stopped
|
||||||
|
# before its manifest: that blob keeps the time of its first upload, so a
|
||||||
|
# prune can delete it
|
||||||
|
# Supports: 36h, 90m, etc.; 0s deletes such blobs and directories at once
|
||||||
|
# Default: 24h
|
||||||
|
#prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname to use in backup metadata
|
# Hostname to use in backup metadata
|
||||||
# Default: system hostname
|
# Default: system hostname
|
||||||
#hostname: myserver
|
#hostname: myserver
|
||||||
|
|||||||
+380
-254
@@ -1,292 +1,418 @@
|
|||||||
# Vaultik Data Model
|
# vaultik data model
|
||||||
|
|
||||||
## Overview
|
`vaultik` keeps a SQLite database on the backed-up machine, called the local
|
||||||
|
index. This document describes its tables and how each command uses them.
|
||||||
|
|
||||||
Vaultik uses a local SQLite database to track file metadata, chunk mappings, and blob associations during the backup process. This database serves as an index for incremental backups and enables efficient deduplication.
|
The local index has two jobs:
|
||||||
|
|
||||||
**Important Notes:**
|
- It records which files, chunks and blobs are already stored. The next backup
|
||||||
|
then does not store them again.
|
||||||
|
- It is the source of each snapshot's own database. A backup uploads a trimmed,
|
||||||
|
encrypted copy of the index with every snapshot.
|
||||||
|
|
||||||
- **Version Compatibility**: In rare cases, you may need to use the same version
|
A restore does not need the local index. It reads the copy that was uploaded
|
||||||
of Vaultik to restore a backup as was used to create it. This ensures
|
with the snapshot.
|
||||||
compatibility with the metadata format stored in S3.
|
|
||||||
|
|
||||||
## Schema Migrations
|
## Terms
|
||||||
|
|
||||||
Vaultik supports schema migrations. They are the numbered SQL files in
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
||||||
`internal/database/schema/`, embedded in the binary: `000.sql` creates the
|
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
|
||||||
`schema_migrations` table, which records each migration that has run, and
|
content.
|
||||||
`001.sql` creates the application tables. `database.New` opens a database and
|
- **Blob**: one object at the destination. It holds many chunks, compressed and
|
||||||
applies, in order, every migration that database has not yet recorded.
|
then encrypted.
|
||||||
|
- **Destination**: the place the backups are stored.
|
||||||
|
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
|
||||||
|
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
|
||||||
|
at the destination.
|
||||||
|
|
||||||
**Before 1.0** no migrations are added, because nothing is installed anywhere
|
## Where the index is
|
||||||
yet. A schema change edits `001.sql` (and the code that uses the affected
|
|
||||||
tables) directly. A local database created before the change has already
|
|
||||||
recorded `001.sql` as run, so it keeps the old schema and can become unusable;
|
|
||||||
`vaultik database delete` followed by a full backup rebuilds it.
|
|
||||||
|
|
||||||
**After 1.0** each schema change is a new numbered file, so an existing local
|
|
||||||
database is migrated the first time an updated vaultik opens it. A file that
|
|
||||||
has shipped in a release is never edited.
|
|
||||||
|
|
||||||
## Database Tables
|
|
||||||
|
|
||||||
### 1. `files`
|
|
||||||
Stores metadata about files in the filesystem being backed up.
|
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID for the file record
|
|
||||||
- `path` (TEXT NOT NULL UNIQUE) - Absolute file path
|
|
||||||
- `mtime` (INTEGER NOT NULL) - Modification time, whole seconds since the Unix epoch
|
|
||||||
- `mtime_nsec` (INTEGER NOT NULL) - Nanoseconds within that second, 0 to 999999999
|
|
||||||
- `size` (INTEGER NOT NULL) - File size in bytes
|
|
||||||
- `mode` (INTEGER NOT NULL) - Unix file permissions and type
|
|
||||||
- `uid` (INTEGER NOT NULL) - User ID of file owner
|
|
||||||
- `gid` (INTEGER NOT NULL) - Group ID of file owner
|
|
||||||
- `link_target` (TEXT) - Symlink target path (NULL for regular files)
|
|
||||||
|
|
||||||
**Indexes:**
|
|
||||||
- `idx_files_path` on `path` for efficient lookups
|
|
||||||
|
|
||||||
**Purpose:** Tracks file metadata to detect changes between backup runs. Used for incremental backup decisions. The UUID primary key provides stable references that don't change if files are moved.
|
|
||||||
|
|
||||||
### 2. `chunks`
|
|
||||||
Stores information about content-defined chunks created from files.
|
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `chunk_hash` (TEXT PRIMARY KEY) - SHA256 hash of chunk content
|
|
||||||
- `size` (INTEGER NOT NULL) - Chunk size in bytes
|
|
||||||
|
|
||||||
**Purpose:** Enables deduplication by tracking unique chunks across all files.
|
The index is the file `index.sqlite` in the platform data directory. That is
|
||||||
|
`~/.local/share/vaultik/` on Linux and `~/Library/Application Support/vaultik/`
|
||||||
### 3. `file_chunks`
|
on macOS. The `index_path` setting and the `VAULTIK_INDEX_PATH` environment
|
||||||
Maps files to their constituent chunks in order.
|
variable change the path.
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
|
||||||
- `idx` (INTEGER) - Chunk index within file (0-based)
|
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
|
||||||
- PRIMARY KEY (`file_id`, `idx`)
|
|
||||||
|
|
||||||
**Purpose:** Allows reconstruction of files from chunks during restore.
|
The file is readable only by its owner, because it lists every backed-up path.
|
||||||
|
`vaultik` sets that mode each time it opens the index.
|
||||||
|
|
||||||
### 4. `chunk_files`
|
## Schema migrations
|
||||||
Reverse mapping showing which files contain each chunk.
|
|
||||||
|
|
||||||
**Columns:**
|
`vaultik` supports schema migrations. They are the numbered SQL files in
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
`internal/database/schema/`, which are built into the binary.
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
|
||||||
- `file_offset` (INTEGER) - Byte offset of chunk within file
|
|
||||||
- `length` (INTEGER) - Length of chunk in bytes
|
|
||||||
- PRIMARY KEY (`chunk_hash`, `file_id`)
|
|
||||||
|
|
||||||
**Purpose:** Supports efficient queries for chunk usage and deduplication statistics.
|
- `000.sql` creates the `schema_migrations` table. That table records each
|
||||||
|
migration that has run.
|
||||||
|
- `001.sql` creates every other table.
|
||||||
|
|
||||||
### 5. `blobs`
|
`database.New` opens the index. It then applies, in order, every migration that
|
||||||
Stores information about packed, compressed, and encrypted blob files.
|
the index has not recorded.
|
||||||
|
|
||||||
**Columns:**
|
Before version 1.0, no migration is added, because nothing is installed anywhere
|
||||||
- `id` (TEXT PRIMARY KEY) - UUID assigned when blob creation starts
|
yet. A schema change edits `001.sql` directly, with the code that uses the
|
||||||
- `blob_hash` (TEXT UNIQUE) - `hex(SHA256(SHA256(uncompressed blob contents)))`, computed before compression and encryption (NULL until finalized); see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs-directory-blobs)
|
changed tables.
|
||||||
- `created_ts` (INTEGER NOT NULL) - Creation timestamp
|
|
||||||
- `finished_ts` (INTEGER) - Finalization timestamp (NULL if in progress)
|
|
||||||
- `uncompressed_size` (INTEGER NOT NULL DEFAULT 0) - Total size of chunks before compression
|
|
||||||
- `compressed_size` (INTEGER NOT NULL DEFAULT 0) - Size after compression and encryption. Once uploaded, the size of the object in storage: when the blob was already stored, under another compression level or other recipients, this can differ from the size of the local copy
|
|
||||||
- `uploaded_ts` (INTEGER) - Upload completion timestamp (NULL if not uploaded)
|
|
||||||
|
|
||||||
**Purpose:** Tracks blob lifecycle from creation through upload. The UUID primary key allows immediate association of chunks with blobs.
|
This has one consequence. An index created before such a change has already
|
||||||
|
recorded `001.sql` as run, so it keeps the old schema and can become unusable.
|
||||||
|
To rebuild it, run `vaultik database delete` and then a full backup.
|
||||||
|
|
||||||
### 6. `blob_chunks`
|
After version 1.0, each schema change is a new numbered file. An existing index
|
||||||
Maps chunks to the blobs that contain them.
|
is then migrated the first time an updated `vaultik` opens it. A file that was
|
||||||
|
part of a release is never edited.
|
||||||
**Columns:**
|
|
||||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
|
||||||
- `chunk_hash` (TEXT) - Chunk hash (FK to chunks.chunk_hash)
|
|
||||||
- `offset` (INTEGER) - Byte offset of chunk within blob (before compression)
|
|
||||||
- `length` (INTEGER) - Length of chunk in bytes
|
|
||||||
- PRIMARY KEY (`blob_id`, `chunk_hash`)
|
|
||||||
|
|
||||||
**Purpose:** Enables chunk retrieval from blobs during restore operations.
|
|
||||||
|
|
||||||
### 7. `snapshots`
|
|
||||||
Tracks backup snapshots.
|
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `id` (TEXT PRIMARY KEY) - Snapshot ID (format: `hostname_name_timestamp`, e.g. `server1_home_2025-06-01T12:00:00Z`: the hostname up to its first `.`, the snapshot name, and an RFC 3339 UTC timestamp)
|
|
||||||
- `hostname` (TEXT) - Hostname where backup was created
|
|
||||||
- `vaultik_version` (TEXT) - Version of Vaultik used
|
|
||||||
- `vaultik_git_revision` (TEXT) - Git revision of Vaultik used
|
|
||||||
- `started_at` (INTEGER) - Start timestamp
|
|
||||||
- `completed_at` (INTEGER) - Completion timestamp (NULL if in progress)
|
|
||||||
- `file_count` (INTEGER) - Number of files in snapshot
|
|
||||||
- `chunk_count` (INTEGER) - Number of chunks this snapshot stored that were not stored before
|
|
||||||
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
|
|
||||||
- `blob_count` (INTEGER) - Number of blobs this snapshot created
|
|
||||||
- `total_size` (INTEGER) - Total size of all files
|
|
||||||
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
|
|
||||||
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
|
|
||||||
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
|
|
||||||
- `compression_ratio` (REAL) - Compression ratio achieved
|
|
||||||
- `compression_level` (INTEGER) - Compression level used for this snapshot
|
|
||||||
- `upload_bytes` (INTEGER) - Total bytes uploaded during this snapshot
|
|
||||||
- `upload_duration_ms` (INTEGER) - Total milliseconds spent uploading to S3
|
|
||||||
|
|
||||||
**Purpose:** Provides snapshot metadata and statistics including version tracking for compatibility.
|
|
||||||
|
|
||||||
### 8. `snapshot_files`
|
|
||||||
Maps snapshots to the files they contain.
|
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
|
||||||
- `file_id` (TEXT) - File ID (FK to files.id)
|
|
||||||
- PRIMARY KEY (`snapshot_id`, `file_id`)
|
|
||||||
|
|
||||||
**Purpose:** Records which files are included in each snapshot.
|
Migrations apply to the local index only. A snapshot's database at the
|
||||||
|
destination is read as it was written. Each snapshot records the `vaultik`
|
||||||
|
version that wrote it.
|
||||||
|
|
||||||
### 9. `snapshot_blobs`
|
## Tables
|
||||||
Maps snapshots to the blobs they reference.
|
|
||||||
|
|
||||||
**Columns:**
|
Times are whole seconds since the Unix epoch, in UTC. The two exceptions are
|
||||||
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
|
noted at their columns.
|
||||||
- `blob_id` (TEXT) - Blob ID (FK to blobs.id)
|
|
||||||
- `blob_hash` (TEXT) - Denormalized blob hash for manifest generation
|
|
||||||
- PRIMARY KEY (`snapshot_id`, `blob_id`)
|
|
||||||
|
|
||||||
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
|
|
||||||
|
|
||||||
### 10. `uploads`
|
|
||||||
Tracks blob upload metrics.
|
|
||||||
|
|
||||||
**Columns:**
|
|
||||||
- `blob_hash` (TEXT PRIMARY KEY) - Hash of uploaded blob
|
|
||||||
- `snapshot_id` (TEXT NOT NULL) - The snapshot that triggered this upload (FK to snapshots.id)
|
|
||||||
- `uploaded_at` (INTEGER) - Upload timestamp
|
|
||||||
- `size` (INTEGER) - Size of uploaded blob
|
|
||||||
- `duration_ms` (INTEGER) - Upload duration in milliseconds
|
|
||||||
|
|
||||||
**Purpose:** Performance monitoring and tracking which blobs were newly created (uploaded) during each snapshot.
|
|
||||||
|
|
||||||
## Data Flow and Operations
|
|
||||||
|
|
||||||
### 1. Backup Process
|
|
||||||
|
|
||||||
1. **File Scanning**
|
|
||||||
- `INSERT OR REPLACE INTO files` - Update file metadata
|
|
||||||
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
|
|
||||||
- `INSERT INTO snapshot_files` - Add file to current snapshot
|
|
||||||
|
|
||||||
2. **Chunking** (for changed files)
|
|
||||||
- `INSERT OR IGNORE INTO chunks` - Store new chunks
|
|
||||||
- `INSERT INTO file_chunks` - Map chunks to file
|
|
||||||
- `INSERT INTO chunk_files` - Create reverse mapping
|
|
||||||
|
|
||||||
3. **Blob Packing**
|
|
||||||
- `INSERT INTO blobs` - Create blob record with UUID (blob_hash NULL)
|
|
||||||
- `INSERT INTO blob_chunks` - Associate chunks with blob immediately
|
|
||||||
- `UPDATE blobs SET blob_hash = ?, finished_ts = ?` - Finalize blob after packing
|
|
||||||
|
|
||||||
4. **Upload**
|
|
||||||
- `UPDATE blobs SET uploaded_ts = ?, compressed_size = ?` - Mark blob as uploaded, with the size of the object in storage (the upload is skipped when the blob is already stored)
|
|
||||||
- `INSERT INTO uploads` - Record upload metrics with snapshot_id
|
|
||||||
- `INSERT INTO snapshot_blobs` - Associate blob with snapshot
|
|
||||||
|
|
||||||
5. **Snapshot Completion**
|
|
||||||
- `UPDATE snapshots SET completed_at = ?, stats...` - Finalize snapshot
|
|
||||||
- Generate and upload blob manifest from `snapshot_blobs`
|
|
||||||
|
|
||||||
### 2. Incremental Backup
|
|
||||||
|
|
||||||
1. **Change Detection**
|
|
||||||
- `SELECT * FROM files WHERE path = ?` - Get previous file metadata
|
|
||||||
- Compare mtime, size, mode to detect changes
|
|
||||||
- Re-chunk a file that lists a chunk no uploaded blob holds, even when its metadata is unchanged
|
|
||||||
- Skip unchanged files but still add to `snapshot_files`
|
|
||||||
|
|
||||||
2. **Chunk Reuse**
|
|
||||||
- `SELECT * FROM blob_chunks WHERE chunk_hash = ?` - Find existing chunks
|
|
||||||
- `INSERT INTO snapshot_blobs` - Reference existing blobs for unchanged files
|
|
||||||
|
|
||||||
### 3. Snapshot Metadata Export
|
|
||||||
|
|
||||||
After a snapshot is completed:
|
|
||||||
1. Copy database to temporary file
|
|
||||||
2. Clean temporary database to contain only current snapshot data
|
|
||||||
3. VACUUM the trimmed database so deleted rows leave no pages behind
|
|
||||||
4. Compress with zstd and encrypt with age
|
|
||||||
5. Upload to S3 as `metadata/{remote-key}/db.zst.age`
|
|
||||||
6. Generate blob manifest and upload as `metadata/{remote-key}/manifest.json.zst`
|
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way hash of the human snapshot ID, so the ID is never written to the store in plaintext. The hash uses no secret, so a guessed hostname and snapshot name can still be confirmed against a listing; see [REPOSTRUCTURE.md](REPOSTRUCTURE.md#remote-key-derivation) and its [Accepted Risks](REPOSTRUCTURE.md#accepted-risks).
|
|
||||||
|
|
||||||
### 4. Restore Process
|
|
||||||
|
|
||||||
The restore process doesn't use the local database. Instead:
|
### `files`
|
||||||
1. Downloads and decrypts the snapshot's metadata database (`db.zst.age`) from S3
|
|
||||||
2. Downloads the blobs holding the chunks of the files being restored, found through that database's `blob_chunks` table; the manifest is not read
|
|
||||||
3. Reconstructs files from decrypted and decompressed chunks
|
|
||||||
|
|
||||||
### 5. Pruning
|
One row for each backed-up path: a regular file, a symlink or a directory.
|
||||||
|
|
||||||
1. **Identify Unreferenced Blobs**
|
- `id`: a UUID. Primary key.
|
||||||
- Query blobs not referenced by any remaining snapshot
|
- `path`: the absolute path. Unique.
|
||||||
- Delete from S3 and local database
|
- `source_path`: the configured path that the scan found this entry under. A
|
||||||
|
restore does not read it.
|
||||||
|
- `mtime`: the modification time.
|
||||||
|
- `mtime_nsec`: the nanoseconds within that second.
|
||||||
|
- `size`: the size in bytes.
|
||||||
|
- `mode`: the permission bits and the file type.
|
||||||
|
- `uid`: the owner's user ID.
|
||||||
|
- `gid`: the owner's group ID.
|
||||||
|
- `link_target`: the target of a symlink. It is `NULL` for other entries.
|
||||||
|
|
||||||
### 6. Incomplete Snapshot Cleanup
|
`idx_files_path` indexes `path`.
|
||||||
|
|
||||||
Before each backup:
|
There is one row for each path, and it is updated when the file changes. The
|
||||||
1. Query incomplete snapshots (where `completed_at IS NULL`)
|
row is shared by every snapshot in the local index that lists the path. An
|
||||||
2. Delete each one and all its associations, without checking S3 for its metadata
|
older snapshot's own view of the file is in that snapshot's database at the
|
||||||
3. Clean up orphaned files, chunks, and blobs
|
destination.
|
||||||
|
|
||||||
## Repository Pattern
|
### `chunks`
|
||||||
|
|
||||||
Vaultik uses a repository pattern for database access:
|
One row for each distinct chunk.
|
||||||
|
|
||||||
- `FileRepository` - CRUD operations for files and file metadata
|
- `chunk_hash`: the SHA-256 hash of the chunk's content, in hex. Primary key.
|
||||||
- `ChunkRepository` - CRUD operations for content chunks
|
- `size`: the size in bytes.
|
||||||
- `FileChunkRepository` - Manage file-to-chunk mappings
|
|
||||||
- `ChunkFileRepository` - Manage chunk-to-file reverse mappings
|
|
||||||
- `BlobRepository` - Manage blob lifecycle (creation, finalization, upload)
|
|
||||||
- `BlobChunkRepository` - Manage blob-to-chunk associations
|
|
||||||
- `SnapshotRepository` - Manage snapshots and their relationships
|
|
||||||
- `UploadRepository` - Track blob upload metrics
|
|
||||||
|
|
||||||
Each repository provides methods like:
|
### `file_chunks`
|
||||||
- `Create()` - Insert new record
|
|
||||||
- `GetByID()` / `GetByPath()` / `GetByHash()` - Retrieve records
|
|
||||||
- `Update()` - Update existing records
|
|
||||||
- `Delete()` - Remove records
|
|
||||||
- Specialized queries for each entity type (e.g., `DeleteOrphaned()`, `GetIncompleteByHostname()`)
|
|
||||||
|
|
||||||
## Transaction Management
|
The chunks that make up each file, in order.
|
||||||
|
|
||||||
All database operations that modify multiple tables are wrapped in transactions:
|
- `file_id`: the file. References `files.id`.
|
||||||
|
- `idx`: the position of the chunk within the file, counted from 0.
|
||||||
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
||||||
|
|
||||||
|
The primary key is (`file_id`, `idx`). `idx_file_chunks_chunk_hash` indexes
|
||||||
|
`chunk_hash`.
|
||||||
|
|
||||||
|
### `chunk_files`
|
||||||
|
|
||||||
|
The reverse of `file_chunks`: the files that contain each chunk.
|
||||||
|
|
||||||
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
||||||
|
- `file_id`: the file. References `files.id`.
|
||||||
|
- `file_offset`: the byte offset of the chunk within the file.
|
||||||
|
- `length`: the length of the chunk in bytes.
|
||||||
|
|
||||||
|
The primary key is (`chunk_hash`, `file_id`). `idx_chunk_files_file_id` indexes
|
||||||
|
`file_id`.
|
||||||
|
|
||||||
|
### `blobs`
|
||||||
|
|
||||||
|
One row for each blob.
|
||||||
|
|
||||||
|
- `id`: a UUID, given when packing of the blob starts. Primary key.
|
||||||
|
- `blob_hash`: the name of the blob at the destination. Unique. Until the blob
|
||||||
|
is closed, it holds a placeholder that starts with `temp-placeholder-`.
|
||||||
|
- `created_ts`: when packing started.
|
||||||
|
- `finished_ts`: when the blob was closed. It is `NULL` before that.
|
||||||
|
- `uncompressed_size`: the total size of the blob's chunks.
|
||||||
|
- `compressed_size`: the size after compression and encryption.
|
||||||
|
- `uploaded_ts`: when the upload finished. It is `NULL` before that.
|
||||||
|
|
||||||
|
`blob_hash` is `hex(SHA256(SHA256(uncompressed contents)))`. See
|
||||||
|
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs).
|
||||||
|
|
||||||
|
Once a blob is uploaded, `compressed_size` is the size of the object at the
|
||||||
|
destination. A blob that was already there can have a different size from the
|
||||||
|
local copy. That happens when it was written with another compression level or
|
||||||
|
other recipients.
|
||||||
|
|
||||||
|
### `blob_chunks`
|
||||||
|
|
||||||
|
The chunks that each blob holds.
|
||||||
|
|
||||||
|
- `blob_id`: the blob. References `blobs.id`.
|
||||||
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
||||||
|
- `offset`: the byte offset of the chunk within the uncompressed blob.
|
||||||
|
- `length`: the length of the chunk in bytes.
|
||||||
|
|
||||||
|
The primary key is (`blob_id`, `chunk_hash`). `idx_blob_chunks_chunk_hash`
|
||||||
|
indexes `chunk_hash`.
|
||||||
|
|
||||||
|
A restore finds the blob for each chunk through this table.
|
||||||
|
|
||||||
|
### `snapshots`
|
||||||
|
|
||||||
|
One row for each snapshot.
|
||||||
|
|
||||||
|
- `id`: the snapshot ID. Primary key.
|
||||||
|
- `hostname`: the hostname of the machine that made the backup.
|
||||||
|
- `vaultik_version`: the `vaultik` version that made it.
|
||||||
|
- `vaultik_git_revision`: the git commit of that version.
|
||||||
|
- `started_at`: when the backup started.
|
||||||
|
- `completed_at`: when the backup finished. It is `NULL` until then.
|
||||||
|
- `file_count`: the number of files and symlinks in the snapshot. Directories
|
||||||
|
are not counted.
|
||||||
|
- `chunk_count`: the number of chunks this snapshot stored that were not stored
|
||||||
|
before.
|
||||||
|
- `new_chunk_size`: the total size of those chunks before compression.
|
||||||
|
- `blob_count`: the number of blobs this snapshot created.
|
||||||
|
- `total_size`: the total size of all files in the snapshot.
|
||||||
|
- `uncompressed_chunk_size`: the total size, before compression, of the
|
||||||
|
distinct chunks the snapshot's files use.
|
||||||
|
- `blob_size`: the total compressed size of the blobs the snapshot uses.
|
||||||
|
- `blob_uncompressed_size`: the total uncompressed size of those blobs.
|
||||||
|
- `compression_ratio`: the compression ratio of those blobs.
|
||||||
|
- `compression_level`: the compression level the backup used.
|
||||||
|
- `upload_bytes`: the bytes uploaded during the backup.
|
||||||
|
- `upload_duration_ms`: the time spent uploading, in milliseconds.
|
||||||
|
|
||||||
|
The snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An
|
||||||
|
example is `server1_home_2025-06-01T12:00:00Z`. It has three parts:
|
||||||
|
|
||||||
|
- the hostname, up to its first `.`
|
||||||
|
- the snapshot name
|
||||||
|
- the start time, in UTC
|
||||||
|
|
||||||
|
`snapshot list` shows `uncompressed_chunk_size` as `UNCOMPRESSED SIZE` and
|
||||||
|
`new_chunk_size` as `NEW CHUNK SIZE`.
|
||||||
|
|
||||||
|
A snapshot with `completed_at` set to `NULL` is unfinished. `snapshot list`
|
||||||
|
does not show it. The next backup deletes it, and so does `prune`.
|
||||||
|
|
||||||
|
### `snapshot_files`
|
||||||
|
|
||||||
|
The files in each snapshot.
|
||||||
|
|
||||||
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
||||||
|
- `file_id`: the file. References `files.id`.
|
||||||
|
|
||||||
|
The primary key is (`snapshot_id`, `file_id`). `idx_snapshot_files_file_id`
|
||||||
|
indexes `file_id`.
|
||||||
|
|
||||||
|
### `snapshot_blobs`
|
||||||
|
|
||||||
|
The blobs that each snapshot uses.
|
||||||
|
|
||||||
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
||||||
|
- `blob_id`: the blob. References `blobs.id`.
|
||||||
|
- `blob_hash`: a copy of the blob's hash.
|
||||||
|
|
||||||
|
The primary key is (`snapshot_id`, `blob_id`). `idx_snapshot_blobs_blob_id`
|
||||||
|
indexes `blob_id`.
|
||||||
|
|
||||||
|
The manifest is built from this table.
|
||||||
|
|
||||||
|
### `snapshot_mount_points`
|
||||||
|
|
||||||
|
The mount points that a snapshot's backup found.
|
||||||
|
|
||||||
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
||||||
|
- `path`: the absolute path of the directory.
|
||||||
|
|
||||||
|
The primary key is (`snapshot_id`, `path`).
|
||||||
|
|
||||||
|
A directory is recorded here when all of these hold:
|
||||||
|
|
||||||
|
- It is at or below a configured path.
|
||||||
|
- The backup found an entry in it that is not excluded.
|
||||||
|
- It is on a different filesystem from its parent directory.
|
||||||
|
|
||||||
|
The device number is read after the directory has been listed. Listing an
|
||||||
|
automount point mounts its volume, so the number is that of the volume. An
|
||||||
|
automount point whose volume cannot be mounted is not recorded, because the
|
||||||
|
backup finds no entry in it.
|
||||||
|
|
||||||
|
`snapshot create --prune` reads this table. It skips the prune when an earlier
|
||||||
|
snapshot of the same name recorded a mount point that the new backup did not
|
||||||
|
find. That happens when the volume is not mounted.
|
||||||
|
|
||||||
|
### `uploads`
|
||||||
|
|
||||||
|
One row for each blob that a backup uploaded.
|
||||||
|
|
||||||
|
- `blob_hash`: the blob. References `blobs.blob_hash`. Primary key.
|
||||||
|
- `snapshot_id`: the snapshot whose backup uploaded it. References
|
||||||
|
`snapshots.id`.
|
||||||
|
- `uploaded_at`: when the upload started. It is written as a date and time, not
|
||||||
|
as seconds.
|
||||||
|
- `size`: the size of the uploaded blob.
|
||||||
|
- `duration_ms`: the duration of the upload, in milliseconds.
|
||||||
|
|
||||||
|
`idx_uploads_snapshot_id` indexes `snapshot_id`.
|
||||||
|
|
||||||
|
A blob that was already at the destination gets no row here.
|
||||||
|
|
||||||
|
### `local_meta`
|
||||||
|
|
||||||
|
Settings that belong to this copy of the index.
|
||||||
|
|
||||||
|
- `key`: the name of the setting. Primary key.
|
||||||
|
- `value`: its value.
|
||||||
|
|
||||||
|
The only key is `storage_url`. It holds the destination that the index was
|
||||||
|
first used with. A command that changes data refuses to run when the config
|
||||||
|
names a different destination.
|
||||||
|
|
||||||
|
### `schema_migrations`
|
||||||
|
|
||||||
|
The migrations that have run.
|
||||||
|
|
||||||
|
- `version`: the number of the migration. Primary key.
|
||||||
|
- `applied_at`: when it ran. It is written as a date and time, not as seconds.
|
||||||
|
|
||||||
|
## How commands use the tables
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
A backup works on the tables in this order:
|
||||||
|
|
||||||
|
- It deletes unfinished snapshots, and the rows that no snapshot uses.
|
||||||
|
- It creates a row in `snapshots`, with `completed_at` set to `NULL`.
|
||||||
|
- It deletes the `blobs` rows that were never uploaded.
|
||||||
|
- It walks each path and compares every entry with its row in `files`.
|
||||||
|
- It links each unchanged file to the snapshot in `snapshot_files`.
|
||||||
|
- It records each mount point in `snapshot_mount_points`.
|
||||||
|
- It reads each changed file and stores it. See below.
|
||||||
|
- It fills `snapshot_blobs` with every blob the snapshot uses.
|
||||||
|
- It stores the snapshot's totals in `snapshots`.
|
||||||
|
- It uploads the snapshot's metadata.
|
||||||
|
- It sets `completed_at`.
|
||||||
|
|
||||||
|
A file counts as changed when its row in `files` differs from the file on disk
|
||||||
|
in any of these:
|
||||||
|
|
||||||
|
- size
|
||||||
|
- modification time
|
||||||
|
- mode
|
||||||
|
- owner or group
|
||||||
|
- link target
|
||||||
|
|
||||||
|
A file is also read again when it lists a chunk that no uploaded blob holds.
|
||||||
|
|
||||||
|
Storing a changed file writes these rows:
|
||||||
|
|
||||||
|
- A row in `blobs`, when packing of a blob starts.
|
||||||
|
- When the blob is closed, in one transaction:
|
||||||
|
- a row in `chunks` for each new chunk in the blob
|
||||||
|
- the blob's rows in `blob_chunks`
|
||||||
|
- the blob's `blob_hash`, sizes and `finished_ts`
|
||||||
|
- When the blob is stored at the destination, in one transaction:
|
||||||
|
- `uploaded_ts` and `compressed_size` in `blobs`
|
||||||
|
- a row in `snapshot_blobs`
|
||||||
|
- a row in `uploads`, unless the blob was already at the destination
|
||||||
|
- The row in `files`, with its `file_chunks`, `chunk_files` and
|
||||||
|
`snapshot_files` rows.
|
||||||
|
|
||||||
|
The row in `files` is written last. It is written only after every blob that
|
||||||
|
holds the file's new chunks is stored.
|
||||||
|
|
||||||
|
### Snapshot metadata
|
||||||
|
|
||||||
|
The metadata of a snapshot is made from a copy of the index:
|
||||||
|
|
||||||
|
- The index is copied into a temporary directory with `VACUUM INTO`.
|
||||||
|
- The rows of every other snapshot are deleted from the copy. So are the files
|
||||||
|
and blobs that this snapshot does not use. A chunk is then deleted when no
|
||||||
|
remaining file and no remaining blob uses it.
|
||||||
|
- The `local_meta` and `schema_migrations` tables are kept as they are. The
|
||||||
|
uploaded database therefore names the destination, and lists the migrations
|
||||||
|
that have run.
|
||||||
|
- `VACUUM` rewrites the copy, so that no deleted row is left in the file.
|
||||||
|
- The copy is compressed with zstd and encrypted with age.
|
||||||
|
- The manifest is built from the copy's `snapshot_blobs` table.
|
||||||
|
- The copy is uploaded as `metadata/<remote-key>/db.zst.age`.
|
||||||
|
- The manifest is uploaded as `metadata/<remote-key>/manifest.json.zst`.
|
||||||
|
|
||||||
|
The copy is made through SQLite, not by copying the file. The index runs in WAL
|
||||||
|
mode, so rows that are already committed can still be in the `-wal` file beside
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
A restore does not use the local index.
|
||||||
|
|
||||||
|
- It downloads and decrypts `db.zst.age` into a temporary directory.
|
||||||
|
- It opens that file read-only. It applies no migration to it.
|
||||||
|
- It reads the files to restore from `files`.
|
||||||
|
- It finds each file's chunks in `file_chunks`.
|
||||||
|
- It finds the blob and offset of each chunk in `blob_chunks`.
|
||||||
|
- It finds the name of each blob in `blobs`.
|
||||||
|
|
||||||
|
It does not read the manifest.
|
||||||
|
|
||||||
|
### Prune
|
||||||
|
|
||||||
|
`prune` changes the local index in two ways:
|
||||||
|
|
||||||
|
- It deletes each snapshot whose manifest is gone from the destination.
|
||||||
|
- It deletes unfinished snapshots, and the rows that no snapshot uses.
|
||||||
|
|
||||||
|
The second step removes unused rows in this order:
|
||||||
|
|
||||||
|
- files that are in no snapshot
|
||||||
|
- blobs that are in no snapshot
|
||||||
|
- `blob_chunks` rows whose chunk is gone
|
||||||
|
- chunks that no file and no blob uses
|
||||||
|
|
||||||
|
`prune` then deletes unused data at the destination. That step reads the
|
||||||
|
manifests, not the local index. It reads the local index only to keep a
|
||||||
|
directory under `metadata/` whose snapshot is still recorded there.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Each repository type in `internal/database` serves one or more tables:
|
||||||
|
|
||||||
|
- `FileRepository`: `files`
|
||||||
|
- `ChunkRepository`: `chunks`
|
||||||
|
- `FileChunkRepository`: `file_chunks`
|
||||||
|
- `ChunkFileRepository`: `chunk_files`
|
||||||
|
- `BlobRepository`: `blobs`
|
||||||
|
- `BlobChunkRepository`: `blob_chunks`
|
||||||
|
- `SnapshotRepository`:
|
||||||
|
- `snapshots`
|
||||||
|
- `snapshot_files`
|
||||||
|
- `snapshot_blobs`
|
||||||
|
- `snapshot_mount_points`
|
||||||
|
- `UploadRepository`: `uploads`
|
||||||
|
- `LocalMetaRepository`: `local_meta`
|
||||||
|
|
||||||
|
No repository type serves `schema_migrations`. The code that applies the
|
||||||
|
migrations reads and writes it.
|
||||||
|
|
||||||
|
`Repositories` holds one of each type.
|
||||||
|
|
||||||
|
`Repositories.WithTx` runs a function in one transaction:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
// Multiple repository operations using tx
|
// several repository calls that use tx
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures consistency, especially important for operations like:
|
The transaction commits when the function returns no error. Otherwise it is
|
||||||
- Creating file-chunk mappings
|
rolled back.
|
||||||
- Associating chunks with blobs
|
|
||||||
- Updating snapshot statistics
|
|
||||||
|
|
||||||
## Performance Considerations
|
## SQLite settings
|
||||||
|
|
||||||
1. **Indexes**:
|
- The index runs in WAL mode. A command that only reads, such as
|
||||||
- Primary keys are automatically indexed
|
`snapshot list`, can read the index while a backup writes to it.
|
||||||
- `idx_files_path` on `files(path)` for efficient file lookups
|
- A connection waits up to 10 seconds for a lock before it fails.
|
||||||
|
- Foreign keys are enforced.
|
||||||
2. **Prepared Statements**: All queries use prepared statements for performance and security
|
- Each open database uses one connection, so its queries run one at a time.
|
||||||
|
- Queries pass their values as parameters.
|
||||||
3. **Batch Operations**: Where possible, operations are batched within transactions
|
|
||||||
|
|
||||||
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
|
|
||||||
|
|
||||||
## Data Integrity
|
|
||||||
|
|
||||||
1. **Foreign Keys**: Enforced through CASCADE DELETE and application-level repository methods
|
|
||||||
2. **Unique Constraints**: Chunk hashes, file paths, and blob hashes are unique
|
|
||||||
3. **Null Handling**: Nullable fields clearly indicate in-progress operations
|
|
||||||
4. **Timestamp Tracking**: All major operations record timestamps for auditing
|
|
||||||
|
|||||||
+361
-122
@@ -1,177 +1,416 @@
|
|||||||
# Vaultik S3 Repository Structure
|
# vaultik destination layout
|
||||||
|
|
||||||
This document describes the structure and organization of data stored in the S3 bucket by Vaultik.
|
This document describes what `vaultik` stores at the destination. It covers the
|
||||||
|
layout, what the layout protects, and the risks it accepts.
|
||||||
|
|
||||||
## Overview
|
The destination is the place the backups are stored. It is an S3 bucket and
|
||||||
|
prefix, a local directory or an rclone remote. The layout is the same on all
|
||||||
|
three.
|
||||||
|
|
||||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
## Terms
|
||||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
|
||||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
|
||||||
|
|
||||||
## Directory Structure
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
||||||
|
- **Snapshot ID**: the name of a snapshot, in the form
|
||||||
|
`<hostname>_<snapshot-name>_<timestamp>`.
|
||||||
|
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
|
||||||
|
content.
|
||||||
|
- **Blob**: one object under `blobs/`. It holds many chunks.
|
||||||
|
- **Local index**: the SQLite database on the backed-up machine.
|
||||||
|
- **Recipient**: an age public key that backups are encrypted to.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
<bucket>/<prefix>/
|
<destination>/
|
||||||
|
├── vaultik-destination.txt
|
||||||
├── blobs/
|
├── blobs/
|
||||||
│ └── <hash[0:2]>/
|
│ └── <hash[0:2]>/
|
||||||
│ └── <hash[2:4]>/
|
│ └── <hash[2:4]>/
|
||||||
│ └── <full-hash>
|
│ └── <hash>
|
||||||
└── metadata/
|
└── metadata/
|
||||||
└── <remote-key>/
|
└── <remote-key>/
|
||||||
├── db.zst.age
|
├── db.zst.age
|
||||||
└── manifest.json.zst
|
└── manifest.json.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
|
The destination holds three things:
|
||||||
|
|
||||||
## Blobs Directory (`blobs/`)
|
- `vaultik-destination.txt`, the marker file
|
||||||
|
- `blobs/`, which holds the file contents
|
||||||
|
- `metadata/`, which holds one directory for each snapshot
|
||||||
|
|
||||||
### Structure
|
## The marker file
|
||||||
- **Path format**: `blobs/<first-2-chars>/<next-2-chars>/<full-hash>`
|
|
||||||
- **Example**: `blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`
|
|
||||||
- **Sharding**: The two-level directory structure (using the first 4 characters of the hash) prevents any single directory from containing too many objects
|
|
||||||
|
|
||||||
### Content
|
Every backup writes `vaultik-destination.txt` before it uploads anything else.
|
||||||
- **What it contains**: Packed collections of content-defined chunks from files
|
The file holds one fixed line of text. It names no host, path or time.
|
||||||
- **Format**: Zstandard compressed, then Age encrypted
|
|
||||||
- **Encryption**: Always encrypted with Age using the configured recipients
|
|
||||||
- **Naming**: Content-addressed. The blob's name is `hex(SHA256(SHA256(uncompressed blob contents)))` — the double SHA-256 of the concatenated chunk data, computed before compression and encryption, not a hash of the stored (compressed, encrypted) bytes. One consequence: only a holder of the age private key can check a stored blob's integrity, because matching a blob to its name means decrypting and decompressing it first — which is what `restore` and `verify --deep` do. Implemented in `internal/blobgen` (`DoubleSHA256`). This is the canonical description of blob naming; other documents and comments point here.
|
|
||||||
|
|
||||||
### Why Encrypted
|
The marker tells an emptied or missing destination apart from a new one. An
|
||||||
Blobs contain the actual file data from backups and must be encrypted for security. The content-addressing ensures deduplication while the encryption ensures privacy.
|
unplugged volume leaves an empty directory at its mount point. Without the
|
||||||
|
marker, that directory would look like a destination whose snapshots are all
|
||||||
|
gone.
|
||||||
|
|
||||||
## Metadata Directory (`metadata/`)
|
The check applies while the local index holds snapshots. When the destination
|
||||||
|
then answers that the marker does not exist, commands treat the destination as
|
||||||
|
not present:
|
||||||
|
|
||||||
Each snapshot has its own subdirectory. The directory is **not** named with the human-readable snapshot ID; it is named with the remote key — a one-way hash of that ID. The human ID is never written to the destination store as a directory name (see [Remote Key Derivation](#remote-key-derivation)).
|
- `snapshot create` refuses to write to it.
|
||||||
|
- `prune`, `snapshot purge` and `remote nuke` fail.
|
||||||
|
- `snapshot list` shows the local index alone.
|
||||||
|
- `snapshot remove` deletes nothing at the destination.
|
||||||
|
|
||||||
### Snapshot ID Format
|
The check is skipped when the look-up fails for any other reason. S3 refuses
|
||||||
|
the look-up to credentials that can only write. A machine with such credentials
|
||||||
|
therefore does not notice an emptied destination. Its next backup would not
|
||||||
|
upload data that its local index says is stored. After a destination is emptied,
|
||||||
|
run `vaultik database delete` on such a machine before it backs up again.
|
||||||
|
|
||||||
The human-readable snapshot ID is used in CLI arguments, log lines, and the local database. It is not written to the destination store.
|
`remote nuke` deletes the marker last.
|
||||||
- **Format**: `<hostname>_<snapshot-name>_<RFC3339>` (or `<hostname>_<RFC3339>` if no
|
|
||||||
name was specified)
|
|
||||||
- **Example**: `laptop_home_2024-01-15T14:30:52Z`
|
|
||||||
- **Components**:
|
|
||||||
- Short hostname (everything before the first dot is stripped from the FQDN)
|
|
||||||
- Snapshot name from the configured `snapshots:` map (optional)
|
|
||||||
- RFC3339 UTC timestamp
|
|
||||||
|
|
||||||
This ID reveals the hostname, the configured snapshot name, and the backup time, so it is never used as the on-disk directory name — the remote key is used instead.
|
## Blobs
|
||||||
|
|
||||||
### Remote Key Derivation
|
A blob holds the content of many chunks, one after another.
|
||||||
|
|
||||||
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot-id)))`: a double SHA-256 over the snapshot ID, with a `vaultik|` domain-separation prefix. The result is a 64-character hex string. The hash is not reversible, but it uses no secret: an observer who guesses a candidate hostname and snapshot name can hash it the same way and confirm whether that snapshot is present. The remote key keeps names out of a plain listing; it does not hide them from a guess. Implemented in `internal/snapshot/remotekey.go`.
|
- **Path**: `blobs/<first 2 characters>/<next 2 characters>/<full hash>`.
|
||||||
|
- **Example**:
|
||||||
|
`blobs/ca/fe/cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678`.
|
||||||
|
- **Format**: the chunk data, compressed with zstd and then encrypted with age.
|
||||||
|
- **Recipients**: the public keys in `age_recipients` when the blob was written.
|
||||||
|
|
||||||
|
The two directory levels keep any one directory from holding too many objects.
|
||||||
|
|
||||||
|
### Blob names
|
||||||
|
|
||||||
|
A blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. The contents are
|
||||||
|
the chunk data, joined in order, before compression and encryption. The name is
|
||||||
|
not a hash of the stored bytes.
|
||||||
|
|
||||||
|
This has two consequences:
|
||||||
|
|
||||||
|
- Checking a blob against its name needs the private key. The blob must be
|
||||||
|
decrypted and decompressed first. `snapshot restore` and
|
||||||
|
`snapshot verify --deep` do this.
|
||||||
|
- Without the key, only a blob's presence and size can be checked.
|
||||||
|
`snapshot verify` does this.
|
||||||
|
|
||||||
|
`blobgen.DoubleSHA256` in `internal/blobgen` computes the name.
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
|
||||||
|
A backup looks a blob up by name before it uploads it. It does not upload a
|
||||||
|
blob that is already there.
|
||||||
|
|
||||||
|
When the destination refuses the look-up, the backup uploads the blob. An
|
||||||
|
object with that name is then replaced by one that holds the same chunk data.
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
Each snapshot has one directory under `metadata/`. The directory is named with
|
||||||
|
the snapshot's remote key, not with its snapshot ID.
|
||||||
|
|
||||||
|
### Snapshot IDs
|
||||||
|
|
||||||
|
A snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An example
|
||||||
|
is `laptop_home_2024-01-15T14:30:52Z`. It has three parts:
|
||||||
|
|
||||||
|
- the hostname, up to its first `.`
|
||||||
|
- the snapshot name from the config
|
||||||
|
- the time the backup started, in UTC
|
||||||
|
|
||||||
|
The snapshot ID is used on the command line, in log records and in the local
|
||||||
|
index. It reveals the hostname, the snapshot name and the backup time. It is
|
||||||
|
therefore never written to the destination without encryption.
|
||||||
|
|
||||||
|
### Remote keys
|
||||||
|
|
||||||
|
The remote key is `hex(SHA256(SHA256("vaultik|" + snapshot ID)))`. It is 64 hex
|
||||||
|
characters long. `internal/snapshot/remotekey.go` computes it.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
Worked example:
|
|
||||||
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
- Snapshot ID: `server1_home_2025-06-01T12:00:00Z`
|
||||||
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
- Remote key: `17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa`
|
||||||
- Directory: `metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
- Directory:
|
||||||
|
`metadata/17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa/`
|
||||||
|
|
||||||
A plain listing of the destination store therefore shows only these hashes, not the hostname or snapshot name of any backup — but because the hash uses no secret, a guessed hostname and snapshot name can be hashed and confirmed against the listing. The same remote key is stored in the manifest's `snapshot_id` field.
|
A listing of the destination therefore shows no hostname and no snapshot name.
|
||||||
|
|
||||||
### Files in Each Snapshot Directory
|
The hash cannot be reversed, but it uses no secret. Someone who guesses a
|
||||||
|
snapshot ID can hash it the same way and see whether that snapshot exists. The
|
||||||
|
remote key keeps names out of a listing. It does not hide them from a guess.
|
||||||
|
|
||||||
#### `db.zst.age` - Encrypted Database
|
### `db.zst.age`
|
||||||
- **What it contains**: Pruned binary SQLite database for this snapshot
|
|
||||||
- **Format**: Binary SQLite → Zstandard compressed → Age encrypted
|
The snapshot's database. A restore needs it.
|
||||||
- **Encryption**: Encrypted with Age
|
|
||||||
- **Purpose**: Contains full file metadata, chunk mappings, and all relationships
|
- **Contents**: a SQLite database. It is a copy of the local index without the
|
||||||
- **Why encrypted**: Contains sensitive metadata like file paths, permissions, and ownership
|
rows of other snapshots. It lists every file in the snapshot with its
|
||||||
|
metadata, its chunks, and the blob that holds each chunk.
|
||||||
|
- **Format**: the database file, compressed with zstd and then encrypted with
|
||||||
|
age.
|
||||||
|
|
||||||
|
It is encrypted because it holds file paths, permissions and owners.
|
||||||
|
[DATAMODEL.md](DATAMODEL.md) describes its tables.
|
||||||
|
|
||||||
|
### `manifest.json.zst`
|
||||||
|
|
||||||
|
The list of the blobs the snapshot uses.
|
||||||
|
|
||||||
|
- **Format**: JSON, compressed with zstd. It is not encrypted.
|
||||||
|
|
||||||
#### `manifest.json.zst` - Unencrypted Blob Manifest
|
|
||||||
- **What it contains**: JSON list of all blob hashes referenced by this snapshot
|
|
||||||
- **Format**: JSON → Zstandard compressed (NOT encrypted)
|
|
||||||
- **Encryption**: NOT encrypted
|
|
||||||
- **Purpose**: Enables pruning operations without requiring decryption keys
|
|
||||||
- **Structure**:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
"snapshot_id": "17f97bcde958748af076b926af59823943db59e80ce7170b40f124dfa28f64aa",
|
||||||
"timestamp": "2025-06-01T12:00:00Z",
|
"timestamp": "2025-06-01T12:04:31Z",
|
||||||
"blob_count": 42,
|
"blob_count": 2,
|
||||||
"total_compressed_size": 1048576,
|
"total_compressed_size": 57344,
|
||||||
"blobs": [
|
"blobs": [
|
||||||
{ "hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 24576 },
|
{
|
||||||
{ "hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678", "compressed_size": 32768 }
|
"hash": "cafebabe1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
]
|
"compressed_size": 24576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hash": "deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||||
|
"compressed_size": 32768
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`snapshot_id` is the remote key (a hash), not the human ID; `timestamp` is written in the clear.
|
|
||||||
|
|
||||||
### Why Manifest is Unencrypted
|
- `snapshot_id`: the remote key, not the snapshot ID.
|
||||||
The manifest must be readable without the private key to enable:
|
- `timestamp`: when the manifest was written, at the end of the backup.
|
||||||
1. **Pruning operations** - Identifying unreferenced blobs for deletion
|
- `blob_count`: the number of blobs.
|
||||||
2. **Storage analysis** - Understanding space usage without decryption
|
- `total_compressed_size`: the total size of those blobs, in bytes.
|
||||||
3. **Verification** - Checking blob existence without decryption
|
- `blobs`: the hash and the size of each blob.
|
||||||
4. **Cross-snapshot deduplication analysis** - Finding shared blobs between snapshots
|
|
||||||
|
|
||||||
The manifest contains the remote key, the backup timestamp, the blob count and total compressed size, and each blob's hash and compressed size. It contains no file names, paths, or other decrypted metadata.
|
The manifest holds no file name and no path.
|
||||||
|
|
||||||
## Security Considerations
|
The manifest is not encrypted so that these commands work without the private
|
||||||
|
key:
|
||||||
|
|
||||||
### What's Encrypted
|
- `prune`, which must know which blobs are still in use
|
||||||
- **All file content** (in blobs)
|
- `snapshot verify`, which checks that each blob is present
|
||||||
- **All file metadata** (paths, permissions, timestamps, ownership in db.zst.age)
|
- `snapshot list` and `remote info`, which report what the destination holds
|
||||||
- **File-to-chunk mappings** (in db.zst.age)
|
|
||||||
|
|
||||||
### What's Not Encrypted
|
## What is protected
|
||||||
- **The remote key** — directory names and the manifest `snapshot_id`, a one-way hash of the snapshot ID (see [Remote Key Derivation](#remote-key-derivation))
|
|
||||||
- **The backup timestamp** (in manifest.json.zst)
|
|
||||||
- **Blob hashes and their compressed sizes** (in manifest.json.zst)
|
|
||||||
- **Blob count and total compressed size per snapshot** (in manifest.json.zst)
|
|
||||||
|
|
||||||
### Privacy Implications
|
These are encrypted:
|
||||||
From the unencrypted data, an observer of the destination store can determine:
|
|
||||||
- **When each backup was taken** — not from the directory name, which is a one-way hash, but from the plaintext `timestamp` field in manifest.json.zst, which is published in the clear
|
|
||||||
- How many blobs each snapshot references, and the total compressed size
|
|
||||||
- The compressed size of each blob, and which blobs are shared between snapshots (deduplication patterns)
|
|
||||||
- **Whether a guessed hostname and snapshot name are present** — the remote key is an unkeyed hash, so an observer holding candidate names can hash each one and match it against the directory listing. The human ID is never published, so it cannot be read off directly, but it can be confirmed by guessing.
|
|
||||||
|
|
||||||
Together these give an observer a timing-and-size profile of every snapshot. This is an accepted, documented property of the format, not a defect: the manifest is unencrypted so that pruning can run without the private key, and the timing channel could not be closed by encrypting it anyway — object creation times and per-object sizes stay visible at the storage layer on both `s3://` and `file://` destinations regardless.
|
- file contents, in the blobs
|
||||||
|
- in `db.zst.age`:
|
||||||
|
- file names and paths
|
||||||
|
- permissions and owners
|
||||||
|
- modification times
|
||||||
|
- which chunks make up which file
|
||||||
|
|
||||||
An observer cannot determine:
|
These are not encrypted:
|
||||||
- The hostname or snapshot name of any backup by reading it off the store — the directory name and the manifest `snapshot_id` are unkeyed hashes of the human ID, so the text is never published (though a guessed name can be confirmed, as above)
|
|
||||||
- File names or paths
|
|
||||||
- File contents
|
|
||||||
- File permissions or ownership
|
|
||||||
- Directory structure
|
|
||||||
- Which chunks belong to which files
|
|
||||||
|
|
||||||
### Accepted Risks
|
- the remote key of each snapshot
|
||||||
|
- the time each backup finished, in the manifest
|
||||||
|
- the hash and the compressed size of each blob, in the manifest
|
||||||
|
- the number of blobs and their total size for each snapshot, in the manifest
|
||||||
|
|
||||||
These are known, deliberate properties of the format and the tooling, recorded so an operator can weigh them rather than discover them.
|
Someone who can read the destination therefore learns these:
|
||||||
|
|
||||||
1. **No proof of authorship.** Restore and `verify --deep` prove that data decrypts with the age private key and matches its unkeyed content hashes. They do not prove who wrote it: anyone who knows a recipient public key and can replace objects on the destination can substitute a snapshot they built. The recipient string is not stored at the destination, but a compromised backed-up host has it. Defences live on the destination side — bucket versioning or object lock, credentials for the source host that cannot delete or overwrite existing versions, and pruning from a trusted host. Note that S3 `PutObject` overwrites an existing key, so PUT permission alone is not append-only.
|
- when each backup finished
|
||||||
2. **Compression reveals sizes.** Blobs and `db.zst.age` are zstd-compressed then age-encrypted; the manifest is compressed only. age does not pad, so an object's size is the exact compressed length of its contents. All new chunks packed into one blob share a single zstd stream (8 MiB window, 4 MiB at compression levels 1-2), and a blob is closed at `blob_size_limit` and at the end of each configured path. Because a stored chunk is never packed again, someone who can write into a backed-up file and watch blob sizes learns something only when their controlled data and a secret land in the same chunk of a file that keeps changing. Advice: back up any outsider-writable directory as its own snapshot.
|
- how many blobs each snapshot uses, and how large they are
|
||||||
3. **Chunking uses no secret.** The FastCDC parameters are fixed and public. The default 10 MB average yields chunks between 2.5 MB and 40 MB, and any file of 2.5 MB or less is a single chunk. At the default 10 GB `blob_size_limit` a blob holds hundreds of chunks, so individual chunk lengths are not visible in the blob's size; lowering the limit toward the chunk size begins to expose them.
|
- which blobs two snapshots share
|
||||||
4. **Decrypted data on local disk.** Several commands stage plaintext under `$TMPDIR`: `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at `vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at `vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600` and removed on success, but a `kill -9` or a power loss leaves them behind — delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as the restore target.
|
- whether a snapshot ID they guess exists
|
||||||
5. **Store permissions differ per command.** The backed-up host needs only PUT to run `snapshot create`: it writes blobs and metadata and neither reads nor deletes them. Other commands need more — `snapshot verify`, `snapshot restore`, and `prune` list and read; `prune`, `snapshot purge`, `snapshot remove`, and `remote nuke` also delete. The recommended cron line uses `--prune`, which runs `prune` on the backed-up host, so granting that host `--prune` gives it credentials that can delete its own backups. To keep the source host to PUT only, prune from a separate trusted host instead.
|
|
||||||
6. **Changing recipients does not re-encrypt existing data.** Deduplicated chunks and same-named blobs already on the destination stay encrypted to the recipients in force when they were written. A new snapshot that reuses them cannot be restored with a newly added recipient's key alone, because those reused objects were never encrypted to it. To make everything readable by a new key, run `vaultik database delete` and take a full backup to a fresh destination or prefix.
|
|
||||||
7. **X25519 recipients only.** vaultik rejects age ssh and plugin recipients. Long-lived ciphertext held by a third party (the destination operator) has no fallback if X25519 is ever broken: there is no second recipient type and no post-quantum option.
|
|
||||||
|
|
||||||
## Consistency Guarantees
|
They do not learn these:
|
||||||
|
|
||||||
1. **Blobs are immutable** - Once written, a blob is never modified
|
- any hostname or snapshot name, unless they guess it
|
||||||
2. **Blobs are written before metadata** - A snapshot's metadata is only written after all its blobs are successfully uploaded
|
- file names or paths
|
||||||
3. **Metadata is written atomically** - Both db.zst.age and manifest.json.zst are written as complete files
|
- file contents
|
||||||
4. **A snapshot is marked complete in the local DB only after its metadata is uploaded** - `finalizeSnapshotMetadata` runs `ExportSnapshotMetadata` first and records completion (`MarkSnapshotComplete`) only once the export succeeds (see the backup data flow in [ARCHITECTURE.md](../ARCHITECTURE.md)). A crash during the export therefore leaves the snapshot incomplete, so the next backup's `PruneDatabase` drops it and re-backs-up its data, rather than leaving a completed-looking row in the local index with no matching metadata on the destination store. (A crash in the brief moment after the export succeeds but before completion is recorded leaves a fully-restorable snapshot on the destination that the local index drops as incomplete on the next run; `snapshot list` then reports it honestly as remote-only, which is the safe direction: the destination copy stays restorable.)
|
- permissions or owners
|
||||||
|
- the directory structure
|
||||||
|
- which chunks belong to which file
|
||||||
|
|
||||||
## Pruning Safety
|
The times and sizes could not be hidden by encrypting the manifest. The
|
||||||
|
destination itself shows when each object was written and how large it is.
|
||||||
|
|
||||||
The prune operation is safe because:
|
## Accepted risks
|
||||||
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
|
|
||||||
2. Manifests are unencrypted and can be read without keys
|
|
||||||
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
|
|
||||||
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
|
|
||||||
|
|
||||||
## Restoration Requirements
|
These are known properties of the format. They are written down so that you can
|
||||||
|
weigh them.
|
||||||
|
|
||||||
To restore from a backup, you need:
|
### No proof of who wrote the data
|
||||||
1. **The Age private key** - To decrypt blobs and database
|
|
||||||
2. **The snapshot metadata** - Both files from the snapshot's metadata directory
|
|
||||||
3. **All referenced blobs** - As listed in the manifest
|
|
||||||
|
|
||||||
The restoration process:
|
A restore and `snapshot verify --deep` prove two things. The data decrypts with
|
||||||
1. Download and decrypt the database dump to understand file structure
|
your private key, and it matches its hashes. They do not prove who wrote it.
|
||||||
2. Download and decrypt the required blobs
|
|
||||||
3. Reconstruct files from their chunks
|
The hashes use no secret. Someone who knows a recipient's public key and can
|
||||||
4. Restore file metadata (permissions, timestamps, etc.)
|
replace objects at the destination can put a snapshot of their own there. The
|
||||||
|
public key is not stored at the destination, but a backed-up machine that is
|
||||||
|
broken into has it.
|
||||||
|
|
||||||
|
The defences are on the destination's side:
|
||||||
|
|
||||||
|
- object versioning or object lock on the bucket
|
||||||
|
- credentials for the backed-up machine that cannot delete or overwrite
|
||||||
|
- running `prune` from a trusted machine
|
||||||
|
|
||||||
|
An S3 `PutObject` replaces an existing object. Permission to write alone
|
||||||
|
therefore does not stop overwrites.
|
||||||
|
|
||||||
|
### Sizes are visible
|
||||||
|
|
||||||
|
An object's size follows exactly from the size of its compressed contents.
|
||||||
|
|
||||||
|
All the new chunks in one blob share one zstd stream. The stream looks back 8
|
||||||
|
MiB, or 4 MiB at compression levels 1 and 2. A blob is closed at
|
||||||
|
`blob_size_limit` and at the end of each configured path. A chunk that is
|
||||||
|
already stored is never packed again.
|
||||||
|
|
||||||
|
Data that an outsider writes compresses smaller when it matches a secret in the
|
||||||
|
same chunk, so the blob gets smaller. The blob's size therefore shows the
|
||||||
|
outsider whether their data matched the secret.
|
||||||
|
|
||||||
|
This works only when all of these hold:
|
||||||
|
|
||||||
|
- The outsider can write into a backed-up file.
|
||||||
|
- The outsider can see the blob sizes at the destination.
|
||||||
|
- The outsider's data and the secret land in the same chunk.
|
||||||
|
- The file keeps changing.
|
||||||
|
|
||||||
|
Back up a directory that outsiders can write to as a snapshot of its own.
|
||||||
|
|
||||||
|
### Chunk boundaries use no secret
|
||||||
|
|
||||||
|
The FastCDC parameters are fixed and public. `chunk_size` sets the average chunk
|
||||||
|
size. No chunk is larger than four times this size. Only a file's last chunk can
|
||||||
|
be smaller than a quarter of it. With the default of 10 MB, those limits are 40
|
||||||
|
MB and 2.5 MB. A file of 2.5 MB or less is one chunk.
|
||||||
|
|
||||||
|
At the default `blob_size_limit` of 10 GB, a blob holds hundreds of chunks. The
|
||||||
|
size of the blob then does not show the size of any one chunk. A limit close to
|
||||||
|
`chunk_size` begins to show chunk sizes.
|
||||||
|
|
||||||
|
### Decrypted data on the local disk
|
||||||
|
|
||||||
|
These commands write unencrypted data to the temporary directory:
|
||||||
|
|
||||||
|
- `snapshot restore` writes decrypted blobs under `vaultik-blobcache-*/`. There
|
||||||
|
is no limit on their total size.
|
||||||
|
- `snapshot restore` writes the decrypted database to
|
||||||
|
`vaultik-restore-*/snapshot.db`.
|
||||||
|
- `snapshot verify --deep` writes the decrypted database to
|
||||||
|
`vaultik-verify-*/snapshot.db`.
|
||||||
|
- `snapshot create` writes a copy of the local index to
|
||||||
|
`vaultik-snapshot-*/snapshot.db`.
|
||||||
|
|
||||||
|
These files are readable only by their owner, and are removed when the command
|
||||||
|
ends. A `kill -9` or a power loss leaves them behind. Delete any leftover
|
||||||
|
`vaultik-*` entry in the temporary directory yourself.
|
||||||
|
|
||||||
|
Trust the temporary directory as much as the directory you restore into.
|
||||||
|
|
||||||
|
### Commands need different permissions
|
||||||
|
|
||||||
|
`snapshot create` needs only permission to write objects. Other commands need
|
||||||
|
more:
|
||||||
|
|
||||||
|
- `snapshot list`, `snapshot verify`, `snapshot restore` and `remote info` list
|
||||||
|
and read.
|
||||||
|
- `snapshot purge`, `snapshot remove` and `remote nuke` list and delete.
|
||||||
|
- `prune` lists, reads and deletes.
|
||||||
|
|
||||||
|
`snapshot create --prune` runs a prune on the backed-up machine. That machine
|
||||||
|
then holds credentials that can delete its own backups. To keep it to
|
||||||
|
write-only credentials, run `prune` from a separate, trusted machine.
|
||||||
|
|
||||||
|
### New recipients cannot read old data
|
||||||
|
|
||||||
|
Changing `age_recipients` does not re-encrypt what is already stored. A chunk
|
||||||
|
that is already stored is not written again, so it stays encrypted to the
|
||||||
|
earlier recipients.
|
||||||
|
|
||||||
|
A new snapshot that uses such chunks cannot be restored with a new recipient's
|
||||||
|
key alone. To make everything readable by a new key, run
|
||||||
|
`vaultik database delete` and take a full backup to a new destination.
|
||||||
|
|
||||||
|
### X25519 recipients only
|
||||||
|
|
||||||
|
`vaultik` accepts age X25519 public keys only. It rejects age SSH and plugin
|
||||||
|
recipients.
|
||||||
|
|
||||||
|
The encrypted data is held by a third party for a long time. There is no second
|
||||||
|
kind of recipient to fall back on if X25519 is broken, and no post-quantum
|
||||||
|
option.
|
||||||
|
|
||||||
|
## Order of writes
|
||||||
|
|
||||||
|
A backup writes in a fixed order:
|
||||||
|
|
||||||
|
- the marker file
|
||||||
|
- the blobs
|
||||||
|
- `db.zst.age`
|
||||||
|
- `manifest.json.zst`
|
||||||
|
|
||||||
|
A snapshot's metadata is written only after all its blobs are uploaded. The
|
||||||
|
manifest is written last. A directory under `metadata/` that has no manifest
|
||||||
|
therefore belongs to a backup that did not finish.
|
||||||
|
|
||||||
|
The local index marks a snapshot complete only after both metadata files are
|
||||||
|
uploaded. A backup that stops during that upload leaves the snapshot
|
||||||
|
unfinished. The next backup removes it from the local index and stores its data
|
||||||
|
again.
|
||||||
|
|
||||||
|
One short gap remains. A backup can stop after the upload and before the
|
||||||
|
snapshot is marked complete. The destination then holds a snapshot that can be
|
||||||
|
restored, and the local index does not list it. `snapshot list` shows it as
|
||||||
|
remote only.
|
||||||
|
|
||||||
|
An upload that is cut off leaves no partial object under the final name on S3
|
||||||
|
or on a local directory. The same holds for an rclone remote that can move
|
||||||
|
objects. An rclone remote that cannot move objects can keep a partial object.
|
||||||
|
|
||||||
|
## Pruning
|
||||||
|
|
||||||
|
`prune` deletes two kinds of object at the destination:
|
||||||
|
|
||||||
|
- a blob that no manifest lists
|
||||||
|
- a directory under `metadata/` that has no manifest
|
||||||
|
|
||||||
|
It reads the manifests, which are not encrypted, so it needs no private key.
|
||||||
|
|
||||||
|
These rules keep it from deleting data that is in use:
|
||||||
|
|
||||||
|
- It keeps every blob that any manifest lists.
|
||||||
|
- It deletes nothing when any manifest cannot be downloaded or decoded.
|
||||||
|
- It deletes a blob only once the blob is older than `prune_grace_period`.
|
||||||
|
- It deletes a directory only once every object in it is that old.
|
||||||
|
- It keeps a directory whose snapshot is in the local index.
|
||||||
|
- It leaves alone a directory whose name is not a remote key.
|
||||||
|
|
||||||
|
`prune_grace_period` defaults to `24h`. It protects a backup that is still
|
||||||
|
running. Such a backup has uploaded blobs and perhaps `db.zst.age`, but not yet
|
||||||
|
its manifest. A prune from this machine or another keeps those objects, as long
|
||||||
|
as no backup takes longer than the grace period.
|
||||||
|
|
||||||
|
The grace period does not protect one case. A backup does not upload a blob
|
||||||
|
that is already at the destination, so that blob keeps the time of its first
|
||||||
|
upload. The blob can be left over from an earlier backup that stopped before
|
||||||
|
its manifest. A prune that runs before the new manifest is written can then
|
||||||
|
delete that blob. The new snapshot cannot be restored afterwards.
|
||||||
|
|
||||||
|
To avoid this case, give each machine and each user its own destination.
|
||||||
|
|
||||||
|
`remote nuke` applies none of these rules.
|
||||||
|
|
||||||
|
## What a restore needs
|
||||||
|
|
||||||
|
- the age private key
|
||||||
|
- the snapshot's `db.zst.age`
|
||||||
|
- the blobs that hold the files to restore
|
||||||
|
|
||||||
|
A restore does not read the manifest and does not need the local index.
|
||||||
|
|
||||||
|
It works in these steps:
|
||||||
|
|
||||||
|
- It downloads and decrypts `db.zst.age`.
|
||||||
|
- It checks that the database is the one for the snapshot that was asked for.
|
||||||
|
- It downloads and decrypts the blobs it needs. It checks each blob against its
|
||||||
|
name.
|
||||||
|
- It writes each file from its chunks.
|
||||||
|
- It sets each file's permissions and times, and its owner when it runs as
|
||||||
|
root.
|
||||||
|
|||||||
+27
-6
@@ -239,6 +239,18 @@ storage_url: ""
|
|||||||
# Default: 3
|
# Default: 3
|
||||||
# compression_level: 3
|
# compression_level: 3
|
||||||
|
|
||||||
|
# How old a blob that no snapshot lists must be before prune deletes it,
|
||||||
|
# and how old every object in a directory under metadata/ with no manifest
|
||||||
|
# must be before prune deletes that directory. A backup uploads its blobs
|
||||||
|
# and its db.zst.age before its manifest, so on a destination shared
|
||||||
|
# between hosts or users this must be longer than the longest backup run.
|
||||||
|
# It does not protect a blob a backup reuses from an earlier run that
|
||||||
|
# stopped before its manifest: that blob keeps the time of its first
|
||||||
|
# upload, so a prune can delete it.
|
||||||
|
# Accepts: 36h, 90m, etc. 0s deletes such blobs and directories at once.
|
||||||
|
# Default: 24h
|
||||||
|
# prune_grace_period: 24h
|
||||||
|
|
||||||
# Hostname used in snapshot IDs. Default: system hostname.
|
# Hostname used in snapshot IDs. Default: system hostname.
|
||||||
# hostname: myserver
|
# hostname: myserver
|
||||||
|
|
||||||
@@ -277,12 +289,14 @@ func newConfigInitCommand() *cobra.Command {
|
|||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Write a default config file",
|
Short: "Write a default config file",
|
||||||
Long: `Creates a default configuration file with commented explanations
|
Long: `Creates a default configuration file with commented explanations
|
||||||
for every setting. If a config file already exists at the target path,
|
for the settings it lists. If a config file already exists at the target
|
||||||
the command refuses to overwrite it.
|
path, the command refuses to overwrite it. The file is created with mode
|
||||||
|
0600, because it can hold credentials.
|
||||||
|
|
||||||
The config is written to the path from --config, $VAULTIK_CONFIG, or
|
The config is written to the path from --config, $VAULTIK_CONFIG, or
|
||||||
the platform default config directory (e.g. ~/Library/Application Support/
|
config.yml in the platform default config directory
|
||||||
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
(~/Library/Application Support/vaultik/ on macOS, ~/.config/vaultik/ on
|
||||||
|
Linux, /etc/vaultik/ as root).`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
path := configPathForInit()
|
path := configPathForInit()
|
||||||
@@ -320,7 +334,7 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|||||||
func newConfigEditCommand() *cobra.Command {
|
func newConfigEditCommand() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "edit",
|
Use: "edit",
|
||||||
Short: "Open the config file in $EDITOR",
|
Short: "Open the config file in $EDITOR (vi when it is not set)",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
path, err := ResolveConfigPath()
|
path, err := ResolveConfigPath()
|
||||||
@@ -333,8 +347,15 @@ func newConfigEditCommand() *cobra.Command {
|
|||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run $EDITOR through the shell, as git does, so a value
|
||||||
|
// with arguments such as "code --wait" works. The path
|
||||||
|
// goes in as an argument ("$@"), not into the command
|
||||||
|
// text, so a path with spaces or quotes arrives whole.
|
||||||
|
// sh -c takes the word after the command as $0, which
|
||||||
|
// is why editor appears twice.
|
||||||
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||||
ed := exec.CommandContext(cmd.Context(), editor, path)
|
ed := exec.CommandContext(cmd.Context(),
|
||||||
|
"sh", "-c", editor+` "$@"`, editor, path)
|
||||||
ed.Stdin = os.Stdin
|
ed.Stdin = os.Stdin
|
||||||
ed.Stdout = os.Stdout
|
ed.Stdout = os.Stdout
|
||||||
ed.Stderr = os.Stderr
|
ed.Stderr = os.Stderr
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
|
|||||||
// testRecipient is a valid age public key.
|
// testRecipient is a valid age public key.
|
||||||
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
||||||
|
|
||||||
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
// TestConfigSetRecipientOnFreshConfig follows the README's getting started
|
||||||
// file `config init` writes, `config set age_recipients.0` and
|
// steps: on the file `config init` writes, `config set age_recipients.0` and
|
||||||
// `config set storage_url` give a config that loads with that recipient.
|
// `config set storage_url` give a config that loads with that recipient.
|
||||||
// Neither is refused, although the config does not load until both are set;
|
// Neither is refused, although the config does not load until both are set;
|
||||||
// once it loads, a recipient that is not a valid key is.
|
// once it loads, a recipient that is not a valid key is.
|
||||||
@@ -647,6 +647,30 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an
|
||||||
|
// argument works and that a config path with a space reaches it whole.
|
||||||
|
// The "editor" is cp with the replacement file as its argument, so the
|
||||||
|
// config ends up with the replacement's content only if both arrived.
|
||||||
|
func TestConfigEditEditorWithArgument(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
|
||||||
|
edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n")
|
||||||
|
|
||||||
|
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||||
|
t.Setenv("EDITOR", "cp '"+edited+"'")
|
||||||
|
|
||||||
|
mustExecute(t, newConfigEditCommand())
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading config back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "compression_level: 9\n" {
|
||||||
|
t.Errorf("config after edit = %q, want the edited content", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func splitPath(s string) []string {
|
func splitPath(s string) []string {
|
||||||
return strings.Split(s, ".")
|
return strings.Split(s, ".")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
|
|||||||
// writeHermeticPruneConfig builds a config over a temp directory with an
|
// writeHermeticPruneConfig builds a config over a temp directory with an
|
||||||
// empty destination directory and, if seedStale is set, creates the
|
// empty destination directory and, if seedStale is set, creates the
|
||||||
// index database up front with one snapshot record that has no
|
// index database up front with one snapshot record that has no
|
||||||
// counterpart on the destination store. Returns the config path.
|
// counterpart on the destination store, and gives the destination the
|
||||||
|
// marker a backup writes. Returns the config path.
|
||||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -131,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
|||||||
|
|
||||||
if seedStale {
|
if seedStale {
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, prune fails on a
|
||||||
|
// destination that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||||
}
|
}
|
||||||
|
|
||||||
return configPath
|
return configPath
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -14,8 +15,8 @@ import (
|
|||||||
const flagCron = "--cron"
|
const flagCron = "--cron"
|
||||||
|
|
||||||
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
||||||
// destination store at storeDir, so that a prune deletes it. Returns its
|
// destination store at storeDir, dated long before any prune grace period,
|
||||||
// path.
|
// so that a prune deletes it. Returns its path.
|
||||||
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ func plantUnreferencedBlob(t *testing.T, storeDir string) string {
|
|||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
|
||||||
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
|
||||||
|
|
||||||
|
longAgo := time.Unix(0, 0)
|
||||||
|
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
|
||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flags of `snapshot create` used to build argument vectors below.
|
// Flags of `snapshot create` used to build argument vectors below.
|
||||||
@@ -87,6 +89,81 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
|
|||||||
"the new snapshot is on the store beside the planted one")
|
"the new snapshot is on the store beside the planted one")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
|
||||||
|
// empties the source directory, as a volume that is not mounted leaves
|
||||||
|
// its mount point, and runs `snapshot create --prune`. The command exits
|
||||||
|
// non-zero and reports the backup as completed, not as a failed snapshot
|
||||||
|
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
// writeBackupConfig puts the source directory beside the config file.
|
||||||
|
require.NoError(t, os.Remove(
|
||||||
|
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 source path(s) were empty")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
|
||||||
|
// up, records in the local index that this backup found the source
|
||||||
|
// directory as a mount point, and runs `snapshot create --prune`, which
|
||||||
|
// does not find it as one. The command exits non-zero, reports the
|
||||||
|
// backup as completed, not as a failed snapshot creation, and keeps the
|
||||||
|
// older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
configPath, storeDir := writeBackupConfig(t)
|
||||||
|
|
||||||
|
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
|
||||||
|
// writeBackupConfig puts the source directory and the local index
|
||||||
|
// beside the config file.
|
||||||
|
dir := filepath.Dir(configPath)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
|
||||||
|
backedUp.ID.String(), filepath.Join(dir, "source")))
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
code, _, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
|
||||||
|
"an earlier snapshot recorded were not found")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
|
||||||
|
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the older snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
// writeBackupConfig builds a hermetic config with one small file in its
|
// writeBackupConfig builds a hermetic config with one small file in its
|
||||||
// source directory and an empty destination store directory. Returns the
|
// source directory and an empty destination store directory. Returns the
|
||||||
// config path and the store directory.
|
// config path and the store directory.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
||||||
|
// under --skip-errors, a source directory that does not exist, which the
|
||||||
|
// walk skips. The command exits non-zero and reports the backup as
|
||||||
|
// completed, not as a failed snapshot creation. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||||
|
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
||||||
|
// writeBackupConfig would create the source directory.
|
||||||
|
configPath := writeHermeticPruneConfig(t, false)
|
||||||
|
|
||||||
|
code, stdout, stderr := runEntry(t,
|
||||||
|
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stdout,
|
||||||
|
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, stdout, "Finished successfully.")
|
||||||
|
assert.Contains(t, stderr,
|
||||||
|
"Backup completed, but 1 file(s) could not be read")
|
||||||
|
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||||
|
}
|
||||||
@@ -41,6 +41,11 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
|||||||
// too, so the purge selects it rather than dropping it as stale.
|
// too, so the purge selects it rather than dropping it as stale.
|
||||||
seedStaleSnapshotRecord(t, indexPath)
|
seedStaleSnapshotRecord(t, indexPath)
|
||||||
|
|
||||||
|
// While the local index holds a snapshot, purge fails on a
|
||||||
|
// destination that answers that it has no marker.
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
|
||||||
|
|
||||||
manifestDir := filepath.Join(dir, "store", "metadata",
|
manifestDir := filepath.Join(dir, "store", "metadata",
|
||||||
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
|
||||||
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
|
||||||
@@ -59,10 +64,12 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
code, _, _ := runEntry(t, flagConfig, configPath,
|
code, _, stderr := runEntry(t, flagConfig, configPath,
|
||||||
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
|
||||||
|
|
||||||
assert.Equal(t, 1, code)
|
assert.Equal(t, 1, code)
|
||||||
|
assert.Contains(t, stderr, "simulated index error",
|
||||||
|
"the purge must fail at the local-index delete")
|
||||||
assert.FileExists(t, manifestPath,
|
assert.FileExists(t, manifestPath,
|
||||||
"the snapshot is still in the local index, so its metadata must stay")
|
"the snapshot is still in the local index, so its metadata must stay")
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-4
@@ -13,14 +13,28 @@ func NewPruneCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "prune",
|
Use: "prune",
|
||||||
Short: "Tidy local database and remote storage",
|
Short: "Tidy local database and remote storage",
|
||||||
Long: `Removes orphaned data from both the local index database and
|
Long: `Removes orphaned data from the local index database, and
|
||||||
unreferenced blobs from the backup destination store.
|
unreferenced blobs and directories under metadata/ with no manifest
|
||||||
|
from the backup destination store.
|
||||||
|
|
||||||
Local cleanup drops incomplete snapshots and any files, chunks, or
|
Needs permission to list, read and delete objects on the destination
|
||||||
|
store. It does not need the age private key.
|
||||||
|
|
||||||
|
Local cleanup drops snapshots whose manifest is gone from the
|
||||||
|
destination store, incomplete snapshots, and any files, chunks, or
|
||||||
blobs no longer referenced by a completed snapshot. Remote cleanup
|
blobs no longer referenced by a completed snapshot. Remote cleanup
|
||||||
scans every snapshot manifest in the destination store, builds the
|
scans every snapshot manifest in the destination store, builds the
|
||||||
set of still-referenced blob hashes, and deletes any blob not in that
|
set of still-referenced blob hashes, and deletes any blob not in that
|
||||||
set.
|
set that is older than prune_grace_period (default 24h). A newer one
|
||||||
|
is kept: it may belong to a backup still running, on this host or
|
||||||
|
another sharing the destination store, that has not yet uploaded its
|
||||||
|
manifest. The grace period does not protect a blob such a backup
|
||||||
|
reuses from an earlier run that stopped before its manifest: that
|
||||||
|
blob keeps the time of its first upload, so it can be deleted.
|
||||||
|
Remote cleanup also deletes each directory under metadata/ with no
|
||||||
|
manifest, as a backup that stopped before its manifest leaves, once
|
||||||
|
all its objects are older than prune_grace_period and its snapshot is
|
||||||
|
not in the local index.
|
||||||
|
|
||||||
Snapshot create --prune runs the same cleanup automatically; this
|
Snapshot create --prune runs the same cleanup automatically; this
|
||||||
command is the manual entry point for the same work (e.g. after a
|
command is the manual entry point for the same work (e.g. after a
|
||||||
|
|||||||
+10
-5
@@ -40,11 +40,13 @@ func newRemoteNukeCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "nuke",
|
Use: "nuke",
|
||||||
Short: "Delete ALL snapshot metadata and blobs from the backup destination store",
|
Short: "Delete ALL snapshot metadata and blobs from the backup destination store",
|
||||||
Long: `Removes every snapshot's metadata and every blob from remote
|
Long: `Removes every snapshot's metadata, every blob and the destination
|
||||||
storage. After this command completes successfully the bucket prefix is
|
marker from the backup destination store, and each removed snapshot from
|
||||||
empty and the next backup starts from scratch.
|
the local index. After this command completes successfully the bucket
|
||||||
|
prefix is empty and the next backup starts from scratch.
|
||||||
|
|
||||||
This is destructive and irreversible. Requires --force.`,
|
This is destructive and irreversible. Requires --force, and permission
|
||||||
|
to list and delete objects on the destination store.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
if !force {
|
if !force {
|
||||||
@@ -75,7 +77,10 @@ func newRemoteInfoCommand() *cobra.Command {
|
|||||||
- Size of all snapshot metadata (per snapshot and total)
|
- Size of all snapshot metadata (per snapshot and total)
|
||||||
- Count and total size of all blobs
|
- Count and total size of all blobs
|
||||||
- Count and size of referenced blobs (from all manifests)
|
- Count and size of referenced blobs (from all manifests)
|
||||||
- Count and size of orphaned blobs (not referenced by any manifest)`,
|
- Count and size of orphaned blobs (not referenced by any manifest)
|
||||||
|
|
||||||
|
Needs permission to list and read the destination store. It does not
|
||||||
|
need the age private key.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
// Use unified config resolution
|
// Use unified config resolution
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "vaultik",
|
Use: "vaultik",
|
||||||
Short: "Secure incremental backup tool with asymmetric encryption",
|
Short: "Secure incremental backup tool with asymmetric encryption",
|
||||||
Long: `vaultik is a secure incremental backup tool that encrypts data using age
|
Long: `vaultik is an incremental backup tool that encrypts data to age public
|
||||||
public keys and uploads to S3-compatible storage. No private keys are needed
|
keys and uploads it to an S3-compatible object store, a local directory or
|
||||||
on the source system.`,
|
an rclone remote. The backed-up machine needs no private key: only
|
||||||
|
'snapshot restore' and 'snapshot verify --deep' need one.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
// Bare 'vaultik' (no subcommand): print help. The banner is
|
// Bare 'vaultik' (no subcommand): print help. The banner is
|
||||||
// printed once at process startup by Entry, before cobra
|
// printed once at process startup by Entry, before cobra
|
||||||
@@ -50,13 +51,15 @@ on the source system.`,
|
|||||||
|
|
||||||
// Add global flags
|
// Add global flags
|
||||||
cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "",
|
cmd.PersistentFlags().StringVar(&rootFlags.ConfigPath, "config", "",
|
||||||
"Path to config file (default: $VAULTIK_CONFIG or platform config dir)")
|
"Path to config file (default: $VAULTIK_CONFIG, then the platform "+
|
||||||
|
"config directory, then /etc/vaultik/config.yml)")
|
||||||
cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false,
|
cmd.PersistentFlags().BoolVarP(&rootFlags.Verbose, "verbose", "v", false,
|
||||||
"Enable verbose output")
|
"Enable verbose output")
|
||||||
cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false,
|
cmd.PersistentFlags().BoolVar(&rootFlags.Debug, "debug", false,
|
||||||
"Enable debug output")
|
"Enable debug output")
|
||||||
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
|
cmd.PersistentFlags().BoolVarP(&rootFlags.Quiet, "quiet", "q", false,
|
||||||
"Suppress non-error output")
|
"Suppress status output (warnings, errors, JSON documents, "+
|
||||||
|
"'config get' values and confirmation prompts are still printed)")
|
||||||
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
cmd.PersistentFlags().BoolVar(&rootFlags.SkipErrors, "skip-errors", false,
|
||||||
"Skip files that cannot be read when creating a snapshot, or "+
|
"Skip files that cannot be read when creating a snapshot, or "+
|
||||||
"that cannot be restored when restoring, instead of aborting "+
|
"that cannot be restored when restoring, instead of aborting "+
|
||||||
|
|||||||
+85
-28
@@ -60,6 +60,21 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotCreateLong is the long help text of 'snapshot create'.
|
||||||
|
const snapshotCreateLong = `Creates new snapshots of the configured directories.
|
||||||
|
|
||||||
|
If snapshot names are provided, only those snapshots are created.
|
||||||
|
If no names are provided, all configured snapshots are created.
|
||||||
|
|
||||||
|
Needs at least one public key in age_recipients, and permission to write
|
||||||
|
objects at the backup destination store. It does not need the age private
|
||||||
|
key. With --prune it also needs permission to list, read and delete objects
|
||||||
|
there.
|
||||||
|
|
||||||
|
The config is read from the path given by --config or VAULTIK_CONFIG;
|
||||||
|
otherwise from the platform config directory (~/.config/vaultik/config.yml
|
||||||
|
on Linux), then /etc/vaultik/config.yml.`
|
||||||
|
|
||||||
// newSnapshotCreateCommand creates the 'snapshot create' subcommand
|
// newSnapshotCreateCommand creates the 'snapshot create' subcommand
|
||||||
func newSnapshotCreateCommand() *cobra.Command {
|
func newSnapshotCreateCommand() *cobra.Command {
|
||||||
opts := &vaultik.SnapshotCreateOptions{}
|
opts := &vaultik.SnapshotCreateOptions{}
|
||||||
@@ -67,15 +82,8 @@ func newSnapshotCreateCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "create [snapshot-names...]",
|
Use: "create [snapshot-names...]",
|
||||||
Short: "Create new snapshots",
|
Short: "Create new snapshots",
|
||||||
Long: `Creates new snapshots of the configured directories.
|
Long: snapshotCreateLong,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
If snapshot names are provided, only those snapshots are created.
|
|
||||||
If no names are provided, all configured snapshots are created.
|
|
||||||
|
|
||||||
The config is read from the path given by --config or VAULTIK_CONFIG;
|
|
||||||
otherwise from the platform config directory (~/.config/vaultik/config.yml
|
|
||||||
on Linux), then /etc/vaultik/config.yml.`,
|
|
||||||
Args: cobra.ArbitraryArgs,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
err := opts.Validate()
|
err := opts.Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,6 +123,15 @@ on Linux), then /etc/vaultik/config.yml.`,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
|
||||||
|
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
|
||||||
|
errors.Is(err, vaultik.ErrVolumeNotMounted) {
|
||||||
|
log.Error("Backup completed with an error", "error", err)
|
||||||
|
ReportErrorf("Backup completed, but %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
ReportErrorf("Snapshot creation failed: %v", err)
|
ReportErrorf("Snapshot creation failed: %v", err)
|
||||||
})
|
})
|
||||||
@@ -124,8 +141,13 @@ on Linux), then /etc/vaultik/config.yml.`,
|
|||||||
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
|
||||||
"Run in cron mode (silent unless warning or error)")
|
"Run in cron mode (silent unless warning or error)")
|
||||||
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
|
||||||
"After backup, drop older snapshots of the same name and remove "+
|
"After backup, drop older snapshots of the same name, remove "+
|
||||||
"orphaned blobs")
|
"orphaned blobs older than prune_grace_period, and delete each "+
|
||||||
|
"directory under metadata/ with no manifest once all its "+
|
||||||
|
"objects are older than prune_grace_period and its snapshot is "+
|
||||||
|
"not in the local index (not after a backup that skipped "+
|
||||||
|
"unreadable files, found a configured path empty, or did not "+
|
||||||
|
"find a mount point an earlier snapshot found)")
|
||||||
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
|
||||||
"With --prune: keep snapshots newer than this duration "+
|
"With --prune: keep snapshots newer than this duration "+
|
||||||
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
|
||||||
@@ -142,8 +164,15 @@ func newSnapshotListCommand() *cobra.Command {
|
|||||||
Use: "list",
|
Use: "list",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Short: "List all snapshots",
|
Short: "List all snapshots",
|
||||||
Long: "Lists all snapshots with their ID, timestamp, and compressed size",
|
Long: `Lists the snapshots in the local index and on the backup destination
|
||||||
Args: cobra.NoArgs,
|
store, with their ID, timestamp, and sizes.
|
||||||
|
|
||||||
|
Needs permission to list and read the destination store. It does not need
|
||||||
|
the age private key. A snapshot found only on the destination store is
|
||||||
|
listed by its remote key, because its ID is stored only in the local index
|
||||||
|
and in the snapshot's encrypted database. If the destination store cannot
|
||||||
|
be listed, the command warns and lists the local index alone.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return runVaultikApp(cmd, readOnly, false,
|
return runVaultikApp(cmd, readOnly, false,
|
||||||
"Failed to list snapshots",
|
"Failed to list snapshots",
|
||||||
@@ -165,11 +194,19 @@ func newSnapshotPurgeCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "purge",
|
Use: "purge",
|
||||||
Short: "Purge old snapshots",
|
Short: "Purge old snapshots",
|
||||||
Long: `Removes snapshots based on age or count criteria.
|
Long: `Removes snapshots based on age or count criteria. Exactly one of
|
||||||
|
--keep-latest and --older-than is required.
|
||||||
|
|
||||||
Retention is per-snapshot-name: --keep-latest keeps the latest of each
|
Retention is per-snapshot-name: --keep-latest keeps the latest snapshot of
|
||||||
configured snapshot name, not the latest globally. Use --snapshot to
|
each name, not the latest globally. Use --snapshot to restrict the
|
||||||
restrict the operation to specific snapshot names.`,
|
operation to specific snapshot names.
|
||||||
|
|
||||||
|
Only snapshots in the local index are removed. Each one is removed from
|
||||||
|
the local index and its metadata is deleted from the backup destination
|
||||||
|
store. Blobs are not touched: run 'vaultik prune' afterwards.
|
||||||
|
|
||||||
|
Needs permission to list and delete objects on the destination store. If
|
||||||
|
the destination store cannot be listed, nothing is removed.`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
// Validate flags
|
// Validate flags
|
||||||
@@ -208,14 +245,29 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "verify <snapshot-id>",
|
Use: "verify <snapshot-id>",
|
||||||
Short: "Check a snapshot's blobs are present with the listed size",
|
Short: "Check a snapshot's blobs are present with the listed size",
|
||||||
Long: "Checks that every blob the snapshot's manifest lists is present\n" +
|
Long: `Checks that every blob the snapshot's manifest lists is present
|
||||||
"in storage with the size the manifest records, and that the\n" +
|
in storage with the size the manifest records, and that the
|
||||||
"snapshot's encrypted database is present. It does not read blob\n" +
|
snapshot's encrypted database is present. It does not read blob
|
||||||
"contents; use --deep to download, decrypt, and re-hash every blob\n" +
|
contents and does not need the age private key.
|
||||||
"to detect corruption -- integrity, not who wrote it.\n\n" +
|
|
||||||
"The snapshot may be named by its ID or, on a host with no local\n" +
|
Needs permission to read the backup destination store, and to list it
|
||||||
"index, by the remote key that 'snapshot list' prints for a\n" +
|
when a remote key is given.
|
||||||
"remote-only snapshot (an unambiguous leading part is enough).",
|
|
||||||
|
Use --deep to download, decrypt, and re-hash every blob to detect
|
||||||
|
corruption -- integrity, not who wrote it.
|
||||||
|
|
||||||
|
--deep requires the age private key, in the VAULTIK_AGE_SECRET_KEY
|
||||||
|
environment variable or as age_secret_key in the config file; the
|
||||||
|
environment variable wins. The variable may hold the whole age-keygen
|
||||||
|
file, with its comments and all of its keys. Read the key from the
|
||||||
|
file rather than typing it, so that it does not land in your shell
|
||||||
|
history:
|
||||||
|
|
||||||
|
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||||
|
|
||||||
|
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).`,
|
||||||
Args: requireSnapshotIDArg,
|
Args: requireSnapshotIDArg,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
snapshotID := args[0]
|
snapshotID := args[0]
|
||||||
@@ -250,7 +302,10 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Download and verify blob hashes")
|
cmd.Flags().BoolVar(&opts.Deep, "deep", false,
|
||||||
|
"Download, decrypt and re-hash every blob; requires the age private "+
|
||||||
|
"key in VAULTIK_AGE_SECRET_KEY, or as age_secret_key in the config "+
|
||||||
|
"file")
|
||||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
|
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -274,8 +329,10 @@ know about), which is what 'vaultik prune' does. The command prints the
|
|||||||
'vaultik prune' invocation to run as a follow-up only when it removed the
|
'vaultik prune' invocation to run as a follow-up only when it removed the
|
||||||
snapshot's metadata from the destination store, and never under --json.
|
snapshot's metadata from the destination store, and never under --json.
|
||||||
|
|
||||||
An ID that names no snapshot in the local index or on the destination
|
The argument must be a snapshot ID: a remote key is not accepted. An ID
|
||||||
store is an error.
|
that names no snapshot in the local index or on the destination store is
|
||||||
|
an error. Needs permission to list and delete objects on the destination
|
||||||
|
store, unless --local-only is given.
|
||||||
|
|
||||||
Use --local-only to skip the remote half (e.g. when you want to forget a
|
Use --local-only to skip the remote half (e.g. when you want to forget a
|
||||||
snapshot locally without touching the destination store); the snapshot
|
snapshot locally without touching the destination store); the snapshot
|
||||||
|
|||||||
@@ -37,13 +37,21 @@ The snapshot may be named by its ID or, when restoring on a host with no
|
|||||||
local index, by the remote key that 'snapshot list' prints for a
|
local index, by the remote key that 'snapshot list' prints for a
|
||||||
remote-only snapshot (an unambiguous leading part is enough).
|
remote-only snapshot (an unambiguous leading part is enough).
|
||||||
|
|
||||||
Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment
|
Each file is restored under its original absolute path inside the target
|
||||||
variable. The variable may hold the whole age-keygen file (comments and
|
directory. File owners are restored only when the command runs as root.
|
||||||
all of its identities are accepted); read it from the file rather than
|
|
||||||
typing the key, so it does not land in your shell history:
|
Requires the age private key, in the VAULTIK_AGE_SECRET_KEY environment
|
||||||
|
variable or as age_secret_key in the config file; the environment
|
||||||
|
variable wins. The variable may hold the whole age-keygen file, with its
|
||||||
|
comments and all of its keys. Read the key from the file rather than
|
||||||
|
typing it, so that it does not land in your shell history:
|
||||||
|
|
||||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||||
|
|
||||||
|
Also needs permission to read the backup destination store (and to list
|
||||||
|
it when a remote key is given), and free space in the temporary directory
|
||||||
|
for the decrypted blobs.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Restore entire snapshot
|
# Restore entire snapshot
|
||||||
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore
|
vaultik snapshot restore myhost_docs_2025-01-01T12:00:00Z /restore
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -38,6 +39,7 @@ const (
|
|||||||
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
||||||
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
||||||
defaultCompressionLevel = 3
|
defaultCompressionLevel = 3
|
||||||
|
defaultPruneGracePeriod = 24 * time.Hour
|
||||||
minChunkSize = 1000 * 1000 // 1MB
|
minChunkSize = 1000 * 1000 // 1MB
|
||||||
minCompressionLevel = 1
|
minCompressionLevel = 1
|
||||||
maxCompressionLevel = 19
|
maxCompressionLevel = 19
|
||||||
@@ -67,6 +69,8 @@ var (
|
|||||||
errFileURLNotAbsolute = errors.New(
|
errFileURLNotAbsolute = errors.New(
|
||||||
"a file:// storage_url needs an absolute path, " +
|
"a file:// storage_url needs an absolute path, " +
|
||||||
"as in file:///path/to/backup")
|
"as in file:///path/to/backup")
|
||||||
|
errNegativePruneGracePeriod = errors.New(
|
||||||
|
"prune_grace_period must not be negative")
|
||||||
errStorageNotConfigured = errors.New(
|
errStorageNotConfigured = errors.New(
|
||||||
"storage not configured; set storage_url or provide s3.endpoint + " +
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
||||||
"s3.bucket + credentials")
|
"s3.bucket + credentials")
|
||||||
@@ -187,6 +191,15 @@ type Config struct {
|
|||||||
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
||||||
CompressionLevel int `yaml:"compression_level"`
|
CompressionLevel int `yaml:"compression_level"`
|
||||||
|
|
||||||
|
// PruneGracePeriod is how old a blob no manifest lists must be before
|
||||||
|
// a prune deletes it, and how old every object in a directory under
|
||||||
|
// metadata/ with no manifest must be before a prune deletes that
|
||||||
|
// directory. A backup writes its manifest only once it has uploaded
|
||||||
|
// all its blobs and its db.zst.age, so a younger blob or directory may
|
||||||
|
// belong to a backup still running, on this host or another sharing
|
||||||
|
// the destination store.
|
||||||
|
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
|
||||||
|
|
||||||
// StorageURL specifies the storage backend using a URL format.
|
// StorageURL specifies the storage backend using a URL format.
|
||||||
// Takes precedence over S3Config if set.
|
// Takes precedence over S3Config if set.
|
||||||
// Supported formats:
|
// Supported formats:
|
||||||
@@ -288,6 +301,7 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
ChunkSize: defaultChunkSize,
|
ChunkSize: defaultChunkSize,
|
||||||
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||||
CompressionLevel: defaultCompressionLevel,
|
CompressionLevel: defaultCompressionLevel,
|
||||||
|
PruneGracePeriod: defaultPruneGracePeriod,
|
||||||
S3: S3Config{PartSize: defaultS3PartSize},
|
S3: S3Config{PartSize: defaultS3PartSize},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +370,7 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
// exceeds the configured limit
|
// exceeds the configured limit
|
||||||
// - Compression level must be between 1 and 19
|
// - Compression level must be between 1 and 19
|
||||||
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
||||||
|
// - Prune grace period must not be negative
|
||||||
//
|
//
|
||||||
// Returns an error describing the first validation failure encountered.
|
// Returns an error describing the first validation failure encountered.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
@@ -405,6 +420,10 @@ func (c *Config) Validate() error {
|
|||||||
return errBadS3PartSize
|
return errBadS3PartSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.PruneGracePeriod < 0 {
|
||||||
|
return errNegativePruneGracePeriod
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/chunker"
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
@@ -412,6 +413,74 @@ func TestDocumentedSizeExamples(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||||
|
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||||
|
// and directories under metadata/ with no manifest at once, and that 0s
|
||||||
|
// and the documented examples are accepted.
|
||||||
|
func TestValidatePruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
setting string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{"prune_grace_period: 0s", nil},
|
||||||
|
{"prune_grace_period: 90m", nil},
|
||||||
|
{"prune_grace_period: 36h", nil},
|
||||||
|
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.setting, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Validate()
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadPruneGracePeriod checks that a config file without
|
||||||
|
// prune_grace_period loads with the documented 24h. The value is written out
|
||||||
|
// here rather than taken from defaultPruneGracePeriod, so that a change of the
|
||||||
|
// default fails this test.
|
||||||
|
func TestLoadPruneGracePeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const withoutGracePeriod = "snapshots:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" paths: [/tmp/vaultik-test-source]\n" +
|
||||||
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
|
||||||
|
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PruneGracePeriod != 24*time.Hour {
|
||||||
|
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
|
||||||
// relative path fails at load. Accepted, it would resolve against the
|
// relative path fails at load. Accepted, it would resolve against the
|
||||||
// working directory, so a backup run from another directory would write
|
// working directory, so a backup run from another directory would write
|
||||||
|
|||||||
@@ -267,6 +267,45 @@ func (r *FileRepository) ListUnderPath(
|
|||||||
return files, rows.Err()
|
return files, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
|
||||||
|
// for path and that the snapshot lists, in no particular order.
|
||||||
|
func (r *FileRepository) ListUnderPathInSnapshot(
|
||||||
|
ctx context.Context, path, snapshotID string,
|
||||||
|
) ([]*File, error) {
|
||||||
|
path = strings.TrimRight(path, "/")
|
||||||
|
dirPrefix := path + "/"
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
|
||||||
|
f.mode, f.uid, f.gid, f.link_target
|
||||||
|
FROM files f
|
||||||
|
JOIN snapshot_files sf ON sf.file_id = f.id
|
||||||
|
WHERE sf.snapshot_id = ?
|
||||||
|
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||||
|
snapshotID, path, dirPrefix, dirPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var files []*File
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
file, err := r.scanFileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
||||||
// path starts with prefix and that list at least one chunk held by no
|
// path starts with prefix and that list at least one chunk held by no
|
||||||
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
||||||
|
|||||||
@@ -212,6 +212,65 @@ func TestFileRepositoryListUnderPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUnderPathInSnapshot leaves out files outside path and files the
|
||||||
|
// snapshot does not list, though another snapshot lists them.
|
||||||
|
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
files := database.NewFileRepository(db)
|
||||||
|
snapshots := database.NewSnapshotRepository(db)
|
||||||
|
|
||||||
|
listed := map[string][]string{
|
||||||
|
"one": {"/src/a.txt", "/src2/c.txt"},
|
||||||
|
"two": {"/src/b.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for snapshotID, paths := range listed {
|
||||||
|
err := snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: types.SnapshotID(snapshotID),
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
err := files.Create(ctx, nil, &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
MTime: time.Now().Truncate(time.Second),
|
||||||
|
Mode: 0644,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshots.AddFile(ctx, nil, snapshotID, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(found))
|
||||||
|
for _, f := range found {
|
||||||
|
got = append(got, f.Path.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, []string{"/src/a.txt"}) {
|
||||||
|
t.Errorf("got %q, want only /src/a.txt", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFileRepositorySymlink(t *testing.T) {
|
func TestFileRepositorySymlink(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
|
|||||||
-- Index for efficient blob lookups (used in orphan detection)
|
-- Index for efficient blob lookups (used in orphan detection)
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
|
||||||
|
|
||||||
|
-- Snapshot mount points table: the directories at or below a configured
|
||||||
|
-- path that the snapshot's backup found on a different filesystem from
|
||||||
|
-- their parent directory, as a mount point is, and found an entry in
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
|
||||||
|
snapshot_id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_id, path),
|
||||||
|
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Uploads table: tracks blob upload metrics
|
-- Uploads table: tracks blob upload metrics
|
||||||
CREATE TABLE IF NOT EXISTS uploads (
|
CREATE TABLE IF NOT EXISTS uploads (
|
||||||
blob_hash TEXT PRIMARY KEY,
|
blob_hash TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotRepository provides access to the snapshots table and its
|
// SnapshotRepository provides access to the snapshots table and its
|
||||||
// snapshot_files / snapshot_blobs association tables.
|
// snapshot_files / snapshot_blobs association tables, and to the mount
|
||||||
|
// points each snapshot's backup found (snapshot_mount_points).
|
||||||
type SnapshotRepository struct {
|
type SnapshotRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
@@ -494,33 +495,63 @@ func (r *SnapshotRepository) GetBlobHashes(
|
|||||||
ctx context.Context, snapshotID string,
|
ctx context.Context, snapshotID string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT sb.blob_hash
|
SELECT sb.blob_hash
|
||||||
FROM snapshot_blobs sb
|
FROM snapshot_blobs sb
|
||||||
WHERE sb.snapshot_id = ?
|
WHERE sb.snapshot_id = ?
|
||||||
ORDER BY sb.blob_hash
|
ORDER BY sb.blob_hash
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID)
|
blobs, err := r.queryStrings(ctx, query, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
return nil, fmt.Errorf("querying blob hashes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
return blobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
var blobs []string
|
// AddMountPoint records dir as a directory that the backup of a snapshot
|
||||||
|
// found on a different filesystem from its parent directory, as a mount
|
||||||
|
// point is, using tx when non-nil.
|
||||||
|
func (r *SnapshotRepository) AddMountPoint(
|
||||||
|
ctx context.Context, tx *sql.Tx, snapshotID string, dir string,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
INSERT OR IGNORE INTO snapshot_mount_points (snapshot_id, path)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
for rows.Next() {
|
var err error
|
||||||
var blobHash string
|
if tx != nil {
|
||||||
|
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
|
||||||
err := rows.Scan(&blobHash)
|
} else {
|
||||||
if err != nil {
|
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
|
||||||
return nil, fmt.Errorf("scanning blob hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blobs = append(blobs, blobHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return blobs, rows.Err()
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding mount point to snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMountPoints returns the directories AddMountPoint recorded for a
|
||||||
|
// snapshot, sorted.
|
||||||
|
func (r *SnapshotRepository) GetMountPoints(
|
||||||
|
ctx context.Context, snapshotID string,
|
||||||
|
) ([]string, error) {
|
||||||
|
query := `
|
||||||
|
SELECT path
|
||||||
|
FROM snapshot_mount_points
|
||||||
|
WHERE snapshot_id = ?
|
||||||
|
ORDER BY path
|
||||||
|
`
|
||||||
|
|
||||||
|
dirs, err := r.queryStrings(ctx, query, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("querying mount points: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
// GetSnapshotTotalCompressedSize returns the total compressed size of all
|
||||||
@@ -709,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryStrings runs query, which selects one text column, and returns
|
||||||
|
// its values in the order of the rows.
|
||||||
|
func (r *SnapshotRepository) queryStrings(
|
||||||
|
ctx context.Context, query string, args ...any,
|
||||||
|
) ([]string, error) {
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var value string
|
||||||
|
|
||||||
|
err := rows.Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// extendedCompressionRatio computes the compression ratio for a snapshot
|
// extendedCompressionRatio computes the compression ratio for a snapshot
|
||||||
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
// from its stored blob_size and the given uncompressed size. Returns 1.0
|
||||||
// when the uncompressed size is zero.
|
// when the uncompressed size is zero.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -422,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
|
|||||||
t.Error("expected error for duplicate snapshot")
|
t.Error("expected error for duplicate snapshot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleting a snapshot from the local index deletes the mount points its
|
||||||
|
// backup recorded, and no other snapshot's. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestSnapshotRepositoryDeleteRemovesMountPoints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := database.NewSnapshotRepository(db)
|
||||||
|
|
||||||
|
for _, id := range []types.SnapshotID{"kept", "deleted"} {
|
||||||
|
err := repo.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: id,
|
||||||
|
Hostname: testHostname,
|
||||||
|
VaultikVersion: testVersion,
|
||||||
|
StartedAt: time.Now().Truncate(time.Second),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repo.AddMountPoint(ctx, nil, id.String(), "/data/nas")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add mount point: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Delete(ctx, "deleted")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, want := range map[string][]string{
|
||||||
|
"kept": {"/data/nas"},
|
||||||
|
"deleted": nil,
|
||||||
|
} {
|
||||||
|
got, err := repo.GetMountPoints(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read mount points: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
@@ -302,7 +303,10 @@ func IsNotFound(err error) bool {
|
|||||||
type ObjectInfo struct {
|
type ObjectInfo struct {
|
||||||
Key string
|
Key string
|
||||||
Size int64
|
Size int64
|
||||||
Err error
|
// ModTime is the object's LastModified time. Only ListObjectsStream
|
||||||
|
// sets it.
|
||||||
|
ModTime time.Time
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListObjectsStream lists objects with the given prefix and returns a channel.
|
// ListObjectsStream lists objects with the given prefix and returns a channel.
|
||||||
@@ -343,8 +347,9 @@ func (c *Client) ListObjectsStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: *obj.Size,
|
Size: *obj.Size,
|
||||||
|
ModTime: aws.ToTime(obj.LastModified),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported deviceNumberFromStat
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The device number a real backup reads is the same for a directory and
|
||||||
|
// a file in it, and differs between the temp directory and /dev.
|
||||||
|
// Without it no mount point is ever found. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestDeviceNumberFromStat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "file.txt")
|
||||||
|
require.NoError(t, os.WriteFile(file, nil, 0o600))
|
||||||
|
|
||||||
|
deviceOf := func(path string) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
device, ok := deviceNumberFromStat(info)
|
||||||
|
require.True(t, ok, "no device number read for %s", path)
|
||||||
|
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, deviceOf(dir), deviceOf(file))
|
||||||
|
|
||||||
|
// /dev is a different filesystem from the temp dir on Linux, macOS and in Docker.
|
||||||
|
assert.NotEqual(t, deviceOf(dir), deviceOf("/dev"))
|
||||||
|
}
|
||||||
+21
-11
@@ -1,6 +1,8 @@
|
|||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
@@ -16,6 +18,12 @@ type ScannerParams struct {
|
|||||||
Fs afero.Fs
|
Fs afero.Fs
|
||||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
|
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none.
|
||||||
|
PreviousSnapshotID string
|
||||||
|
// DeviceNumber is ScannerConfig.DeviceNumber; nil reads it from the
|
||||||
|
// file's stat.
|
||||||
|
DeviceNumber func(info os.FileInfo) (uint64, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module exports backup functionality as an fx module.
|
// Module exports backup functionality as an fx module.
|
||||||
@@ -44,17 +52,19 @@ func provideScannerFactory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NewScanner(ScannerConfig{
|
return NewScanner(ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
ChunkSize: cfg.ChunkSize.Int64(),
|
ChunkSize: cfg.ChunkSize.Int64(),
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
MaxBlobSize: cfg.BlobSizeLimit.Int64(),
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: excludes,
|
Exclude: excludes,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
|
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||||
|
DeviceNumber: params.DeviceNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package snapshot_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"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/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A scan records with the snapshot each directory on a different device
|
||||||
|
// from its parent directory that the walk found an entry in, as a mount
|
||||||
|
// point is, the scanned path included. A filesystem that gives no device
|
||||||
|
// numbers, as the in-memory one, records none. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestScanRecordsMountPoints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
scannedPath = "/data/source"
|
||||||
|
snapshotID = "mount-points"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// onVolume names the directories on the volume's device; every
|
||||||
|
// other directory is on another one. nil leaves the device
|
||||||
|
// numbers to the in-memory filesystem, which has none.
|
||||||
|
onVolume []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "volume below the scanned path",
|
||||||
|
onVolume: []string{"nas"},
|
||||||
|
want: []string{scannedPath + "/nas"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume at the scanned path",
|
||||||
|
onVolume: []string{"source", "nas"},
|
||||||
|
want: []string{scannedPath},
|
||||||
|
},
|
||||||
|
{name: "no device numbers", want: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
scannedPath + "/notes.txt", scannedPath + "/nas/a.txt",
|
||||||
|
} {
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
|
cfg := snapshot.ScannerConfig{
|
||||||
|
FS: fs,
|
||||||
|
ChunkSize: int64(1024 * 16),
|
||||||
|
Repositories: repos,
|
||||||
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
}
|
||||||
|
if tt.onVolume != nil {
|
||||||
|
cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
||||||
|
if slices.Contains(tt.onVolume, info.Name()) {
|
||||||
|
return 2, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// automountFs is an in-memory filesystem with a volume at mountPoint
|
||||||
|
// that is mounted on first access: opening mountPoint to list it mounts
|
||||||
|
// the volume or, if it cannot be mounted, fails with "no such file or
|
||||||
|
// directory", as with the autofs daemon. Stat records the device an
|
||||||
|
// entry is on at that moment, as a real stat does: unmountedDevice for
|
||||||
|
// mountPoint until the volume is mounted; the volume's, 2, for
|
||||||
|
// mountPoint once it is and for everything in it; 1 for everything else.
|
||||||
|
type automountFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
mountPoint string
|
||||||
|
unmountedDevice uint64
|
||||||
|
canMount bool
|
||||||
|
mounted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||||
|
func (fs *automountFs) Open(name string) (afero.File, error) {
|
||||||
|
if name == fs.mountPoint && !fs.canMount {
|
||||||
|
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == fs.mountPoint {
|
||||||
|
fs.mounted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
|
||||||
|
info, err := fs.Fs.Stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
device := uint64(1)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case name == fs.mountPoint && !fs.mounted:
|
||||||
|
device = fs.unmountedDevice
|
||||||
|
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
|
||||||
|
device = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return statWithDevice{FileInfo: info, device: device}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statWithDevice is an entry automountFs.Stat looked up, with the device
|
||||||
|
// it was on then.
|
||||||
|
type statWithDevice struct {
|
||||||
|
os.FileInfo
|
||||||
|
|
||||||
|
device uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceAtLookup returns the device automountFs.Stat recorded for info.
|
||||||
|
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
|
||||||
|
stat, ok := info.(statWithDevice)
|
||||||
|
|
||||||
|
return stat.device, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// A volume mounted on first access is mounted when the walk lists its
|
||||||
|
// mount point. Until then the mount point is on the automounter's own
|
||||||
|
// device (a systemd automount) or on its parent's (an autofs map that
|
||||||
|
// shows its mount points before mounting them). Either way only the
|
||||||
|
// mount point is recorded, by the backup that mounts the volume and by a
|
||||||
|
// later one that finds it mounted. Listing an automount point whose
|
||||||
|
// volume cannot be mounted fails; it is not recorded, because the walk
|
||||||
|
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const scannedPath = "/data/source"
|
||||||
|
|
||||||
|
mountPoint := scannedPath + "/nas"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unmountedDevice uint64
|
||||||
|
canMount bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "on the automounter's device until listed",
|
||||||
|
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "on its parent's device until listed",
|
||||||
|
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "volume that cannot be mounted",
|
||||||
|
unmountedDevice: 1, canMount: false, want: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := &automountFs{
|
||||||
|
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
|
||||||
|
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
|
||||||
|
} {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewTestDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
for _, snapshotID := range []string{"first", "later"} {
|
||||||
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
||||||
|
|
||||||
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
|
FS: fs,
|
||||||
|
ChunkSize: int64(1024 * 16),
|
||||||
|
Repositories: repos,
|
||||||
|
MaxBlobSize: int64(1024 * 1024),
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgePublicKey},
|
||||||
|
DeviceNumber: deviceAtLookup,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,7 @@ func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
|
|||||||
|
|
||||||
stats := progress.GetStats()
|
stats := progress.GetStats()
|
||||||
|
|
||||||
// Directories count toward the total size but produce no chunks, so
|
// With both paths counted, the percentage must not exceed 100%.
|
||||||
// the percentage ends just under 100%.
|
|
||||||
percent := float64(stats.BytesProcessed.Load()) /
|
percent := float64(stats.BytesProcessed.Load()) /
|
||||||
float64(stats.TotalSize.Load()) * 100
|
float64(stats.TotalSize.Load()) * 100
|
||||||
if percent > 100 {
|
if percent > 100 {
|
||||||
|
|||||||
+398
-115
@@ -58,6 +58,10 @@ type Scanner struct {
|
|||||||
compressionLevel int
|
compressionLevel int
|
||||||
ageRecipient string
|
ageRecipient string
|
||||||
snapshotID string // Current snapshot being processed
|
snapshotID string // Current snapshot being processed
|
||||||
|
// previousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none. Only a file it lists can be counted as
|
||||||
|
// deleted.
|
||||||
|
previousSnapshotID string
|
||||||
// currentSourcePath is the source directory being scanned, stored with
|
// currentSourcePath is the source directory being scanned, stored with
|
||||||
// each file record.
|
// each file record.
|
||||||
currentSourcePath string
|
currentSourcePath string
|
||||||
@@ -69,6 +73,9 @@ type Scanner struct {
|
|||||||
skipErrors bool
|
skipErrors bool
|
||||||
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
// ui is the user-facing output; never nil (defaults to a discarding writer).
|
||||||
ui *ui.Writer
|
ui *ui.Writer
|
||||||
|
// deviceNumber returns the number of the device that holds a file;
|
||||||
|
// never nil (defaults to deviceNumberFromStat).
|
||||||
|
deviceNumber func(info os.FileInfo) (uint64, bool)
|
||||||
|
|
||||||
// In-memory cache of known chunk hashes for fast existence checks
|
// In-memory cache of known chunk hashes for fast existence checks
|
||||||
knownChunks map[string]struct{}
|
knownChunks map[string]struct{}
|
||||||
@@ -129,15 +136,24 @@ type ScannerConfig struct {
|
|||||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
// SkipErrors skips files that cannot be opened or read (log loudly but
|
||||||
// continue); packer, database, encryption, and upload errors still abort.
|
// continue); packer, database, encryption, and upload errors still abort.
|
||||||
SkipErrors bool
|
SkipErrors bool
|
||||||
|
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||||
|
// same name, "" for none.
|
||||||
|
PreviousSnapshotID string
|
||||||
|
// DeviceNumber returns the number of the device that holds a file,
|
||||||
|
// and false when there is none to read. nil reads it from the file's
|
||||||
|
// stat; tests replace it, since the in-memory test filesystem has none.
|
||||||
|
DeviceNumber func(info os.FileInfo) (uint64, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanResult contains the results of a scan operation. Files and bytes
|
// ScanResult contains the results of a scan operation. Files and bytes
|
||||||
// are counted per file: BytesScanned is the size of the new and changed
|
// are counted per file: BytesScanned is the size of the new and changed
|
||||||
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
|
||||||
// new and changed files that phase 2 could not store; FilesScanned
|
// new and changed files that phase 2 could not store, each entry removed
|
||||||
// includes them and BytesScanned does not. ChunksCreated counts the
|
// after the walk listed its directory and, under --skip-errors, each
|
||||||
// chunks that were not already stored, and NewChunkSize is their total
|
// entry the walk could not read, such as a directory it could not list;
|
||||||
// size.
|
// FilesScanned includes them and BytesScanned does not. ChunksCreated
|
||||||
|
// counts the chunks that were not already stored, and NewChunkSize is
|
||||||
|
// their total size.
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
FilesScanned int
|
FilesScanned int
|
||||||
FilesSkipped int
|
FilesSkipped int
|
||||||
@@ -154,6 +170,17 @@ type ScanResult struct {
|
|||||||
UploadDuration time.Duration
|
UploadDuration time.Duration
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
EndTime time.Time
|
EndTime time.Time
|
||||||
|
|
||||||
|
// FilesUnreadable is the part of FilesFailed that --skip-errors
|
||||||
|
// skipped because it could not be read or listed; the rest are files
|
||||||
|
// removed after the walk listed them.
|
||||||
|
FilesUnreadable int
|
||||||
|
|
||||||
|
// EntriesFound counts the entries the walk found and could look up
|
||||||
|
// that no exclude pattern matches, leaving out the scanned path
|
||||||
|
// itself when it is a directory. It is zero for an empty directory,
|
||||||
|
// such as the mount point of a volume that is not mounted.
|
||||||
|
EntriesFound int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner creates a new scanner instance
|
// NewScanner creates a new scanner instance
|
||||||
@@ -194,6 +221,11 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
uiw = ui.NewWithColor(io.Discard, false)
|
uiw = ui.NewWithColor(io.Discard, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deviceNumber := cfg.DeviceNumber
|
||||||
|
if deviceNumber == nil {
|
||||||
|
deviceNumber = deviceNumberFromStat
|
||||||
|
}
|
||||||
|
|
||||||
return &Scanner{
|
return &Scanner{
|
||||||
fs: cfg.FS,
|
fs: cfg.FS,
|
||||||
chunker: chunker.NewChunker(cfg.ChunkSize),
|
chunker: chunker.NewChunker(cfg.ChunkSize),
|
||||||
@@ -207,7 +239,9 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
compiledExclude: compiledExclude,
|
compiledExclude: compiledExclude,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
skipErrors: cfg.SkipErrors,
|
skipErrors: cfg.SkipErrors,
|
||||||
|
previousSnapshotID: cfg.PreviousSnapshotID,
|
||||||
ui: uiw,
|
ui: uiw,
|
||||||
|
deviceNumber: deviceNumber,
|
||||||
pendingChunkHashes: make(map[string]struct{}),
|
pendingChunkHashes: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,8 +292,9 @@ func (s *Scanner) Scan(
|
|||||||
|
|
||||||
filesToProcess := scanResult.FilesToProcess
|
filesToProcess := scanResult.FilesToProcess
|
||||||
|
|
||||||
// Phase 1b: Detect deleted files by comparing DB against scanned files
|
// Phase 1b: Detect deleted files by comparing the previous snapshot
|
||||||
err = s.detectDeletedFilesFromMap(ctx, knownFiles, existingFiles, result)
|
// of this name against scanned files
|
||||||
|
err = s.detectDeletedFiles(ctx, path, existingFiles, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
||||||
}
|
}
|
||||||
@@ -275,6 +310,11 @@ func (s *Scanner) Scan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = s.recordDirectories(ctx, scanResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Summarize scan phase results and update progress
|
// Summarize scan phase results and update progress
|
||||||
s.summarizeScanPhase(result, filesToProcess)
|
s.summarizeScanPhase(result, filesToProcess)
|
||||||
|
|
||||||
@@ -439,9 +479,8 @@ func (s *Scanner) summarizeScanPhase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadKnownFiles loads the known files at and beneath path from the
|
// loadKnownFiles loads the known files at and beneath path from the
|
||||||
// database into a map for fast lookup. Every loaded file the scan does
|
// database into a map for fast lookup. This avoids per-file database
|
||||||
// not find is counted as deleted. This avoids per-file database queries
|
// queries during the scan phase.
|
||||||
// during the scan phase.
|
|
||||||
func (s *Scanner) loadKnownFiles(
|
func (s *Scanner) loadKnownFiles(
|
||||||
ctx context.Context, path string,
|
ctx context.Context, path string,
|
||||||
) (map[string]*database.File, error) {
|
) (map[string]*database.File, error) {
|
||||||
@@ -847,14 +886,20 @@ func (s *Scanner) batchInsertFileData(
|
|||||||
// ScanPhaseResult contains the results of the scan phase
|
// ScanPhaseResult contains the results of the scan phase
|
||||||
type ScanPhaseResult struct {
|
type ScanPhaseResult struct {
|
||||||
FilesToProcess []*FileToProcess
|
FilesToProcess []*FileToProcess
|
||||||
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
|
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
|
||||||
|
Directories []*FileToProcess // Every directory found, to record
|
||||||
|
// MountPoints are the directories, the scanned path included, that
|
||||||
|
// the walk found an entry in and, once it had listed them, on a
|
||||||
|
// different device from their parent directory (see
|
||||||
|
// recordIfMountPoint).
|
||||||
|
MountPoints []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanPhase performs the initial directory scan to identify files to process
|
// scanPhase performs the initial directory scan to identify files to process
|
||||||
// It uses the pre-loaded knownFiles map for fast change detection without DB
|
// It uses the pre-loaded knownFiles map for fast change detection without DB
|
||||||
// queries. It also populates existingFiles map for deletion detection.
|
// queries. It also populates existingFiles map for deletion detection.
|
||||||
// Returns files needing processing and IDs of unchanged files for snapshot
|
// Returns files needing processing, IDs of unchanged files for snapshot
|
||||||
// association.
|
// association, and the directories found.
|
||||||
func (s *Scanner) scanPhase(
|
func (s *Scanner) scanPhase(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
path string,
|
path string,
|
||||||
@@ -862,10 +907,12 @@ func (s *Scanner) scanPhase(
|
|||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
knownFiles map[string]*database.File,
|
knownFiles map[string]*database.File,
|
||||||
) (*ScanPhaseResult, error) {
|
) (*ScanPhaseResult, error) {
|
||||||
// Use known file count as estimate for progress (accurate for subsequent backups)
|
// Use known file count as estimate for progress (accurate for
|
||||||
estimatedTotal := int64(len(knownFiles))
|
// subsequent backups). The walk does not count directories, so the
|
||||||
|
// estimate leaves them out.
|
||||||
|
estimatedTotal := countNonDirectories(knownFiles)
|
||||||
|
|
||||||
collector := &scanCollector{}
|
collector := &scanCollector{dirsWithEntries: make(map[string]struct{})}
|
||||||
|
|
||||||
// Set up periodic status output
|
// Set up periodic status output
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -874,8 +921,28 @@ func (s *Scanner) scanPhase(
|
|||||||
log.Debug("Starting directory walk", "path", path)
|
log.Debug("Starting directory walk", "path", path)
|
||||||
|
|
||||||
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
walkFn := func(filePath string, info os.FileInfo, err error) error {
|
||||||
|
// Check exclude patterns first, so an excluded entry that is gone
|
||||||
|
// or cannot be looked up is neither counted nor reported. For
|
||||||
|
// directories, skip the entire subtree. info is nil when the
|
||||||
|
// walk could not look the entry up.
|
||||||
|
if s.shouldExclude(filePath, path) {
|
||||||
|
if info != nil && info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source path is in no directory listing, so its absence is
|
||||||
|
// an error rather than a removal.
|
||||||
|
if errors.Is(err, os.ErrNotExist) && filePath != path {
|
||||||
|
countRemovedDuringWalk(filePath, existingFiles, result)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.handleWalkError(filePath, err)
|
return s.handleWalkError(filePath, err, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
@@ -885,18 +952,16 @@ func (s *Scanner) scanPhase(
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check exclude patterns - for directories, skip the entire subtree
|
// The scanned directory itself is not an entry found in it.
|
||||||
if s.shouldExclude(filePath, path) {
|
if filePath != path || !info.IsDir() {
|
||||||
if info.IsDir() {
|
result.EntriesFound++
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.recordIfMountPoint(filePath, path, collector)
|
||||||
|
|
||||||
// Handle symlinks and directories
|
// Handle symlinks and directories
|
||||||
handled, err := s.recordSpecialEntry(
|
handled, err := s.recordSpecialEntry(
|
||||||
filePath, info, existingFiles, collector, result)
|
filePath, info, existingFiles, knownFiles, collector, result)
|
||||||
if handled {
|
if handled {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -906,17 +971,8 @@ func (s *Scanner) scanPhase(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track this file as existing (for deletion detection)
|
filesScanned, changedCount := s.recordRegularFile(
|
||||||
existingFiles[filePath] = struct{}{}
|
filePath, info, existingFiles, knownFiles, collector, result)
|
||||||
|
|
||||||
// Check file against in-memory map (no DB query!)
|
|
||||||
file, needsProcessing := s.checkFileInMemory(filePath, info, knownFiles)
|
|
||||||
|
|
||||||
filesScanned, changedCount := collector.recordRegularFile(
|
|
||||||
filePath, info, file, needsProcessing)
|
|
||||||
|
|
||||||
// Update result stats
|
|
||||||
s.updateScanEntryStats(result, needsProcessing, info)
|
|
||||||
|
|
||||||
// Output periodic status
|
// Output periodic status
|
||||||
if time.Since(lastStatusTime) >= scanStatusInterval {
|
if time.Since(lastStatusTime) >= scanStatusInterval {
|
||||||
@@ -936,27 +992,46 @@ func (s *Scanner) scanPhase(
|
|||||||
return &ScanPhaseResult{
|
return &ScanPhaseResult{
|
||||||
FilesToProcess: collector.filesToProcess,
|
FilesToProcess: collector.filesToProcess,
|
||||||
UnchangedFileIDs: collector.unchangedFileIDs,
|
UnchangedFileIDs: collector.unchangedFileIDs,
|
||||||
|
Directories: collector.directories,
|
||||||
|
MountPoints: collector.mountPoints,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countNonDirectories returns how many of files are not directories.
|
||||||
|
func countNonDirectories(files map[string]*database.File) int64 {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Mode&uint32(os.ModeDir) == 0 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
// 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. For a symlink
|
// data to chunk) and reports whether it handled the entry. A symlink is
|
||||||
// whose target cannot be read it returns handleWalkError's result.
|
// counted and checked for changes as a regular file is; a directory is
|
||||||
|
// set aside to be recorded and is not counted. 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{},
|
||||||
|
knownFiles map[string]*database.File,
|
||||||
collector *scanCollector, result *ScanResult,
|
collector *scanCollector, result *ScanResult,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
// Handle symlinks
|
// Handle symlinks
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
file, err := s.buildSymlinkEntry(filePath, info)
|
file, err := s.buildSymlinkEntry(filePath, info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true, s.handleWalkError(filePath, err)
|
return true, s.handleWalkError(filePath, err, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
collector.addToProcess(filePath, info, file)
|
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||||
s.updateScanEntryStats(result, true, info)
|
collector.recordFile(filePath, info, file, needsProcessing)
|
||||||
|
s.updateScanEntryStats(result, needsProcessing, info)
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -966,7 +1041,7 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
file := s.buildDirectoryEntry(filePath, info)
|
file := s.buildDirectoryEntry(filePath, info)
|
||||||
existingFiles[filePath] = struct{}{}
|
existingFiles[filePath] = struct{}{}
|
||||||
collector.addToProcess(filePath, info, file)
|
collector.addDirectory(filePath, info, file)
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -974,15 +1049,62 @@ func (s *Scanner) recordSpecialEntry(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordRegularFile records a regular file as recordSpecialEntry does a
|
||||||
|
// symlink and returns the running scanned and changed counts.
|
||||||
|
func (s *Scanner) recordRegularFile(
|
||||||
|
filePath string, info os.FileInfo,
|
||||||
|
existingFiles map[string]struct{},
|
||||||
|
knownFiles map[string]*database.File,
|
||||||
|
collector *scanCollector, result *ScanResult,
|
||||||
|
) (int64, int) {
|
||||||
|
// Track this file as existing (for deletion detection)
|
||||||
|
existingFiles[filePath] = struct{}{}
|
||||||
|
|
||||||
|
// Check file against in-memory map (no DB query!)
|
||||||
|
file := s.buildRegularFileEntry(filePath, info)
|
||||||
|
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||||
|
|
||||||
|
filesScanned, changedCount := collector.recordFile(
|
||||||
|
filePath, info, file, needsProcessing)
|
||||||
|
|
||||||
|
// Update result stats
|
||||||
|
s.updateScanEntryStats(result, needsProcessing, info)
|
||||||
|
|
||||||
|
return filesScanned, changedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRemovedDuringWalk counts an entry that was in its directory's
|
||||||
|
// listing but was gone when the walk looked it up, like a file removed
|
||||||
|
// before phase 2 reads it: as examined and failed, and not as an error,
|
||||||
|
// with or without --skip-errors.
|
||||||
|
func countRemovedDuringWalk(
|
||||||
|
filePath string, existingFiles map[string]struct{}, result *ScanResult,
|
||||||
|
) {
|
||||||
|
log.Warn("File was deleted during the directory walk, skipping",
|
||||||
|
"path", filePath)
|
||||||
|
|
||||||
|
// The walk found it, so it is not also counted as deleted.
|
||||||
|
existingFiles[filePath] = struct{}{}
|
||||||
|
result.FilesScanned++
|
||||||
|
result.FilesFailed++
|
||||||
|
}
|
||||||
|
|
||||||
// handleWalkError deals with a filesystem error surfaced by the walk:
|
// handleWalkError deals with a filesystem error surfaced by the walk:
|
||||||
// skipped (with a UI notice) under --skip-errors, fatal otherwise.
|
// under --skip-errors the entry is skipped with a UI notice and counted
|
||||||
func (s *Scanner) handleWalkError(filePath string, err error) error {
|
// as examined, failed and unreadable, otherwise the error is fatal.
|
||||||
|
func (s *Scanner) handleWalkError(
|
||||||
|
filePath string, err error, result *ScanResult,
|
||||||
|
) error {
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("Failed to access file (skipping due to --skip-errors)",
|
log.Error("Failed to access file (skipping due to --skip-errors)",
|
||||||
"path", filePath, "error", err)
|
"path", filePath, "error", err)
|
||||||
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
||||||
s.ui.Path(filePath), err)
|
s.ui.Path(filePath), err)
|
||||||
|
|
||||||
|
result.FilesScanned++
|
||||||
|
result.FilesFailed++
|
||||||
|
result.FilesUnreadable++
|
||||||
|
|
||||||
return nil // Continue scanning
|
return nil // Continue scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,28 +1118,52 @@ type scanCollector struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
filesToProcess []*FileToProcess
|
filesToProcess []*FileToProcess
|
||||||
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
||||||
|
directories []*FileToProcess
|
||||||
filesScanned int64
|
filesScanned int64
|
||||||
|
// dirsWithEntries holds each directory the walk has found an entry in.
|
||||||
|
dirsWithEntries map[string]struct{}
|
||||||
|
mountPoints []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// addToProcess queues a file entry for processing.
|
// firstEntryIn notes that the walk found an entry in directory dir and
|
||||||
func (c *scanCollector) addToProcess(
|
// reports whether it is the first one.
|
||||||
|
func (c *scanCollector) firstEntryIn(dir string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
_, seen := c.dirsWithEntries[dir]
|
||||||
|
c.dirsWithEntries[dir] = struct{}{}
|
||||||
|
|
||||||
|
return !seen
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMountPoint sets a mount point aside to be recorded.
|
||||||
|
func (c *scanCollector) addMountPoint(dir string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.mountPoints = append(c.mountPoints, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDirectory sets a directory aside to be recorded. It is not counted
|
||||||
|
// as a file.
|
||||||
|
func (c *scanCollector) addDirectory(
|
||||||
path string, info os.FileInfo, file *database.File,
|
path string, info os.FileInfo, file *database.File,
|
||||||
) {
|
) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
c.filesToProcess = append(c.filesToProcess, &FileToProcess{
|
c.directories = append(c.directories, &FileToProcess{
|
||||||
Path: path,
|
Path: path,
|
||||||
FileInfo: info,
|
FileInfo: info,
|
||||||
File: file,
|
File: file,
|
||||||
})
|
})
|
||||||
c.filesScanned++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordRegularFile records a regular file as changed (to process) or
|
// recordFile records a regular file or symlink as changed (to process)
|
||||||
// unchanged (snapshot association only) and returns the running scanned
|
// or unchanged (snapshot association only) and returns the running
|
||||||
// and changed counts.
|
// scanned and changed counts.
|
||||||
func (c *scanCollector) recordRegularFile(
|
func (c *scanCollector) recordFile(
|
||||||
path string, info os.FileInfo, file *database.File, needsProcessing bool,
|
path string, info os.FileInfo, file *database.File, needsProcessing bool,
|
||||||
) (int64, int) {
|
) (int64, int) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -1120,7 +1266,28 @@ func (s *Scanner) printScanProgressLine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSymlinkEntry creates a File record for a symlink.
|
// buildRegularFileEntry creates a File record for a regular file, with
|
||||||
|
// no ID: checkFileInMemory sets it.
|
||||||
|
func (s *Scanner) buildRegularFileEntry(path string, info os.FileInfo) *database.File {
|
||||||
|
var uid, gid uint32
|
||||||
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
uid = stat.Uid
|
||||||
|
gid = stat.Gid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &database.File{
|
||||||
|
Path: types.FilePath(path),
|
||||||
|
SourcePath: types.SourcePath(s.currentSourcePath),
|
||||||
|
MTime: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Mode: uint32(info.Mode()),
|
||||||
|
UID: uid,
|
||||||
|
GID: gid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSymlinkEntry creates a File record for a symlink, with no ID:
|
||||||
|
// checkFileInMemory sets it.
|
||||||
func (s *Scanner) buildSymlinkEntry(
|
func (s *Scanner) buildSymlinkEntry(
|
||||||
path string, info os.FileInfo,
|
path string, info os.FileInfo,
|
||||||
) (*database.File, error) {
|
) (*database.File, error) {
|
||||||
@@ -1136,7 +1303,6 @@ func (s *Scanner) buildSymlinkEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &database.File{
|
return &database.File{
|
||||||
ID: types.NewFileID(),
|
|
||||||
Path: types.FilePath(path),
|
Path: types.FilePath(path),
|
||||||
SourcePath: types.SourcePath(s.currentSourcePath),
|
SourcePath: types.SourcePath(s.currentSourcePath),
|
||||||
MTime: info.ModTime(),
|
MTime: info.ModTime(),
|
||||||
@@ -1168,6 +1334,102 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordDirectories records every directory the walk found, and its
|
||||||
|
// mount points (see recordMountPoints). A directory has no data to back
|
||||||
|
// up, so it is not among the files counted and backed up.
|
||||||
|
func (s *Scanner) recordDirectories(
|
||||||
|
ctx context.Context, scanned *ScanPhaseResult,
|
||||||
|
) error {
|
||||||
|
for _, dir := range scanned.Directories {
|
||||||
|
err := s.recordNonRegularFile(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.recordMountPoints(ctx, scanned.MountPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordMountPoints records with the snapshot the mount points the walk
|
||||||
|
// found (see ScanPhaseResult.MountPoints).
|
||||||
|
func (s *Scanner) recordMountPoints(
|
||||||
|
ctx context.Context, mountPoints []string,
|
||||||
|
) error {
|
||||||
|
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
|
||||||
|
for _, dir := range mountPoints {
|
||||||
|
log.Info("Found a mount point", "path", dir)
|
||||||
|
|
||||||
|
err := s.repos.Snapshots.AddMountPoint(txCtx, tx, s.snapshotID, dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recording mount point %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordIfMountPoint sets the directory that holds entry, a path the
|
||||||
|
// walk found, aside to be recorded as a mount point if it is on a
|
||||||
|
// different device from its parent directory. The walk calls it for each
|
||||||
|
// entry it finds, and it decides on the first entry in a directory, so a
|
||||||
|
// directory the walk found no entry in is never recorded. By then the
|
||||||
|
// walk has listed the directory, which mounts a volume mounted there on
|
||||||
|
// first access, so the directory and its parent are looked up again:
|
||||||
|
// before the listing, the directory can be on the automounter's own
|
||||||
|
// device (a systemd automount) or on its parent's (an autofs map that
|
||||||
|
// shows its mount points before mounting them). The scanned path is in
|
||||||
|
// no listing the walk made, so it is skipped as an entry. A directory
|
||||||
|
// that cannot be looked up then, or whose parent cannot, is not
|
||||||
|
// recorded, so this snapshot does not stop the prune of a later backup
|
||||||
|
// that misses its volume.
|
||||||
|
func (s *Scanner) recordIfMountPoint(
|
||||||
|
entry, scannedPath string, collector *scanCollector,
|
||||||
|
) {
|
||||||
|
dir := filepath.Dir(entry)
|
||||||
|
|
||||||
|
if entry == scannedPath || !collector.firstEntryIn(dir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dirDevice, ok := s.deviceNumberAt(dir)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parentDevice, ok := s.deviceNumberAt(filepath.Dir(dir))
|
||||||
|
if ok && dirDevice != parentDevice {
|
||||||
|
collector.addMountPoint(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceNumberAt looks path up now and returns the number of the device
|
||||||
|
// that holds it, and false when the lookup fails (logged) or gives no
|
||||||
|
// device number.
|
||||||
|
func (s *Scanner) deviceNumberAt(path string) (uint64, bool) {
|
||||||
|
info, err := s.fs.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Could not look up a directory to check for a mount point",
|
||||||
|
"path", path, "error", err)
|
||||||
|
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.deviceNumber(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceNumberFromStat returns the number of the device that holds the
|
||||||
|
// file info describes, and false when info carries no stat, as from the
|
||||||
|
// in-memory test filesystem.
|
||||||
|
func deviceNumberFromStat(info os.FileInfo) (uint64, bool) {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint64(stat.Dev), true //nolint:unconvert // Dev is an int32 on darwin
|
||||||
|
}
|
||||||
|
|
||||||
// recordNonRegularFile writes a symlink or directory entry to the database
|
// recordNonRegularFile writes a symlink or directory entry to the database
|
||||||
// and associates it with the current snapshot. No chunking is performed.
|
// and associates it with the current snapshot. No chunking is performed.
|
||||||
func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error {
|
func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error {
|
||||||
@@ -1181,62 +1443,36 @@ func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkFileInMemory checks if a file needs processing using the in-memory map
|
// checkFileInMemory reports whether a regular file or symlink needs
|
||||||
|
// processing, and gives file the ID of its known record, or a new ID for
|
||||||
|
// a new path.
|
||||||
// No database access is performed - this is purely CPU/memory work
|
// No database access is performed - this is purely CPU/memory work
|
||||||
func (s *Scanner) checkFileInMemory(
|
func (s *Scanner) checkFileInMemory(
|
||||||
path string, info os.FileInfo, knownFiles map[string]*database.File,
|
file *database.File, knownFiles map[string]*database.File,
|
||||||
) (*database.File, bool) {
|
) bool {
|
||||||
var uid, gid uint32
|
existingFile, exists := knownFiles[file.Path.String()]
|
||||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
||||||
uid = stat.Uid
|
|
||||||
gid = stat.Gid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check against in-memory map first to get existing ID if available
|
|
||||||
existingFile, exists := knownFiles[path]
|
|
||||||
|
|
||||||
// Create file record with ID set upfront
|
|
||||||
// For new files, generate UUID immediately so it's available for chunk associations
|
|
||||||
// For existing files, reuse the existing ID
|
|
||||||
var fileID types.FileID
|
|
||||||
if exists {
|
|
||||||
fileID = existingFile.ID
|
|
||||||
} else {
|
|
||||||
fileID = types.NewFileID()
|
|
||||||
}
|
|
||||||
|
|
||||||
file := &database.File{
|
|
||||||
ID: fileID,
|
|
||||||
Path: types.FilePath(path),
|
|
||||||
SourcePath: types.SourcePath(s.currentSourcePath),
|
|
||||||
MTime: info.ModTime(),
|
|
||||||
Size: info.Size(),
|
|
||||||
Mode: uint32(info.Mode()),
|
|
||||||
UID: uid,
|
|
||||||
GID: gid,
|
|
||||||
}
|
|
||||||
|
|
||||||
// New file - needs processing
|
// New file - needs processing
|
||||||
if !exists {
|
if !exists {
|
||||||
return file, true
|
file.ID = types.NewFileID()
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file.ID = existingFile.ID
|
||||||
|
|
||||||
// No uploaded blob holds one of its chunks (see loadFilesToRechunk)
|
// No uploaded blob holds one of its chunks (see loadFilesToRechunk)
|
||||||
if _, rechunk := s.filesToRechunk[fileID]; rechunk {
|
if _, rechunk := s.filesToRechunk[file.ID]; rechunk {
|
||||||
return file, true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file has changed
|
// Check if file has changed
|
||||||
if existingFile.Size != file.Size ||
|
return existingFile.Size != file.Size ||
|
||||||
!existingFile.MTime.Equal(file.MTime) ||
|
!existingFile.MTime.Equal(file.MTime) ||
|
||||||
existingFile.Mode != file.Mode ||
|
existingFile.Mode != file.Mode ||
|
||||||
existingFile.UID != file.UID ||
|
existingFile.UID != file.UID ||
|
||||||
existingFile.GID != file.GID {
|
existingFile.GID != file.GID ||
|
||||||
return file, true
|
existingFile.LinkTarget != file.LinkTarget
|
||||||
}
|
|
||||||
|
|
||||||
// File unchanged
|
|
||||||
return file, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
|
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
|
||||||
@@ -1363,17 +1599,16 @@ func (s *Scanner) processPhase(
|
|||||||
return s.finalizeProcessPhase(ctx, result)
|
return s.finalizeProcessPhase(ctx, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// processFileWithErrorHandling records a directory or symlink, or wraps
|
// processFileWithErrorHandling records a symlink, or wraps
|
||||||
// processFileStreaming for a regular file with error recovery for deleted
|
// processFileStreaming for a regular file with error recovery for deleted
|
||||||
// files and skip-errors mode. Returns (skipped, error).
|
// files and skip-errors mode. Returns (skipped, error).
|
||||||
func (s *Scanner) processFileWithErrorHandling(
|
func (s *Scanner) processFileWithErrorHandling(
|
||||||
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
// A directory or symlink has no data to open or read; it is only
|
// A symlink has no data to open or read; it is only recorded in the
|
||||||
// recorded in the local index. An error recording it stops the run
|
// local index. An error recording it stops the run even under
|
||||||
// even under --skip-errors, or the snapshot would complete without it.
|
// --skip-errors, or the snapshot would complete without it.
|
||||||
mode := os.FileMode(fileToProcess.File.Mode)
|
if os.FileMode(fileToProcess.File.Mode)&os.ModeSymlink != 0 {
|
||||||
if mode&os.ModeSymlink != 0 || mode.IsDir() {
|
|
||||||
err := s.recordNonRegularFile(ctx, fileToProcess)
|
err := s.recordNonRegularFile(ctx, fileToProcess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
||||||
@@ -1414,6 +1649,7 @@ func (s *Scanner) processFileWithErrorHandling(
|
|||||||
s.ui.Path(fileToProcess.Path), err)
|
s.ui.Path(fileToProcess.Path), err)
|
||||||
|
|
||||||
countFailedFile(fileToProcess, result)
|
countFailedFile(fileToProcess, result)
|
||||||
|
result.FilesUnreadable++
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1954,21 +2190,29 @@ func (s *Scanner) queueFileForBatchInsert(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectDeletedFilesFromMap finds files that existed in previous snapshots
|
// detectDeletedFiles counts as deleted each file at or beneath path that
|
||||||
// but no longer exist. Uses pre-loaded maps to avoid any filesystem or
|
// the previous snapshot of this name lists, the walk did not find, and
|
||||||
// database access.
|
// is gone from disk. A file that is excluded, or inside an excluded
|
||||||
func (s *Scanner) detectDeletedFilesFromMap(
|
// directory, is not counted: the walk skips it whether or not it is
|
||||||
|
// still on disk.
|
||||||
|
func (s *Scanner) detectDeletedFiles(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
knownFiles map[string]*database.File,
|
path string,
|
||||||
existingFiles map[string]struct{},
|
existingFiles map[string]struct{},
|
||||||
result *ScanResult,
|
result *ScanResult,
|
||||||
) error {
|
) error {
|
||||||
if len(knownFiles) == 0 {
|
if s.previousSnapshotID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each known file against the enumerated set (no filesystem access needed)
|
previousFiles, err := s.repos.Files.ListUnderPathInSnapshot(
|
||||||
for path, file := range knownFiles {
|
ctx, path, s.previousSnapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing files of snapshot %s: %w",
|
||||||
|
s.previousSnapshotID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range previousFiles {
|
||||||
// Check context cancellation periodically
|
// Check context cancellation periodically
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -1976,13 +2220,22 @@ func (s *Scanner) detectDeletedFilesFromMap(
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file exists in our enumerated set
|
filePath := file.Path.String()
|
||||||
if _, exists := existingFiles[path]; !exists {
|
|
||||||
// File has been deleted
|
_, exists := existingFiles[filePath]
|
||||||
result.FilesDeleted++
|
if exists || s.excludedOrInExcludedDir(filePath, path) {
|
||||||
result.BytesDeleted += file.Size
|
continue
|
||||||
log.Debug("Detected deleted file", "path", path, "size", file.Size)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Under --skip-errors the walk also misses what it cannot reach,
|
||||||
|
// such as the files in a directory it cannot open.
|
||||||
|
if !s.goneFromDisk(filePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FilesDeleted++
|
||||||
|
result.BytesDeleted += file.Size
|
||||||
|
log.Debug("Detected deleted file", "path", filePath, "size", file.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesDeleted > 0 {
|
if result.FilesDeleted > 0 {
|
||||||
@@ -1993,6 +2246,36 @@ func (s *Scanner) detectDeletedFilesFromMap(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// excludedOrInExcludedDir reports whether filePath, which is at or
|
||||||
|
// beneath rootPath, or any directory between rootPath and it, matches an
|
||||||
|
// exclude pattern. A pattern such as */.local/share/Trash matches the
|
||||||
|
// directory but not the files inside it, which the walk skips too.
|
||||||
|
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
|
||||||
|
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
|
||||||
|
if s.shouldExclude(p, rootPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// goneFromDisk reports whether looking filePath up on disk finds that
|
||||||
|
// it does not exist. Like the walk, it does not follow a symlink, so a
|
||||||
|
// symlink whose target is gone is still on disk. A file whose directory
|
||||||
|
// was replaced by a regular file is gone too, though the lookup reports
|
||||||
|
// "not a directory".
|
||||||
|
func (s *Scanner) goneFromDisk(filePath string) bool {
|
||||||
|
var err error
|
||||||
|
if lstater, ok := s.fs.(afero.Lstater); ok {
|
||||||
|
_, _, err = lstater.LstatIfPossible(filePath)
|
||||||
|
} else {
|
||||||
|
_, err = s.fs.Stat(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
|
||||||
|
}
|
||||||
|
|
||||||
// wrapPermissionError augments permission errors with platform-specific
|
// wrapPermissionError augments permission errors with platform-specific
|
||||||
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
||||||
// Reminders, Photos, etc.) return EPERM unless the running application has
|
// Reminders, Photos, etc.) return EPERM unless the running application has
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//nolint:testpackage // exercises the unexported countNonDirectories helper
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The walk counts regular files and symlinks, not directories, so the
|
||||||
|
// estimate of its count made from the local index's records leaves the
|
||||||
|
// directories out. See https://git.eeqj.de/sneak/vaultik/issues/305.
|
||||||
|
func TestCountNonDirectories(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
known := map[string]*database.File{
|
||||||
|
"/src": {Mode: uint32(os.ModeDir | 0o755)},
|
||||||
|
"/src/sub": {Mode: uint32(os.ModeDir | 0o755)},
|
||||||
|
"/src/a.txt": {Mode: 0o644},
|
||||||
|
"/src/link": {Mode: uint32(os.ModeSymlink | 0o777), LinkTarget: "a.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := countNonDirectories(known)
|
||||||
|
if got != 2 {
|
||||||
|
t.Errorf("countNonDirectories = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -518,8 +518,8 @@ type CleanupStats struct {
|
|||||||
//
|
//
|
||||||
// The cleanup is performed in a specific order to maintain referential integrity:
|
// The cleanup is performed in a specific order to maintain referential integrity:
|
||||||
// 1. Delete other snapshots
|
// 1. Delete other snapshots
|
||||||
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs)
|
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs,
|
||||||
// for deleted snapshots
|
// snapshot_mount_points) for deleted snapshots
|
||||||
// 3. Delete orphaned files (not in the current snapshot)
|
// 3. Delete orphaned files (not in the current snapshot)
|
||||||
// 4. Delete orphaned chunk-to-file mappings (references to deleted files)
|
// 4. Delete orphaned chunk-to-file mappings (references to deleted files)
|
||||||
// 5. Delete orphaned blobs (not in the current snapshot)
|
// 5. Delete orphaned blobs (not in the current snapshot)
|
||||||
@@ -948,8 +948,8 @@ func (sm *SnapshotManager) deleteOtherSnapshots(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteOrphanedSnapshotAssociations deletes snapshot_files and
|
// deleteOrphanedSnapshotAssociations deletes snapshot_files,
|
||||||
// snapshot_blobs for deleted snapshots
|
// snapshot_blobs and snapshot_mount_points for deleted snapshots
|
||||||
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
||||||
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -983,6 +983,19 @@ func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
|
|||||||
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
|
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
|
||||||
"count", rowsAffected)
|
"count", rowsAffected)
|
||||||
|
|
||||||
|
// Delete the mount points other snapshots recorded
|
||||||
|
query := "DELETE FROM snapshot_mount_points WHERE snapshot_id != ?"
|
||||||
|
database.LogSQL("Execute", query, currentSnapshotID)
|
||||||
|
|
||||||
|
result, err = tx.ExecContext(ctx, query, currentSnapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting orphaned snapshot_mount_points: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ = result.RowsAffected()
|
||||||
|
log.Debug("[Temp DB Cleanup] Deleted snapshot_mount_points rows",
|
||||||
|
"count", rowsAffected)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -387,6 +389,77 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The exported metadata database keeps only the mount points that the
|
||||||
|
// exported snapshot recorded. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestCleanSnapshotDBKeepsOnlyTheSnapshotsMountPoints(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "export.db")
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
for _, id := range []types.SnapshotID{"exported", "other"} {
|
||||||
|
err = repos.Snapshots.Create(ctx, nil, &database.Snapshot{ID: id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repos.Snapshots.AddMountPoint(ctx, nil, id.String(), "/data/"+id.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add mount point: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to close database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SnapshotManager{
|
||||||
|
config: &config.Config{
|
||||||
|
CompressionLevel: 3,
|
||||||
|
AgeRecipients: []string{testAgeRecipient},
|
||||||
|
},
|
||||||
|
fs: afero.NewOsFs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sm.cleanSnapshotDB(ctx, dbPath, "exported")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to clean snapshot database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned, err := database.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open cleaned database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = cleaned.Close() }()
|
||||||
|
|
||||||
|
cleanedRepos := database.NewRepositories(cleaned)
|
||||||
|
|
||||||
|
for id, want := range map[string][]string{
|
||||||
|
"exported": {"/data/exported"},
|
||||||
|
"other": nil,
|
||||||
|
} {
|
||||||
|
got, err := cleanedRepos.Snapshots.GetMountPoints(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read mount points: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Two creates of one snapshot name back to back start within one second,
|
// Two creates of one snapshot name back to back start within one second,
|
||||||
// the resolution of the timestamp in a snapshot ID. See
|
// the resolution of the timestamp in a snapshot ID. See
|
||||||
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
// https://git.eeqj.de/sneak/vaultik/issues/270.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -19,15 +20,17 @@ import (
|
|||||||
// constructor, so the contract is defined once.
|
// constructor, so the contract is defined once.
|
||||||
//
|
//
|
||||||
// It exercises the public Storer interface: round-trip, stat, list with
|
// It exercises the public Storer interface: round-trip, stat, list with
|
||||||
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
|
// prefix filtering, the modification time ListStream reports, overwrite,
|
||||||
// Get and Stat. Each section takes its own fresh backend instance, so the
|
// delete, delete-of-missing, and not-found on Get and Stat. Each section
|
||||||
// order of sections never matters and no section sees another's objects.
|
// takes its own fresh backend instance, so the order of sections never
|
||||||
|
// matters and no section sees another's objects.
|
||||||
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
conformanceRoundTrip(t, newStorer(t))
|
conformanceRoundTrip(t, newStorer(t))
|
||||||
conformanceOverwrite(t, newStorer(t))
|
conformanceOverwrite(t, newStorer(t))
|
||||||
conformanceList(t, newStorer(t))
|
conformanceList(t, newStorer(t))
|
||||||
|
conformanceListStreamModTime(t, newStorer(t))
|
||||||
conformanceDelete(t, newStorer(t))
|
conformanceDelete(t, newStorer(t))
|
||||||
conformanceNotFound(t, newStorer(t))
|
conformanceNotFound(t, newStorer(t))
|
||||||
}
|
}
|
||||||
@@ -118,6 +121,35 @@ func conformanceList(t *testing.T, s storage.Storer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// conformanceListStreamModTime checks that ListStream gives an object
|
||||||
|
// just written a modification time of about now, which prune compares
|
||||||
|
// with its grace period.
|
||||||
|
func conformanceListStreamModTime(t *testing.T, s storage.Storer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := s.Put(ctx, "blobs/dd/written-now", bytes.NewReader([]byte("data")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var listed []storage.ObjectInfo
|
||||||
|
|
||||||
|
for info := range s.ListStream(ctx, "blobs/") {
|
||||||
|
listed = append(listed, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed) != 1 || listed[0].Err != nil {
|
||||||
|
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(listed[0].ModTime).Abs() > time.Minute {
|
||||||
|
t.Errorf("ListStream ModTime = %v, want within a minute of now",
|
||||||
|
listed[0].ModTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// conformanceDelete checks that Delete removes an object and that deleting
|
// conformanceDelete checks that Delete removes an object and that deleting
|
||||||
// a missing key is not an error.
|
// a missing key is not an error.
|
||||||
func conformanceDelete(t *testing.T, s storage.Storer) {
|
func conformanceDelete(t *testing.T, s storage.Storer) {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
// faults on demand, so tests can reproduce the failure modes a real
|
// faults on demand, so tests can reproduce the failure modes a real
|
||||||
// backend exhibits: an upload that fails partway, a backend that reports
|
// backend exhibits: an upload that fails partway, a backend that reports
|
||||||
// success while storing nothing, reads that return corrupt or truncated
|
// success while storing nothing, reads that return corrupt or truncated
|
||||||
// bytes, and a delete the backend refuses. It is the seam called for by
|
// bytes, and a delete or a stat the backend refuses. It is the seam
|
||||||
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
|
// called for by the fault-injection tests (sneak/vaultik issue 72) and
|
||||||
// reused by future tests rather than re-implemented per case.
|
// is meant to be reused by future tests rather than re-implemented per
|
||||||
|
// case.
|
||||||
//
|
//
|
||||||
// The wrapper delegates every method to the inner Storer. Three hooks
|
// The wrapper delegates every method to the inner Storer. Four hooks
|
||||||
// change that: OnPut decides the fate of each write, OnGet decides how
|
// change that: OnPut decides the fate of each write, OnGet decides how
|
||||||
// each read's bytes are returned, and OnDelete decides whether each
|
// each read's bytes are returned, OnDelete decides whether each delete
|
||||||
// delete fails. All are keyed by the object key, so a test can fault
|
// fails, and OnStat whether each stat fails. All are keyed by the object
|
||||||
// only blobs, only metadata, or a single object.
|
// key, so a test can fault only blobs, only metadata, or a single
|
||||||
|
// object.
|
||||||
package faultstore
|
package faultstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,6 +32,11 @@ var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
|
|||||||
// fail.
|
// fail.
|
||||||
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
|
||||||
|
|
||||||
|
// ErrInjectedStat is returned by a Stat the OnStat hook chose to fail.
|
||||||
|
// It is not storage.ErrNotFound: the backend refused the request, as S3
|
||||||
|
// refuses a HEAD to credentials that allow only PUT.
|
||||||
|
var ErrInjectedStat = errors.New("faultstore: injected stat failure")
|
||||||
|
|
||||||
// PutAction is the disposition OnPut assigns to a write.
|
// PutAction is the disposition OnPut assigns to a write.
|
||||||
type PutAction int
|
type PutAction int
|
||||||
|
|
||||||
@@ -76,6 +83,10 @@ type Storer struct {
|
|||||||
// object key. Returning true fails the delete and leaves the object
|
// object key. Returning true fails the delete and leaves the object
|
||||||
// in place.
|
// in place.
|
||||||
OnDelete func(key string) bool
|
OnDelete func(key string) bool
|
||||||
|
|
||||||
|
// OnStat, when set, is consulted before every Stat with the object
|
||||||
|
// key. Returning true fails the stat with ErrInjectedStat.
|
||||||
|
OnStat func(key string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New wraps inner. inner must be non-nil.
|
// New wraps inner. inner must be non-nil.
|
||||||
@@ -159,8 +170,12 @@ func damage(fault GetFault, data []byte) []byte {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat delegates unchanged.
|
// Stat returns the object's info unless OnStat fails the stat.
|
||||||
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
|
||||||
|
if f.OnStat != nil && f.OnStat(key) {
|
||||||
|
return nil, fmt.Errorf("%w for %q", ErrInjectedStat, key)
|
||||||
|
}
|
||||||
|
|
||||||
return f.inner.Stat(ctx, key)
|
return f.inner.Stat(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ type FileStorer struct {
|
|||||||
// NewFileStorer creates a new filesystem storage backend.
|
// NewFileStorer creates a new filesystem storage backend.
|
||||||
//
|
//
|
||||||
// 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 backend never creates basePath: the user does, so that when
|
||||||
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
|
// basePath is a directory on an unplugged volume, a write fails instead
|
||||||
// per-blob parent directory, which also covers basePath on first use.
|
// of landing on the disk under the mount point. This does not hold when
|
||||||
|
// basePath is the mount point itself, which stays behind when its volume
|
||||||
|
// is unplugged.
|
||||||
|
// Put and PutWithProgress fail on a missing basePath and create the
|
||||||
|
// directories below it as needed.
|
||||||
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
// Get and Stat report a key under a missing basePath as ErrNotFound.
|
||||||
// List and ListStream fail on a missing basePath, because listing it as
|
// List and ListStream fail on a missing basePath, because listing it as
|
||||||
// an empty store would make `prune` drop every local snapshot record.
|
// an empty store would make `prune` drop every local snapshot record.
|
||||||
@@ -231,8 +235,9 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
|||||||
// Normalize path separators
|
// Normalize path separators
|
||||||
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: relPath,
|
Key: relPath,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +304,19 @@ func (f *FileStorer) writeAtomic(
|
|||||||
path := f.fullPath(key)
|
path := f.fullPath(key)
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
// Checked on every write, so a destination that disappears part-way
|
||||||
|
// through a backup fails it rather than being recreated by MkdirAll.
|
||||||
|
_, err := f.fs.Stat(f.basePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("destination directory does not exist; "+
|
||||||
|
"create it, or mount the volume it is on: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = f.fs.MkdirAll(dir, storageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating directories: %w", err)
|
return fmt.Errorf("creating directories: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,3 +292,43 @@ func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFileStorerPutMissingDestination checks that a write fails when the
|
||||||
|
// destination directory has gone, even after earlier writes to it
|
||||||
|
// succeeded, and that it does not create the directory again.
|
||||||
|
func TestFileStorerPutMissingDestination(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
base := filepath.Join(t.TempDir(), "usbstick")
|
||||||
|
|
||||||
|
err := os.Mkdir(base, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mkdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := storage.NewFileStorer(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFileStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(base, base+"-unplugged")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Put = %v, want a not-exist error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(base)
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("Put created the destination directory again (Stat = %v)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e
|
|||||||
rclonePath += path
|
rclonePath += path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On S3, Swift and some other remotes rclone keeps a time of its own in
|
||||||
|
// each object's metadata, which costs one request per object to read.
|
||||||
|
// rclone's use_server_modtime setting makes ModTime the time the
|
||||||
|
// listing gives instead: when the object was stored, which is what
|
||||||
|
// prune compares with prune_grace_period.
|
||||||
|
ctx, ci := fs.AddConfig(ctx)
|
||||||
|
ci.UseServerModTime = true
|
||||||
|
|
||||||
// Create the rclone filesystem
|
// Create the rclone filesystem
|
||||||
fsys, err := fs.NewFs(ctx, rclonePath)
|
fsys, err := fs.NewFs(ctx, rclonePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,8 +219,9 @@ func (r *RcloneStorer) ListStream(
|
|||||||
|
|
||||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: obj.Size(),
|
Size: obj.Size(),
|
||||||
|
ModTime: obj.ModTime(ctx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/johannesboyne/gofakes3"
|
||||||
|
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
||||||
"github.com/rclone/rclone/fs"
|
"github.com/rclone/rclone/fs"
|
||||||
"github.com/rclone/rclone/fs/config"
|
"github.com/rclone/rclone/fs/config"
|
||||||
"github.com/rclone/rclone/fs/config/configmap"
|
"github.com/rclone/rclone/fs/config/configmap"
|
||||||
@@ -429,3 +435,72 @@ func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
|
|||||||
t.Errorf("object is not in the remote's directory: %v", err)
|
t.Errorf("object is not in the remote's directory: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRcloneListStreamOnS3MakesNoRequestPerObject checks that listing an
|
||||||
|
// rclone S3 remote takes each object's time from the listing itself. By
|
||||||
|
// default rclone reads the time it keeps in each object's metadata, one
|
||||||
|
// request per object, and every command that lists the destination store
|
||||||
|
// would slow down as it grows.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
|
||||||
|
func TestRcloneListStreamOnS3MakesNoRequestPerObject(t *testing.T) {
|
||||||
|
backend := s3mem.New()
|
||||||
|
|
||||||
|
err := backend.CreateBucket(s3TestBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create bucket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = "blob-bytes"
|
||||||
|
|
||||||
|
_, err = backend.PutObject(s3TestBucket, testBlobKey, nil,
|
||||||
|
strings.NewReader(body), int64(len(body)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed object: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var headRequests atomic.Int64
|
||||||
|
|
||||||
|
fakeS3 := gofakes3.New(backend).Server()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
headRequests.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeS3.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
remote := ":s3,provider=Other" +
|
||||||
|
",access_key_id=" + s3TestAccessKeyID +
|
||||||
|
",secret_access_key=" + s3TestSecretAccessKey +
|
||||||
|
",endpoint='" + srv.URL + "'"
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
s, err := storage.NewRcloneStorer(ctx, remote, s3TestBucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRcloneStorer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headRequests.Store(0)
|
||||||
|
|
||||||
|
var listed []storage.ObjectInfo
|
||||||
|
|
||||||
|
for info := range s.ListStream(ctx, "blobs/") {
|
||||||
|
listed = append(listed, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(listed) != 1 || listed[0].Err != nil {
|
||||||
|
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(listed[0].ModTime).Abs() > time.Minute {
|
||||||
|
t.Errorf("ListStream ModTime = %v, want within a minute of now",
|
||||||
|
listed[0].ModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := headRequests.Load(); n != 0 {
|
||||||
|
t.Errorf("ListStream made %d HEAD request(s), want none", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,9 +89,10 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
|
|||||||
|
|
||||||
for info := range s.client.ListObjectsStream(ctx, prefix, false) {
|
for info := range s.client.ListObjectsStream(ctx, prefix, false) {
|
||||||
ch <- ObjectInfo{
|
ch <- ObjectInfo{
|
||||||
Key: info.Key,
|
Key: info.Key,
|
||||||
Size: info.Size,
|
Size: info.Size,
|
||||||
Err: info.Err,
|
ModTime: info.ModTime,
|
||||||
|
Err: info.Err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
|
|||||||
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
|
||||||
// listing page marked truncated that carries no continuation token, as a
|
// listing page marked truncated that carries no continuation token, as a
|
||||||
// server answering V2 listings V1-style sends it. Taken as the last page,
|
// server answering V2 listings V1-style sends it. Taken as the last page,
|
||||||
// it would hide every later key, and prune would treat the blobs of the
|
// it would hide every later key: prune would treat the blobs of the
|
||||||
// snapshots listed there as unreferenced.
|
// snapshots listed there as unreferenced, and a directory under metadata/
|
||||||
|
// whose manifest is listed there as one with no manifest.
|
||||||
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
func TestS3ListingCutShortIsAnError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ func TestS3ListingCutShortIsAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
// TestS3URLPrefixKeyLayout pins the bucket keys an s3:// URL reads and
|
||||||
// writes: the README's remote storage layout, with the prefix joined to
|
// writes: the layout in docs/REPOSTRUCTURE.md, with the prefix joined to
|
||||||
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
// each key by one "/". s3://b/p and s3://b/p/ must be the same
|
||||||
// destination, or a host that writes the URL the other way finds no
|
// destination, or a host that writes the URL the other way finds no
|
||||||
// snapshots. The listed object is put straight into the bucket, as
|
// snapshots. The listed object is put straight into the bucket, as
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound is returned when an object does not exist.
|
// ErrNotFound is returned when an object does not exist.
|
||||||
@@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error
|
|||||||
type ObjectInfo struct {
|
type ObjectInfo struct {
|
||||||
Key string // Object key/path
|
Key string // Object key/path
|
||||||
Size int64 // Size in bytes
|
Size int64 // Size in bytes
|
||||||
Err error // Error for streaming results (nil on success)
|
// ModTime is when the object was written, as the store reports it.
|
||||||
|
// Only ListStream sets it; Stat leaves it zero.
|
||||||
|
ModTime time.Time
|
||||||
|
Err error // Error for streaming results (nil on success)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info provides human-readable storage configuration.
|
// Info provides human-readable storage configuration.
|
||||||
@@ -41,7 +45,8 @@ type Info struct {
|
|||||||
// directory for filesystem).
|
// directory for filesystem).
|
||||||
type Storer interface {
|
type Storer interface {
|
||||||
// Put stores data at the specified key.
|
// Put stores data at the specified key.
|
||||||
// Parent directories are created automatically for filesystem backends.
|
// The filesystem backend fails when its destination directory does
|
||||||
|
// not exist, and creates the directories below it as needed.
|
||||||
Put(ctx context.Context, key string, data io.Reader) error
|
Put(ctx context.Context, key string, data io.Reader) error
|
||||||
|
|
||||||
// PutWithProgress stores data with progress reporting.
|
// PutWithProgress stores data with progress reporting.
|
||||||
@@ -66,7 +71,8 @@ type Storer interface {
|
|||||||
// For large result sets, prefer ListStream.
|
// For large result sets, prefer ListStream.
|
||||||
List(ctx context.Context, prefix string) ([]string, error)
|
List(ctx context.Context, prefix string) ([]string, error)
|
||||||
|
|
||||||
// ListStream returns a channel of ObjectInfo for large result sets.
|
// ListStream returns a channel of ObjectInfo, with each object's key,
|
||||||
|
// size and modification time, for large result sets.
|
||||||
// The channel is closed when listing completes.
|
// The channel is closed when listing completes.
|
||||||
// If an error occurs during listing, the final item will have Err set.
|
// If an error occurs during listing, the final item will have Err set.
|
||||||
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
|
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
|
||||||
|
|||||||
+47
-6
@@ -30,6 +30,12 @@ var (
|
|||||||
"storage URL must not carry credentials; " +
|
"storage URL must not carry credentials; " +
|
||||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||||
|
ErrURLBadQuery = errors.New(
|
||||||
|
"storage URL query cannot be decoded; " +
|
||||||
|
"check it for a ; or a % not followed by two hex digits")
|
||||||
|
ErrRcloneRemoteParams = errors.New(
|
||||||
|
"rclone URL must not carry parameters in the remote name; " +
|
||||||
|
"set them on the remote with rclone config instead")
|
||||||
)
|
)
|
||||||
|
|
||||||
// URL represents a parsed storage URL.
|
// URL represents a parsed storage URL.
|
||||||
@@ -90,8 +96,9 @@ func parseFileURL(rawPath string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||||
// the userinfo and any query parameter other than endpoint, region and
|
// the userinfo or in the endpoint parameter's userinfo, a query that
|
||||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
// cannot be decoded, and any query parameter other than endpoint, region
|
||||||
|
// and ssl, so a credential-bearing URL is never stored or echoed.
|
||||||
func parseS3URL(rawURL string) (*URL, error) {
|
func parseS3URL(rawURL string) (*URL, error) {
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,13 +114,26 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrMissingBucket
|
return nil, ErrMissingBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
query := u.Query()
|
// u.Query() would silently drop a part it cannot decode, endpoint
|
||||||
|
// included. The decode error is not wrapped: it can quote the value.
|
||||||
|
query, err := url.ParseQuery(u.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrURLBadQuery
|
||||||
|
}
|
||||||
|
|
||||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A host name never contains "@", so one in an endpoint, with or
|
||||||
|
// without a scheme, can only introduce userinfo.
|
||||||
|
for _, endpoint := range query["endpoint"] {
|
||||||
|
if strings.Contains(endpoint, "@") {
|
||||||
|
return nil, ErrURLCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &URL{
|
return &URL{
|
||||||
Scheme: schemeS3,
|
Scheme: schemeS3,
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -125,9 +145,23 @@ func parseS3URL(rawURL string) (*URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||||
// query parameters, so credentials in the userinfo and any parameter at
|
// query parameters, so credentials in the userinfo, parameters in the
|
||||||
|
// remote name, a query that cannot be decoded and any query parameter at
|
||||||
// all are rejected rather than silently ignored.
|
// all are rejected rather than silently ignored.
|
||||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||||
|
// The remote name runs up to the first "/", "?" or "#". It never
|
||||||
|
// contains a comma; one starts rclone's remote,param=value form,
|
||||||
|
// whose values can be credentials. This is checked before url.Parse,
|
||||||
|
// whose error for such a name can print part of it.
|
||||||
|
remote := strings.TrimPrefix(rawURL, "rclone://")
|
||||||
|
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
|
||||||
|
remote = remote[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(remote, ",") {
|
||||||
|
return nil, ErrRcloneRemoteParams
|
||||||
|
}
|
||||||
|
|
||||||
u, err := url.Parse(rawURL)
|
u, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapParseError(err)
|
return nil, wrapParseError(err)
|
||||||
@@ -137,12 +171,19 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
|||||||
return nil, ErrURLCredentials
|
return nil, ErrURLCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
remote := u.Host
|
remote = u.Host
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
return nil, ErrMissingRemote
|
return nil, ErrMissingRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rejectUnknownParams(u.Query())
|
// As in parseS3URL: u.Query() would drop a part it cannot decode, and
|
||||||
|
// the decode error can quote the value.
|
||||||
|
query, err := url.ParseQuery(u.RawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrURLBadQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rejectUnknownParams(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,19 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
wantErr: storage.ErrURLCredentials,
|
wantErr: storage.ErrURLCredentials,
|
||||||
secrets: []string{key, secret, "mybucket"},
|
secrets: []string{key, secret, "mybucket"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "s3 userinfo in endpoint",
|
||||||
|
raw: "s3://mybucket?endpoint=https://" +
|
||||||
|
key + ":" + secret + "@minio.example.com",
|
||||||
|
wantErr: storage.ErrURLCredentials,
|
||||||
|
secrets: []string{key, secret},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "s3 userinfo in endpoint without scheme",
|
||||||
|
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
|
||||||
|
wantErr: storage.ErrURLCredentials,
|
||||||
|
secrets: []string{key, secret},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "s3 unknown query param",
|
name: "s3 unknown query param",
|
||||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||||
@@ -203,6 +216,112 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
||||||
|
// remote name carrying parameters (rclone's "remote,param=value" form,
|
||||||
|
// which can hold a credential) is rejected without echoing the value,
|
||||||
|
// including the URLs that url.Parse itself refuses.
|
||||||
|
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
secrets []string // must not appear in the error message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "configured remote",
|
||||||
|
raw: "rclone://gdrive,client_secret=topsecret/backups",
|
||||||
|
secrets: []string{"topsecret"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote defined inline",
|
||||||
|
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
|
||||||
|
secrets: []string{"KEY", "SECRET"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colon in a parameter value",
|
||||||
|
raw: "rclone://gdrive,client_secret=ab:cd/backups",
|
||||||
|
secrets: []string{"cd"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := storage.ParseStorageURL(tc.raw)
|
||||||
|
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
||||||
|
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||||
|
tc.raw, err, storage.ErrRcloneRemoteParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secret := range tc.secrets {
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 or rclone
|
||||||
|
// URL whose query cannot be decoded is rejected, not read with that part
|
||||||
|
// of the query left out, and that the error does not echo it.
|
||||||
|
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
key = "AKIAKEY"
|
||||||
|
secret = "topsecret"
|
||||||
|
)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "semicolon",
|
||||||
|
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||||
|
"@minio.example.com:9000;region=us-east-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad percent escape",
|
||||||
|
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||||
|
"%zz@minio.example.com:9000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rclone semicolon",
|
||||||
|
raw: "rclone://gdrive/backups?token=" + secret + ";x=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rclone bad percent escape",
|
||||||
|
raw: "rclone://gdrive/backups?token=" + secret + "%zz",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := storage.ParseStorageURL(tc.raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, storage.ErrURLBadQuery) {
|
||||||
|
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
||||||
|
tc.raw, err, storage.ErrURLBadQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range []string{key, secret, "minio.example.com"} {
|
||||||
|
if strings.Contains(err.Error(), s) {
|
||||||
|
t.Errorf("error message leaked %q: %v", s, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||||
// itself fails, the wrapped error carries only the inner cause, not the
|
// itself fails, the wrapped error carries only the inner cause, not the
|
||||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
@@ -94,7 +94,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package vaultik
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// destinationMarkerKey is the object every backup writes at the root of
|
||||||
|
// the backup destination store. While the local index holds a snapshot
|
||||||
|
// stored there, a destination that answers a look-up of it with not
|
||||||
|
// found is treated as not present: an unplugged volume leaves an empty
|
||||||
|
// directory at its mount point, which would otherwise read as a store
|
||||||
|
// whose snapshots are all gone.
|
||||||
|
const destinationMarkerKey = "vaultik-destination.txt"
|
||||||
|
|
||||||
|
// destinationMarkerText is the whole content of the marker. It names no
|
||||||
|
// host, path or time.
|
||||||
|
const destinationMarkerText = "This is a vaultik backup destination store.\n"
|
||||||
|
|
||||||
|
// errDestinationNotPresent is returned when the local index holds a
|
||||||
|
// snapshot stored on the backup destination store and the store answers
|
||||||
|
// that it has no marker.
|
||||||
|
var errDestinationNotPresent = errors.New("backup destination store is not present")
|
||||||
|
|
||||||
|
// checkDestinationMarker returns errDestinationNotPresent when the local
|
||||||
|
// index holds a snapshot stored on the backup destination store and the
|
||||||
|
// store answers that it has no marker. With no snapshot in the local
|
||||||
|
// index, as on a first backup or on another machine, there is nothing to
|
||||||
|
// check; nor when the local index is bound to a different destination
|
||||||
|
// than the configured one, since its snapshots are stored there.
|
||||||
|
//
|
||||||
|
// A look-up of the marker that fails for any other reason than not
|
||||||
|
// found is logged and otherwise ignored, because the check could not be
|
||||||
|
// made: S3 refuses it to credentials that allow only PUT, which is all
|
||||||
|
// a backup needs. The check exists for a file:// volume's empty mount
|
||||||
|
// point, which always answers not found, and a destination that cannot
|
||||||
|
// be reached fails on the next request made to it, such as the backup's
|
||||||
|
// PUT of the marker.
|
||||||
|
func (v *Vaultik) checkDestinationMarker() error {
|
||||||
|
ids, err := v.localSnapshotIDs()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing local snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is nil only in tests, which then record no binding (see
|
||||||
|
// EnsureStorageBinding).
|
||||||
|
if v.Config != nil {
|
||||||
|
stored, configured, err := v.storageBinding()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stored != "" && stored != configured {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
|
||||||
|
"snapshots stored there; mount the volume if it is removable, "+
|
||||||
|
"or if the destination was emptied on purpose, run "+
|
||||||
|
"'vaultik database delete' and back up again",
|
||||||
|
errDestinationNotPresent, v.Storage.Info().Location,
|
||||||
|
destinationMarkerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info, not a warning: --cron must stay silent on a successful
|
||||||
|
// backup from a host whose credentials allow only PUT.
|
||||||
|
if err != nil {
|
||||||
|
log.Info("Could not check the backup destination store for its marker",
|
||||||
|
"key", destinationMarkerKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markDestination checks the backup destination store with
|
||||||
|
// checkDestinationMarker and then writes the marker, so a first backup
|
||||||
|
// marks its destination and a later one writes the same text again.
|
||||||
|
func (v *Vaultik) markDestination() error {
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = v.Storage.Put(v.ctx, destinationMarkerKey,
|
||||||
|
strings.NewReader(destinationMarkerText))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing %s to the backup destination store: %w",
|
||||||
|
destinationMarkerKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/storage/faultstore"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
|
||||||
|
// file:// destination at a volume's mount point leaves an empty directory
|
||||||
|
// behind when the volume is unplugged. While the local index holds
|
||||||
|
// snapshots, a destination that answers that it has no
|
||||||
|
// vaultik-destination.txt is not present, so no command may write to it
|
||||||
|
// or conclude from it that the local snapshots are gone.
|
||||||
|
|
||||||
|
// markerFile is the name of the file a backup writes at the root of the
|
||||||
|
// destination.
|
||||||
|
const markerFile = "vaultik-destination.txt"
|
||||||
|
|
||||||
|
// backUpThenUnplugMountPoint backs up to a file:// destination, then
|
||||||
|
// moves the destination directory away and leaves an empty directory in
|
||||||
|
// its place, as unplugging a volume mounted there would. It returns the
|
||||||
|
// path of that empty directory.
|
||||||
|
func backUpThenUnplugMountPoint(
|
||||||
|
ctx context.Context, t *testing.T,
|
||||||
|
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "mnt")
|
||||||
|
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
|
return v, repos, out, storeDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertUntouched checks that the local index still holds its one
|
||||||
|
// snapshot and that nothing was written to the empty mount point.
|
||||||
|
func assertUntouched(
|
||||||
|
ctx context.Context, t *testing.T,
|
||||||
|
repos *database.Repositories, storeDir string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries, "nothing may be written to the empty mount point")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirstBackupWritesMarker checks that a backup writes the marker
|
||||||
|
// file at the root of the destination.
|
||||||
|
func TestFirstBackupWritesMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
backUpToFileDestination(context.Background(), t, storeDir)
|
||||||
|
|
||||||
|
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
|
||||||
|
// --prune writes nothing and drops no local snapshot record when the
|
||||||
|
// destination has no marker.
|
||||||
|
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Cron: true,
|
||||||
|
Prune: true,
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on
|
||||||
|
// and writes the marker when looking it up fails with an error other
|
||||||
|
// than not found, as S3 answers credentials that allow only PUT
|
||||||
|
// (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker
|
||||||
|
// that is not found still stops it.
|
||||||
|
func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
// Removed so the test sees the backup write it again: with the
|
||||||
|
// look-up failing, the backup cannot tell that it is gone.
|
||||||
|
markerPath := filepath.Join(storeDir, markerFile)
|
||||||
|
require.NoError(t, os.Remove(markerPath))
|
||||||
|
|
||||||
|
store := faultstore.New(v.Storage)
|
||||||
|
store.OnStat = func(key string) bool { return key == markerFile }
|
||||||
|
v.Storage = store
|
||||||
|
|
||||||
|
require.NoError(t, backUp(v, "second"))
|
||||||
|
localSnapshotID(ctx, t, repos, "second")
|
||||||
|
assert.FileExists(t, markerPath)
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(markerPath))
|
||||||
|
|
||||||
|
store.OnStat = nil
|
||||||
|
|
||||||
|
require.ErrorContains(t, backUp(v, "third"), markerFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
|
||||||
|
// snapshot create writes nothing when the local index holds only an
|
||||||
|
// incomplete snapshot and the destination has no marker. The backup drops
|
||||||
|
// incomplete snapshots before it scans; checked after that, the local
|
||||||
|
// index would hold none and nothing would be checked.
|
||||||
|
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||||
|
ID: "host_first_2026-01-02T03:04:05Z",
|
||||||
|
Hostname: "host",
|
||||||
|
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||||
|
}))
|
||||||
|
|
||||||
|
err := backUp(v, "first")
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
|
||||||
|
// deletes no local snapshot record when the destination has no marker.
|
||||||
|
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.Prune(&vaultik.PruneOptions{Force: true})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
|
||||||
|
// fails and deletes no local snapshot record when the destination has no
|
||||||
|
// marker.
|
||||||
|
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||||
|
KeepLatest: true,
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke
|
||||||
|
// fails, deletes no local snapshot record, and neither reports the
|
||||||
|
// destination empty nor advises running nuke again when the destination
|
||||||
|
// has no marker.
|
||||||
|
func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t)
|
||||||
|
|
||||||
|
err := v.NukeRemote(true)
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||||
|
// remove warns that the metadata could not be removed from a destination
|
||||||
|
// without a marker, instead of reporting that it was.
|
||||||
|
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, _ := backUpThenUnplugMountPoint(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.Contains(t, out.String(), markerFile)
|
||||||
|
assert.NotContains(t, out.String(),
|
||||||
|
"Removed snapshot metadata from remote storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
|
||||||
|
// snapshot remove, for a snapshot that is only on the destination, fails
|
||||||
|
// saying the destination is not present when it has no marker, instead
|
||||||
|
// of saying the snapshot does not exist.
|
||||||
|
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "mnt")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
require.NoError(t, backUp(v, "second"))
|
||||||
|
|
||||||
|
id := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
_, err := v.RemoveSnapshot(id,
|
||||||
|
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Unplug the volume: an empty directory stays at its mount point.
|
||||||
|
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
|
||||||
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
||||||
|
|
||||||
|
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
|
||||||
|
require.ErrorContains(t, err, markerFile)
|
||||||
|
require.ErrorContains(t, err, "mount the volume")
|
||||||
|
|
||||||
|
assertUntouched(ctx, t, repos, storeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
|
||||||
|
// list warns and shows the local index alone when the destination has no
|
||||||
|
// marker, without reporting the local snapshot as missing from it.
|
||||||
|
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out, _ := backUpThenUnplugMountPoint(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(), markerFile)
|
||||||
|
assert.Contains(t, out.String(), id)
|
||||||
|
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
|
||||||
|
// snapshot list, after storage_url is changed to an empty directory,
|
||||||
|
// warns only that the local index is bound to a different destination:
|
||||||
|
// the local snapshots are stored at the old one, so the new one's lack
|
||||||
|
// of a marker says nothing. With --json the local row is not on the new
|
||||||
|
// destination, so remote_present is false, not unknown.
|
||||||
|
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
|
||||||
|
id := localSnapshotID(ctx, t, repos, "first")
|
||||||
|
|
||||||
|
newDir := t.TempDir()
|
||||||
|
store, err := storage.NewFileStorer(newDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
v.Storage = store
|
||||||
|
v.Config.StorageURL = "file://" + newDir
|
||||||
|
|
||||||
|
require.NoError(t, v.ListSnapshots(false))
|
||||||
|
|
||||||
|
assert.Contains(t, out.String(), id)
|
||||||
|
assert.Contains(t, out.String(),
|
||||||
|
"local index is bound to a different backup destination")
|
||||||
|
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||||
|
assert.NotContains(t, out.String(), markerFile)
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
require.NoError(t, v.ListSnapshots(true))
|
||||||
|
|
||||||
|
rows := decodeListJSON(t, out.String())
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.NotNil(t, rows[0].RemotePresent)
|
||||||
|
assert.False(t, *rows[0].RemotePresent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
|
||||||
|
// destination ready for a new first backup, which writes the marker
|
||||||
|
// again.
|
||||||
|
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
|
||||||
|
|
||||||
|
require.NoError(t, v.NukeRemote(true))
|
||||||
|
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
|
||||||
|
|
||||||
|
require.NoError(t, backUp(v, "second"))
|
||||||
|
assert.FileExists(t, filepath.Join(storeDir, markerFile))
|
||||||
|
}
|
||||||
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -298,10 +298,6 @@ 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)
|
||||||
|
|
||||||
@@ -365,7 +361,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -429,7 +425,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -445,6 +441,10 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
// snapshot create writes the marker; this backup runs its steps one
|
||||||
|
// by one, so the marker is written here.
|
||||||
|
require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
// Back up and complete with a working backend.
|
// Back up and complete with a working backend.
|
||||||
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
|
||||||
scanner := newFaultScanner(fs, inner, cfg, repos)
|
scanner := newFaultScanner(fs, inner, cfg, repos)
|
||||||
@@ -493,8 +493,14 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
|||||||
"shallow verify must report the missing manifest")
|
"shallow verify must report the missing manifest")
|
||||||
|
|
||||||
// snapshot list must not crash on the partial snapshot.
|
// snapshot list must not crash on the partial snapshot.
|
||||||
|
var listOut strings.Builder
|
||||||
|
|
||||||
|
reader.UI = ui.NewWithColor(&listOut, false)
|
||||||
require.NoError(t, reader.ListSnapshots(false),
|
require.NoError(t, reader.ListSnapshots(false),
|
||||||
"snapshot list must tolerate a partially-exported snapshot")
|
"snapshot list must tolerate a partially-exported snapshot")
|
||||||
|
assert.NotContains(t, listOut.String(),
|
||||||
|
"Could not list backup destination store",
|
||||||
|
"snapshot list must read the partial snapshot from the destination")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scenario 2, repair: the process dies during the metadata export of a
|
// Scenario 2, repair: the process dies during the metadata export of a
|
||||||
@@ -511,7 +517,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -661,17 +667,19 @@ func faultScannerFactory(
|
|||||||
) snapshot.ScannerFactory {
|
) snapshot.ScannerFactory {
|
||||||
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
return func(params snapshot.ScannerParams) *snapshot.Scanner {
|
||||||
return snapshot.NewScanner(snapshot.ScannerConfig{
|
return snapshot.NewScanner(snapshot.ScannerConfig{
|
||||||
FS: params.Fs,
|
FS: params.Fs,
|
||||||
Storage: storer,
|
Storage: storer,
|
||||||
ChunkSize: faultChunkSize,
|
ChunkSize: faultChunkSize,
|
||||||
MaxBlobSize: faultMaxBlobSize,
|
MaxBlobSize: faultMaxBlobSize,
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
Repositories: repos,
|
Repositories: repos,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
UI: params.UI,
|
UI: params.UI,
|
||||||
Exclude: params.Exclude,
|
Exclude: params.Exclude,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
|
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||||
|
DeviceNumber: params.DeviceNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,7 +694,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
|||||||
osFS := afero.NewOsFs()
|
osFS := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, size := range allBlobs {
|
for _, blob := range allBlobs {
|
||||||
result.TotalBlobCount++
|
result.TotalBlobCount++
|
||||||
result.TotalBlobSize += size
|
result.TotalBlobSize += blob.Size
|
||||||
}
|
}
|
||||||
|
|
||||||
// A blob named only by a manifest that could not be read, or by one
|
// A blob named only by a manifest that could not be read, or by one
|
||||||
@@ -474,10 +474,10 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
|||||||
orphanedSize int64
|
orphanedSize int64
|
||||||
)
|
)
|
||||||
|
|
||||||
for hash, size := range allBlobs {
|
for hash, blob := range allBlobs {
|
||||||
if _, referenced := referencedBlobs[hash]; !referenced {
|
if _, referenced := referencedBlobs[hash]; !referenced {
|
||||||
orphanedCount++
|
orphanedCount++
|
||||||
orphanedSize += size
|
orphanedSize += blob.Size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ 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.
|
||||||
|
|
||||||
// backUpToFileDestination backs up the snapshot named "first" to a
|
// newFileDestinationVaultik returns a Vaultik that backs up the
|
||||||
// file:// destination at storeDir, which need not exist yet. Everything
|
// snapshot named "first" to a file:// destination at storeDir.
|
||||||
// the returned Vaultik prints after the backup goes to the returned
|
func newFileDestinationVaultik(
|
||||||
// buffer.
|
|
||||||
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) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
osFs := afero.NewOsFs()
|
osFs := afero.NewOsFs()
|
||||||
@@ -49,8 +47,22 @@ func backUpToFileDestination(
|
|||||||
|
|
||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
cfg := changedFileConfig(dataDir, dbPath)
|
cfg := changedFileConfig(dataDir, dbPath)
|
||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
|
|
||||||
|
|
||||||
|
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
|
||||||
|
}
|
||||||
|
|
||||||
|
// backUpToFileDestination creates the directory storeDir, as a user must
|
||||||
|
// before the first backup, and backs up the snapshot named "first" to a
|
||||||
|
// file:// destination there. 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()
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(storeDir, 0o750))
|
||||||
|
|
||||||
|
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
|
||||||
require.NoError(t, backUp(v, "first"))
|
require.NoError(t, backUp(v, "first"))
|
||||||
|
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
@@ -75,21 +87,28 @@ func backUpThenUnplug(
|
|||||||
return v, repos, out
|
return v, repos, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
|
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
|
||||||
// to a destination directory that does not exist yet creates it, and
|
// a file:// destination whose directory does not exist fails before it
|
||||||
// that the destination can be listed afterwards.
|
// records a snapshot, and does not create the directory.
|
||||||
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
|
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
volume := filepath.Join(t.TempDir(), "volume")
|
||||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
||||||
|
|
||||||
require.NoError(t, v.ListSnapshots(false))
|
err := backUp(v, "first")
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
||||||
|
|
||||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
_, err = os.Stat(volume)
|
||||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
require.ErrorIs(t, err, fs.ErrNotExist,
|
||||||
|
"the backup must not create the destination directory")
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, snapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||||
@@ -169,3 +188,41 @@ func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
|
|||||||
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
|
||||||
err.Error())
|
err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteKeepsLocalIndexWhenDestinationMissing checks that remote
|
||||||
|
// nuke fails on a destination it cannot list, leaves the local index
|
||||||
|
// unchanged, and neither reports the destination empty nor advises
|
||||||
|
// running nuke again.
|
||||||
|
func TestNukeRemoteKeepsLocalIndexWhenDestinationMissing(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
v, repos, out := backUpThenUnplug(ctx, t)
|
||||||
|
|
||||||
|
filesBefore, err := repos.Files.ListAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, filesBefore)
|
||||||
|
|
||||||
|
blobsBefore, err := repos.Blobs.GetAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, blobsBefore)
|
||||||
|
|
||||||
|
err = v.NukeRemote(true)
|
||||||
|
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
require.ErrorContains(t, err, "listing remote snapshots")
|
||||||
|
assert.NotContains(t, out.String(), "now empty")
|
||||||
|
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "nuke must delete no local snapshot record")
|
||||||
|
|
||||||
|
files, err := repos.Files.ListAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, files, len(filesBefore), "nuke must delete no local file record")
|
||||||
|
|
||||||
|
blobs, err := repos.Blobs.GetAll(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, blobs, len(blobsBefore), "nuke must delete no local blob record")
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
|
|||||||
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
|
||||||
// error when a snapshot's metadata or a blob cannot be deleted, does not
|
// error when a snapshot's metadata or a blob cannot be deleted, does not
|
||||||
// report the store empty, and advises running nuke again: prune deletes
|
// report the store empty, and advises running nuke again: prune deletes
|
||||||
// no metadata.
|
// no snapshot metadata that has its manifest.
|
||||||
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -104,6 +104,32 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted checks that
|
||||||
|
// remote nuke keeps a snapshot in the local index while its metadata
|
||||||
|
// cannot be deleted from the destination store: dropped from the local
|
||||||
|
// index, it would be left there as a remote-only snapshot without its
|
||||||
|
// name.
|
||||||
|
func TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := filepath.Join(t.TempDir(), "store")
|
||||||
|
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
|
||||||
|
|
||||||
|
store := faultstore.New(v.Storage)
|
||||||
|
store.OnDelete = func(key string) bool {
|
||||||
|
return strings.HasPrefix(key, "metadata/")
|
||||||
|
}
|
||||||
|
v.Storage = store
|
||||||
|
|
||||||
|
require.Error(t, v.NukeRemote(true))
|
||||||
|
|
||||||
|
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1)
|
||||||
|
}
|
||||||
|
|
||||||
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
|
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
|
||||||
// it does not recognise cannot be deleted, does not report the store
|
// it does not recognise cannot be deleted, does not report the store
|
||||||
// empty but names that object and fails.
|
// empty but names that object and fails.
|
||||||
@@ -154,8 +180,7 @@ func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(t *testing.T) {
|
|||||||
|
|
||||||
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
||||||
// snapshot remote nuke cannot remove from the local index fails the
|
// snapshot remote nuke cannot remove from the local index fails the
|
||||||
// run: nuke then leaves that snapshot's metadata on the destination
|
// run, and that nuke then does not report the destination store empty.
|
||||||
// store.
|
|
||||||
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
+322
-81
@@ -4,9 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PruneOptions contains options for the prune command
|
// PruneOptions contains options for the prune command
|
||||||
@@ -22,6 +26,11 @@ var errNukeRequiresForce = errors.New(
|
|||||||
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
|
||||||
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
|
||||||
|
|
||||||
|
// errMetadataDirDeleteFailed reports directories under metadata/ with no
|
||||||
|
// manifest that prune could not delete.
|
||||||
|
var errMetadataDirDeleteFailed = errors.New(
|
||||||
|
"metadata director(ies) with no manifest could not be deleted")
|
||||||
|
|
||||||
// errObjectsLeft reports objects remote nuke still finds on the backup
|
// errObjectsLeft reports objects remote nuke still finds on the backup
|
||||||
// destination store after deleting.
|
// destination store after deleting.
|
||||||
var errObjectsLeft = errors.New(
|
var errObjectsLeft = errors.New(
|
||||||
@@ -29,7 +38,8 @@ var errObjectsLeft = errors.New(
|
|||||||
|
|
||||||
// nukeCommandHint is the command suggested when a remote nuke could not
|
// nukeCommandHint is the command suggested when a remote nuke could not
|
||||||
// delete everything: running it again deletes what is left, which
|
// delete everything: running it again deletes what is left, which
|
||||||
// `vaultik prune` does not do for snapshot metadata.
|
// `vaultik prune` does not do for snapshot metadata that has its
|
||||||
|
// manifest.
|
||||||
const nukeCommandHint = "vaultik remote nuke --force"
|
const nukeCommandHint = "vaultik remote nuke --force"
|
||||||
|
|
||||||
// metadataDirName is the top-level remote directory holding snapshot
|
// metadataDirName is the top-level remote directory holding snapshot
|
||||||
@@ -42,9 +52,10 @@ const blobKeyParts = 4
|
|||||||
|
|
||||||
// NukeRemote deletes every object under metadata/ and blobs/ in remote
|
// NukeRemote deletes every object under metadata/ and blobs/ in remote
|
||||||
// storage, whatever its name, along with any object an upload cut off
|
// storage, whatever its name, along with any object an upload cut off
|
||||||
// part-way left under a temporary `.partial` name. It returns
|
// part-way left under a temporary `.partial` name, and last the
|
||||||
// successfully only when a listing afterwards finds nothing left there,
|
// destination marker. It returns successfully only when a listing
|
||||||
// and the next backup then starts from scratch.
|
// afterwards finds nothing left under metadata/ and blobs/, and the next
|
||||||
|
// backup then starts from scratch.
|
||||||
//
|
//
|
||||||
// Refuses to run unless force is true. The caller is responsible for
|
// Refuses to run unless force is true. The caller is responsible for
|
||||||
// confirming with the user.
|
// confirming with the user.
|
||||||
@@ -54,12 +65,19 @@ func (v *Vaultik) NukeRemote(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Checked before anything is deleted: running nuke again would fail
|
// Checked before anything is deleted: running nuke again would fail
|
||||||
// the same way, so the advice below must not follow this error.
|
// the same way, so the advice below must not follow these errors.
|
||||||
|
// The listing also fails on a destination that answers that it has
|
||||||
|
// no marker while the local index holds snapshots.
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = v.listAllRemoteSnapshotKeys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err = v.emptyDestinationStore()
|
err = v.emptyDestinationStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.UI.Warningf("Run '%s' again to delete what is left on the "+
|
v.UI.Warningf("Run '%s' again to delete what is left on the "+
|
||||||
@@ -105,6 +123,13 @@ func (v *Vaultik) emptyDestinationStore() error {
|
|||||||
return fmt.Errorf("%d %w", len(left), errObjectsLeft)
|
return fmt.Errorf("%d %w", len(left), errObjectsLeft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, once the local snapshot records are gone: the next backup
|
||||||
|
// then has none and writes the marker again.
|
||||||
|
err = v.Storage.Delete(v.ctx, destinationMarkerKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,14 +173,24 @@ type PruneBlobsResult struct {
|
|||||||
BlobsDeleted int `json:"blobs_deleted"`
|
BlobsDeleted int `json:"blobs_deleted"`
|
||||||
BlobsFailed int `json:"blobs_failed,omitempty"`
|
BlobsFailed int `json:"blobs_failed,omitempty"`
|
||||||
BytesFreed int64 `json:"bytes_freed"`
|
BytesFreed int64 `json:"bytes_freed"`
|
||||||
|
// BlobsTooNew counts the unreferenced blobs kept because they are
|
||||||
|
// newer than the prune grace period, or of unknown age. BlobsFound
|
||||||
|
// does not include them.
|
||||||
|
BlobsTooNew int `json:"blobs_too_new"`
|
||||||
|
// MetadataDirsDeleted counts the directories under metadata/ with no
|
||||||
|
// manifest that prune deleted, and MetadataDirsFailed those it could
|
||||||
|
// not delete.
|
||||||
|
MetadataDirsDeleted int `json:"metadata_dirs_deleted"`
|
||||||
|
MetadataDirsFailed int `json:"metadata_dirs_failed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune removes orphaned data from the local index database AND
|
// Prune removes orphaned data from the local index database AND, from the
|
||||||
// unreferenced blobs from the backup destination store. This is the
|
// backup destination store, unreferenced blobs and the directories under
|
||||||
// single user-facing prune entry point — the split between local and
|
// metadata/ with no manifest. This is the single user-facing prune entry
|
||||||
// remote cleanup is an implementation detail. Calling code should
|
// point — the split between local and remote cleanup is an
|
||||||
// prefer this method over PruneDatabase or PruneBlobs individually
|
// implementation detail. Calling code should prefer this method over
|
||||||
// unless it specifically wants one half.
|
// PruneDatabase or PruneBlobs individually unless it specifically wants
|
||||||
|
// one half.
|
||||||
func (v *Vaultik) Prune(opts *PruneOptions) error {
|
func (v *Vaultik) Prune(opts *PruneOptions) error {
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,11 +214,17 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
|
|||||||
return v.PruneBlobs(opts)
|
return v.PruneBlobs(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneBlobs removes unreferenced blobs from storage
|
// PruneBlobs removes unreferenced blobs from storage, and each directory
|
||||||
|
// under metadata/ that a backup left with no manifest.
|
||||||
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||||
log.Info("Starting prune operation")
|
log.Info("Starting prune operation")
|
||||||
|
|
||||||
allBlobsReferenced, err := v.collectReferencedBlobs()
|
metadataObjects, err := v.listMetadataObjects()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
allBlobsReferenced, err := v.collectReferencedBlobs(metadataObjects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -193,11 +234,22 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced)
|
unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
|
||||||
|
allBlobs, allBlobsReferenced)
|
||||||
|
|
||||||
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)}
|
dirsWithoutManifest, err := v.findMetadataDirsWithoutManifest(metadataObjects)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if len(unreferencedBlobs) == 0 {
|
result := &PruneBlobsResult{
|
||||||
|
BlobsFound: len(unreferencedBlobs),
|
||||||
|
BlobsTooNew: tooNew,
|
||||||
|
}
|
||||||
|
|
||||||
|
v.reportBlobsTooNew(tooNew, opts)
|
||||||
|
|
||||||
|
if len(unreferencedBlobs) == 0 && len(dirsWithoutManifest) == 0 {
|
||||||
log.Info("No unreferenced blobs found")
|
log.Info("No unreferenced blobs found")
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
@@ -210,38 +262,16 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Found unreferenced blobs",
|
log.Info("Found unreferenced blobs",
|
||||||
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
|
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize),
|
||||||
|
"metadata_dirs_without_manifest", len(dirsWithoutManifest))
|
||||||
|
|
||||||
if opts.Force && !opts.JSON {
|
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
|
||||||
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
|
dirsWithoutManifest) {
|
||||||
len(unreferencedBlobs), ubytes(totalSize))
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.Force && !opts.JSON {
|
|
||||||
// The count, the prompt and its answer bypass the UI: they are an
|
|
||||||
// interactive exchange the operator must see, so --quiet does not
|
|
||||||
// silence them.
|
|
||||||
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
|
|
||||||
len(unreferencedBlobs), ubytes(totalSize))
|
|
||||||
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
|
|
||||||
|
|
||||||
var confirm string
|
|
||||||
|
|
||||||
_, err = v.scanStdin(&confirm)
|
|
||||||
if err != nil {
|
|
||||||
v.printlnStdout("Cancelled")
|
|
||||||
|
|
||||||
return nil //nolint:nilerr // read failure means no confirmation
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.ToLower(confirm) != "y" {
|
|
||||||
v.printlnStdout("Cancelled")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
|
||||||
|
v.deleteMetadataDirs(dirsWithoutManifest, result)
|
||||||
|
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
err = v.outputPruneBlobsJSON(result)
|
err = v.outputPruneBlobsJSON(result)
|
||||||
@@ -249,33 +279,142 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
v.reportPruneDeletions(result, len(dirsWithoutManifest))
|
||||||
|
}
|
||||||
|
|
||||||
|
var failures []error
|
||||||
|
|
||||||
|
if result.BlobsFailed > 0 {
|
||||||
|
failures = append(failures,
|
||||||
|
fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MetadataDirsFailed > 0 {
|
||||||
|
failures = append(failures, fmt.Errorf("%d %w",
|
||||||
|
result.MetadataDirsFailed, errMetadataDirDeleteFailed))
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(failures...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmPrune states what prune found to delete, unreferenced blobs and
|
||||||
|
// directories under metadata/ with no manifest, naming each directory by
|
||||||
|
// its remote key, and, unless --force is given, asks the operator to
|
||||||
|
// confirm. Under --json it prints nothing and does not ask. It reports
|
||||||
|
// whether to go ahead.
|
||||||
|
func (v *Vaultik) confirmPrune(
|
||||||
|
opts *PruneOptions, blobCount int, blobsSize int64, dirs []string,
|
||||||
|
) bool {
|
||||||
|
if opts.JSON {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
dirCount := len(dirs)
|
||||||
|
blobsFound := fmt.Sprintf("Found %d unreferenced blob(s) totaling %s.",
|
||||||
|
blobCount, ubytes(blobsSize))
|
||||||
|
dirsFound := fmt.Sprintf("Found %d metadata director(ies) left by "+
|
||||||
|
"backups that stopped before their manifest:", dirCount)
|
||||||
|
|
||||||
|
if opts.Force {
|
||||||
|
if blobCount > 0 {
|
||||||
|
v.UI.Infof("%s", blobsFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dirCount > 0 {
|
||||||
|
v.UI.Infof("%s", dirsFound)
|
||||||
|
|
||||||
|
for _, remoteKey := range dirs {
|
||||||
|
v.UI.Detailf("metadata/%s/", remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The counts, the names, the prompt and its answer bypass the UI:
|
||||||
|
// they are an interactive exchange the operator must see, so --quiet
|
||||||
|
// does not silence them.
|
||||||
|
if blobCount > 0 {
|
||||||
|
v.stdoutf("%s\n", blobsFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dirCount > 0 {
|
||||||
|
v.stdoutf("%s\n", dirsFound)
|
||||||
|
|
||||||
|
for _, remoteKey := range dirs {
|
||||||
|
v.stdoutf(" metadata/%s/\n", remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case dirCount == 0:
|
||||||
|
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", blobCount)
|
||||||
|
case blobCount == 0:
|
||||||
|
v.stdoutf("\nDelete %d metadata director(ies)? [y/N] ", dirCount)
|
||||||
|
default:
|
||||||
|
v.stdoutf("\nDelete %d unreferenced blob(s) and %d metadata "+
|
||||||
|
"director(ies)? [y/N] ", blobCount, dirCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm string
|
||||||
|
|
||||||
|
// A failure to read the answer means no confirmation.
|
||||||
|
_, err := v.scanStdin(&confirm)
|
||||||
|
if err != nil || strings.ToLower(confirm) != "y" {
|
||||||
|
v.printlnStdout("Cancelled")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportPruneDeletions says how many blobs and metadata directories
|
||||||
|
// prune deleted, naming each kind only when it found some to delete.
|
||||||
|
func (v *Vaultik) reportPruneDeletions(result *PruneBlobsResult, dirCount int) {
|
||||||
|
if result.BlobsFound > 0 {
|
||||||
v.UI.Infof("Deleted %d blob(s) totaling %s.",
|
v.UI.Infof("Deleted %d blob(s) totaling %s.",
|
||||||
result.BlobsDeleted, ubytes(result.BytesFreed))
|
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.BlobsFailed > 0 {
|
if dirCount > 0 {
|
||||||
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed)
|
v.UI.Infof("Deleted %d metadata director(ies) with no manifest.",
|
||||||
|
result.MetadataDirsDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectReferencedBlobs downloads all manifests and returns the set of
|
// listMetadataObjects lists every object under metadata/ on the
|
||||||
// referenced blob hashes.
|
// destination store.
|
||||||
|
func (v *Vaultik) listMetadataObjects() ([]storage.ObjectInfo, error) {
|
||||||
|
log.Info("Listing remote snapshots")
|
||||||
|
|
||||||
|
var objects []storage.ObjectInfo
|
||||||
|
|
||||||
|
for object := range v.Storage.ListStream(v.ctx, "metadata/") {
|
||||||
|
if object.Err != nil {
|
||||||
|
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
objects = append(objects, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectReferencedBlobs downloads every manifest among the listed
|
||||||
|
// metadata objects and returns the set of referenced blob hashes.
|
||||||
//
|
//
|
||||||
// Every manifest must be read successfully. A manifest that cannot be
|
// Every manifest must be read successfully. A manifest that cannot be
|
||||||
// downloaded or decoded means its snapshot's blobs are unknown, so
|
// downloaded or decoded means its snapshot's blobs are unknown, so
|
||||||
// treating them as unreferenced would let prune delete data a snapshot
|
// treating them as unreferenced would let prune delete data a snapshot
|
||||||
// still needs. Rather than risk that silent loss, any failure returns an
|
// still needs. Rather than risk that silent loss, any failure returns an
|
||||||
// error naming the remote key and prune deletes nothing.
|
// error naming the remote key and prune deletes nothing.
|
||||||
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
func (v *Vaultik) collectReferencedBlobs(
|
||||||
log.Info("Listing remote snapshots")
|
metadataObjects []storage.ObjectInfo,
|
||||||
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
|
) (map[string]bool, error) {
|
||||||
|
// IDs returned by uniqueSnapshotIDs are remote keys (hashed
|
||||||
// subdirectories under metadata/), not human snapshot IDs.
|
// subdirectories under metadata/), not human snapshot IDs.
|
||||||
remoteKeys, err := v.listUniqueSnapshotIDs()
|
remoteKeys := uniqueSnapshotIDs(metadataObjects)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing snapshot keys: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
|
||||||
|
|
||||||
@@ -300,18 +439,14 @@ func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
|
|||||||
return allBlobsReferenced, nil
|
return allBlobsReferenced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// listUniqueSnapshotIDs returns deduplicated snapshot IDs from remote metadata
|
// uniqueSnapshotIDs returns deduplicated snapshot IDs from the listed
|
||||||
func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
// metadata objects.
|
||||||
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
|
func uniqueSnapshotIDs(metadataObjects []storage.ObjectInfo) []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
var snapshotIDs []string
|
var snapshotIDs []string
|
||||||
|
|
||||||
for object := range objectCh {
|
for _, object := range metadataObjects {
|
||||||
if object.Err != nil {
|
|
||||||
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(object.Key, "/")
|
parts := strings.Split(object.Key, "/")
|
||||||
if len(parts) >= minSnapshotIDParts &&
|
if len(parts) >= minSnapshotIDParts &&
|
||||||
parts[0] == metadataDirName && parts[1] != "" {
|
parts[0] == metadataDirName && parts[1] != "" {
|
||||||
@@ -326,14 +461,15 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshotIDs, nil
|
return snapshotIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage
|
// listAllRemoteBlobs returns a map of all blob hashes in remote storage
|
||||||
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
// to their listing entries.
|
||||||
|
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
|
||||||
log.Info("Listing all blobs in storage")
|
log.Info("Listing all blobs in storage")
|
||||||
|
|
||||||
allBlobs := make(map[string]int64)
|
allBlobs := make(map[string]storage.ObjectInfo)
|
||||||
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
|
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
|
||||||
|
|
||||||
for object := range blobObjectCh {
|
for object := range blobObjectCh {
|
||||||
@@ -357,7 +493,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
allBlobs[parts[3]] = object.Size
|
allBlobs[parts[3]] = object
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Found blobs in storage", "count", len(allBlobs))
|
log.Info("Found blobs in storage", "count", len(allBlobs))
|
||||||
@@ -365,30 +501,135 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
|||||||
return allBlobs, nil
|
return allBlobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUnreferencedBlobs returns blob hashes not referenced by any
|
// findUnreferencedBlobs returns the hashes of the blobs no manifest
|
||||||
// manifest and their total size.
|
// references that are older than the prune grace period, and their total
|
||||||
|
// size. It also returns how many unreferenced blobs it left out for
|
||||||
|
// being newer than that, or of unknown age because the listing gave no
|
||||||
|
// time for them.
|
||||||
func (v *Vaultik) findUnreferencedBlobs(
|
func (v *Vaultik) findUnreferencedBlobs(
|
||||||
allBlobs map[string]int64, referenced map[string]bool,
|
allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
|
||||||
) ([]string, int64) {
|
) ([]string, int64, int) {
|
||||||
var (
|
var (
|
||||||
unreferenced []string
|
unreferenced []string
|
||||||
totalSize int64
|
totalSize int64
|
||||||
|
tooNew int
|
||||||
)
|
)
|
||||||
|
|
||||||
for hash, size := range allBlobs {
|
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
|
||||||
if !referenced[hash] {
|
|
||||||
unreferenced = append(unreferenced, hash)
|
for hash, blob := range allBlobs {
|
||||||
totalSize += size
|
if referenced[hash] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if blob.ModTime.IsZero() || blob.ModTime.After(cutoff) {
|
||||||
|
tooNew++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unreferenced = append(unreferenced, hash)
|
||||||
|
totalSize += blob.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
return unreferenced, totalSize, tooNew
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMetadataDirsWithoutManifest returns the remote key of each
|
||||||
|
// directory under metadata/ that has no manifest.json.zst, such as one a
|
||||||
|
// backup leaves when its manifest upload fails after db.zst.age was
|
||||||
|
// uploaded. It leaves out a directory a local snapshot record claims,
|
||||||
|
// and one holding an object newer than the prune grace period, or of
|
||||||
|
// unknown age because the listing gave no time for it: a backup still
|
||||||
|
// running may be writing there. A name that is not a remote key is
|
||||||
|
// never returned.
|
||||||
|
func (v *Vaultik) findMetadataDirsWithoutManifest(
|
||||||
|
metadataObjects []storage.ObjectInfo,
|
||||||
|
) ([]string, error) {
|
||||||
|
localIDs, err := v.localSnapshotIDs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing local snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keep := make(map[string]bool, len(localIDs))
|
||||||
|
for _, id := range localIDs {
|
||||||
|
keep[snapshot.RemoteSnapshotKey(id)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
|
||||||
|
listed := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, object := range metadataObjects {
|
||||||
|
parts := strings.Split(object.Key, "/")
|
||||||
|
if len(parts) < metadataKeyParts || !isBlobHash(parts[1]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteKey := parts[1]
|
||||||
|
listed[remoteKey] = true
|
||||||
|
|
||||||
|
if parts[2] == "manifest.json.zst" ||
|
||||||
|
object.ModTime.IsZero() || object.ModTime.After(cutoff) {
|
||||||
|
keep[remoteKey] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unreferenced, totalSize
|
var remoteKeys []string
|
||||||
|
|
||||||
|
for remoteKey := range listed {
|
||||||
|
if !keep[remoteKey] {
|
||||||
|
remoteKeys = append(remoteKeys, remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(remoteKeys)
|
||||||
|
|
||||||
|
return remoteKeys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteMetadataDirs deletes everything in the given directories under
|
||||||
|
// metadata/ and counts in the result the directories deleted and those
|
||||||
|
// it could not delete.
|
||||||
|
func (v *Vaultik) deleteMetadataDirs(
|
||||||
|
remoteKeys []string, result *PruneBlobsResult,
|
||||||
|
) {
|
||||||
|
for _, remoteKey := range remoteKeys {
|
||||||
|
_, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to delete metadata directory with no manifest",
|
||||||
|
"remote_key", remoteKey, "error", err)
|
||||||
|
|
||||||
|
result.MetadataDirsFailed++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Deleted metadata directory with no manifest",
|
||||||
|
"remote_key", remoteKey)
|
||||||
|
|
||||||
|
result.MetadataDirsDeleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportBlobsTooNew says how many unreferenced blobs prune kept for being
|
||||||
|
// newer than the grace period or of unknown age. Under --json the
|
||||||
|
// document carries the count instead.
|
||||||
|
func (v *Vaultik) reportBlobsTooNew(tooNew int, opts *PruneOptions) {
|
||||||
|
if tooNew == 0 || opts.JSON {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v.UI.Infof("Kept %d unreferenced blob(s) newer than "+
|
||||||
|
"prune_grace_period (%s), or of unknown age: a backup still "+
|
||||||
|
"running may need them.",
|
||||||
|
tooNew, v.Config.PruneGracePeriod)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteUnreferencedBlobs deletes the given blobs from storage and
|
// deleteUnreferencedBlobs deletes the given blobs from storage and
|
||||||
// populates the result.
|
// populates the result.
|
||||||
func (v *Vaultik) deleteUnreferencedBlobs(
|
func (v *Vaultik) deleteUnreferencedBlobs(
|
||||||
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult,
|
unreferencedBlobs []string, allBlobs map[string]storage.ObjectInfo,
|
||||||
|
result *PruneBlobsResult,
|
||||||
) {
|
) {
|
||||||
log.Info("Deleting unreferenced blobs")
|
log.Info("Deleting unreferenced blobs")
|
||||||
|
|
||||||
@@ -403,7 +644,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.BlobsDeleted++
|
result.BlobsDeleted++
|
||||||
result.BytesFreed += allBlobs[hash]
|
result.BytesFreed += allBlobs[hash].Size
|
||||||
|
|
||||||
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
|
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
|
||||||
log.Info("Deletion progress",
|
log.Info("Deletion progress",
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPruneDuringBackupKeepsItsBlobs runs a prune from another host
|
||||||
|
// sharing the destination store at the moment a backup has uploaded its
|
||||||
|
// blobs and its db.zst.age but not yet its manifest, so no manifest lists
|
||||||
|
// those blobs. The prune must leave the blobs and the db.zst.age, and the
|
||||||
|
// backup must then restore.
|
||||||
|
func TestPruneDuringBackupKeepsItsBlobs(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
testFiles := writeFaultSourceTree(t, fs, dataDir)
|
||||||
|
|
||||||
|
inner, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
otherHostCfg := faultTestConfig()
|
||||||
|
otherHostCfg.PruneGracePeriod = time.Hour
|
||||||
|
otherHost := newReaderVaultik(ctx, otherHostCfg, inner, nil, fs)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pruned bool
|
||||||
|
pruneErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
store := faultstore.New(inner)
|
||||||
|
store.OnPut = func(key string) faultstore.PutAction {
|
||||||
|
if strings.HasSuffix(key, "/manifest.json.zst") {
|
||||||
|
pruned = true
|
||||||
|
pruneErr = otherHost.PruneBlobs(&vaultik.PruneOptions{Force: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
return faultstore.PutNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(ctx, dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
|
||||||
|
id := fullFaultBackup(ctx, t, fs, store, cfg, repos, dataDir, dbPath, "shared")
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
require.True(t, pruned, "the prune must run before the manifest upload")
|
||||||
|
require.NoError(t, pruneErr)
|
||||||
|
|
||||||
|
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
|
||||||
|
require.NoError(t, v.Restore(&vaultik.RestoreOptions{
|
||||||
|
SnapshotID: id,
|
||||||
|
TargetDir: restoreDir,
|
||||||
|
Verify: true,
|
||||||
|
}), "a prune during the backup must not delete the backup's blobs "+
|
||||||
|
"or its db.zst.age")
|
||||||
|
|
||||||
|
assertRestoredTree(t, fs, restoreDir, testFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneKeepsUnreferencedBlobWithNoTime checks that a blob no
|
||||||
|
// manifest lists, whose listing gives no modification time (as an S3
|
||||||
|
// listing entry without LastModified does), is kept and counted with
|
||||||
|
// the blobs too new to delete: its age is unknown.
|
||||||
|
func TestPruneKeepsUnreferencedBlobWithNoTime(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
// Written into the mock store's map directly rather than through
|
||||||
|
// Put, so its listing gives no time.
|
||||||
|
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||||
|
"/" + testBlobHashA
|
||||||
|
env.store.data[blobKey] = []byte("blob")
|
||||||
|
|
||||||
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
|
||||||
|
|
||||||
|
assert.True(t, env.store.hasKey(blobKey),
|
||||||
|
"a blob of unknown age must be kept")
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result))
|
||||||
|
assert.Equal(t, 0, result.BlobsDeleted)
|
||||||
|
assert.Equal(t, 1, result.BlobsTooNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneDeletesUnreferencedBlobOlderThanGracePeriod checks that the
|
||||||
|
// grace period only delays deletion: a blob no manifest lists is deleted
|
||||||
|
// once it is older than the grace period, and kept and counted until
|
||||||
|
// then.
|
||||||
|
func TestPruneDeletesUnreferencedBlobOlderThanGracePeriod(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
oldKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
|
||||||
|
"/" + testBlobHashA
|
||||||
|
newKey := "blobs/" + testBlobHashB[:2] + "/" + testBlobHashB[2:4] +
|
||||||
|
"/" + testBlobHashB
|
||||||
|
|
||||||
|
for _, key := range []string{oldKey, newKey} {
|
||||||
|
require.NoError(t, store.Put(ctx, key, strings.NewReader("blob")))
|
||||||
|
}
|
||||||
|
|
||||||
|
gracePeriod := time.Hour
|
||||||
|
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
|
||||||
|
require.NoError(t, os.Chtimes(filepath.Join(storeDir, oldKey),
|
||||||
|
beforeGracePeriod, beforeGracePeriod))
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
v := &vaultik.Vaultik{
|
||||||
|
Config: &config.Config{PruneGracePeriod: gracePeriod},
|
||||||
|
Storage: store,
|
||||||
|
Stdout: stdout,
|
||||||
|
UI: ui.NewWithColor(stdout, false),
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
|
||||||
|
|
||||||
|
_, err = store.Stat(ctx, oldKey)
|
||||||
|
require.ErrorIs(t, err, storage.ErrNotFound,
|
||||||
|
"a blob older than the grace period must be deleted")
|
||||||
|
|
||||||
|
_, err = store.Stat(ctx, newKey)
|
||||||
|
require.NoError(t, err, "a blob newer than the grace period must be kept")
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
|
||||||
|
assert.Equal(t, 1, result.BlobsDeleted)
|
||||||
|
assert.Equal(t, 1, result.BlobsTooNew)
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/storage/faultstore"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
|
)
|
||||||
|
|
||||||
|
// metadataObjectKey returns the key of the named file in the metadata
|
||||||
|
// directory of the snapshot with the given human ID.
|
||||||
|
func metadataObjectKey(snapshotID, name string) string {
|
||||||
|
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod checks
|
||||||
|
// that prune deletes a directory under metadata/ holding only
|
||||||
|
// db.zst.age, as a backup leaves when its manifest upload fails, once it
|
||||||
|
// is older than the grace period, and keeps one newer than that. A
|
||||||
|
// directory with a manifest is never deleted, however old.
|
||||||
|
func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(storeDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
const (
|
||||||
|
oldID = "testhost_home_2026-01-01T00:00:00Z"
|
||||||
|
newID = "testhost_home_2026-01-02T00:00:00Z"
|
||||||
|
completeID = "testhost_home_2026-01-03T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
oldDB := metadataObjectKey(oldID, "db.zst.age")
|
||||||
|
newDB := metadataObjectKey(newID, "db.zst.age")
|
||||||
|
completeDB := metadataObjectKey(completeID, "db.zst.age")
|
||||||
|
completeManifest := metadataObjectKey(completeID, "manifest.json.zst")
|
||||||
|
|
||||||
|
manifest, err := snapshot.EncodeManifest(&snapshot.Manifest{
|
||||||
|
SnapshotID: snapshot.RemoteSnapshotKey(completeID),
|
||||||
|
}, 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, key := range []string{oldDB, newDB, completeDB} {
|
||||||
|
require.NoError(t, store.Put(ctx, key, strings.NewReader("db")))
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, store.Put(ctx, completeManifest,
|
||||||
|
bytes.NewReader(manifest)))
|
||||||
|
|
||||||
|
gracePeriod := time.Hour
|
||||||
|
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
|
||||||
|
|
||||||
|
for _, key := range []string{oldDB, completeDB, completeManifest} {
|
||||||
|
require.NoError(t, os.Chtimes(filepath.Join(storeDir, key),
|
||||||
|
beforeGracePeriod, beforeGracePeriod))
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout := &bytes.Buffer{}
|
||||||
|
v := &vaultik.Vaultik{
|
||||||
|
Config: &config.Config{PruneGracePeriod: gracePeriod},
|
||||||
|
Storage: store,
|
||||||
|
Stdout: stdout,
|
||||||
|
UI: ui.NewWithColor(stdout, false),
|
||||||
|
}
|
||||||
|
v.SetContext(ctx)
|
||||||
|
|
||||||
|
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
|
||||||
|
|
||||||
|
_, err = store.Stat(ctx, oldDB)
|
||||||
|
require.ErrorIs(t, err, storage.ErrNotFound,
|
||||||
|
"a directory with no manifest older than the grace period "+
|
||||||
|
"must be deleted")
|
||||||
|
|
||||||
|
for _, key := range []string{newDB, completeDB, completeManifest} {
|
||||||
|
_, err = store.Stat(ctx, key)
|
||||||
|
require.NoError(t, err, "%s must be kept", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
|
||||||
|
assert.Equal(t, 1, result.MetadataDirsDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that
|
||||||
|
// prune --force keeps a directory under metadata/ with no manifest when a
|
||||||
|
// local snapshot record claims it, when the listing gives no time for an
|
||||||
|
// object in it, or when its name is not a remote key. A fourth such
|
||||||
|
// directory is named, deleted and reported, although there are no
|
||||||
|
// unreferenced blobs to delete.
|
||||||
|
func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
claimedID = "testhost_home_2026-01-01T00:00:00Z"
|
||||||
|
leftID = "testhost_home_2026-01-03T00:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
env.addLocal(t, claimedID, time.Now().Add(-time.Hour))
|
||||||
|
|
||||||
|
claimedDB := metadataObjectKey(claimedID, "db.zst.age")
|
||||||
|
unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z",
|
||||||
|
"db.zst.age")
|
||||||
|
leftDB := metadataObjectKey(leftID, "db.zst.age")
|
||||||
|
notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age"
|
||||||
|
|
||||||
|
for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} {
|
||||||
|
require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written into the mock store's map directly rather than through
|
||||||
|
// Put, so its listing gives no time.
|
||||||
|
env.store.data[unknownAgeDB] = []byte("db")
|
||||||
|
|
||||||
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{Force: true}))
|
||||||
|
|
||||||
|
assert.True(t, env.store.hasKey(claimedDB),
|
||||||
|
"a directory a local snapshot record claims must be kept")
|
||||||
|
assert.True(t, env.store.hasKey(unknownAgeDB),
|
||||||
|
"a directory holding an object of unknown age must be kept")
|
||||||
|
assert.True(t, env.store.hasKey(notRemoteKeyDB),
|
||||||
|
"a directory whose name is not a remote key must be kept")
|
||||||
|
assert.False(t, env.store.hasKey(leftDB),
|
||||||
|
"a directory with no manifest, unclaimed and old, must be deleted")
|
||||||
|
|
||||||
|
output := env.stdout.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output, "Found 1 metadata director(ies) left by "+
|
||||||
|
"backups that stopped before their manifest:")
|
||||||
|
assert.Contains(t, output,
|
||||||
|
"metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/")
|
||||||
|
assert.NotContains(t, output, "not-a-remote-key")
|
||||||
|
assert.Contains(t, output,
|
||||||
|
"Deleted 1 metadata director(ies) with no manifest.")
|
||||||
|
assert.NotContains(t, output, "No unreferenced blobs to remove.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt
|
||||||
|
// names a directory under metadata/ with no manifest that prune is about
|
||||||
|
// to delete, and that answering no keeps it.
|
||||||
|
func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
const leftID = "testhost_home_2026-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
leftDB := metadataObjectKey(leftID, "db.zst.age")
|
||||||
|
require.NoError(t, env.store.Put(context.Background(), leftDB,
|
||||||
|
strings.NewReader("db")))
|
||||||
|
|
||||||
|
env.v.Stdin = strings.NewReader("n\n")
|
||||||
|
|
||||||
|
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||||
|
|
||||||
|
assert.True(t, env.store.hasKey(leftDB),
|
||||||
|
"a declined prune must keep the directory")
|
||||||
|
|
||||||
|
output := env.stdout.String()
|
||||||
|
|
||||||
|
assert.Contains(t, output,
|
||||||
|
" metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n")
|
||||||
|
assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ")
|
||||||
|
assert.Contains(t, output, "Cancelled")
|
||||||
|
assert.NotContains(t, output, "Deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json
|
||||||
|
// counts a directory under metadata/ with no manifest that it cannot
|
||||||
|
// delete, and then returns an error, so the command exits non-zero.
|
||||||
|
func TestPruneFailedMetadataDirDeleteReturnsError(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
|
||||||
|
leftDB := metadataObjectKey("testhost_home_2026-01-01T00:00:00Z",
|
||||||
|
"db.zst.age")
|
||||||
|
require.NoError(t, env.store.Put(context.Background(), leftDB,
|
||||||
|
strings.NewReader("db")))
|
||||||
|
|
||||||
|
store := faultstore.New(env.store)
|
||||||
|
store.OnDelete = func(key string) bool {
|
||||||
|
return strings.HasPrefix(key, "metadata/")
|
||||||
|
}
|
||||||
|
env.v.Storage = store
|
||||||
|
|
||||||
|
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
|
||||||
|
|
||||||
|
require.ErrorContains(t, err,
|
||||||
|
"1 metadata director(ies) with no manifest could not be deleted")
|
||||||
|
assert.True(t, env.store.hasKey(leftDB))
|
||||||
|
|
||||||
|
var result vaultik.PruneBlobsResult
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
|
||||||
|
"stdout must hold the JSON document, got:\n%s", env.stdout.String())
|
||||||
|
assert.Equal(t, 1, result.MetadataDirsFailed)
|
||||||
|
assert.Equal(t, 0, result.MetadataDirsDeleted)
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ func setupConsistencyTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
parts := strings.Split(id, "_")
|
parts := strings.Split(id, "_")
|
||||||
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ func setupPurgeTest(
|
|||||||
repos := database.NewRepositories(db)
|
repos := database.NewRepositories(db)
|
||||||
mockStorage := NewMockStorer()
|
mockStorage := NewMockStorer()
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
// Insert each snapshot into the DB and create remote metadata stubs.
|
// Insert each snapshot into the DB and create remote metadata stubs.
|
||||||
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
// Use timestamps parsed from snapshot IDs for realistic ordering.
|
||||||
for _, id := range snapshotIDs {
|
for _, id := range snapshotIDs {
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
|
|||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
tv.UI.SetQuiet(true)
|
tv.UI.SetQuiet(true)
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ import (
|
|||||||
type testStorer struct {
|
type testStorer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
data map[string][]byte
|
data map[string][]byte
|
||||||
|
// putTimes holds when Put stored each object; ListStream reports it.
|
||||||
|
putTimes map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestStorer() *testStorer {
|
func newTestStorer() *testStorer {
|
||||||
return &testStorer{
|
return &testStorer{
|
||||||
data: make(map[string][]byte),
|
data: make(map[string][]byte),
|
||||||
|
putTimes: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ func (s *testStorer) Put(_ context.Context, key string, reader io.Reader) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.data[key] = data
|
s.data[key] = data
|
||||||
|
s.putTimes[key] = time.Now().UTC()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
delete(s.data, key)
|
delete(s.data, key)
|
||||||
|
delete(s.putTimes, key)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -120,8 +125,9 @@ func (s *testStorer) ListStream(
|
|||||||
for key, data := range s.data {
|
for key, data := range s.data {
|
||||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||||
ch <- storage.ObjectInfo{
|
ch <- storage.ObjectInfo{
|
||||||
Key: key,
|
Key: key,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
|
ModTime: s.putTimes[key],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,6 +310,10 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
|
|||||||
addBlob(t, store, blobUnique)
|
addBlob(t, store, blobUnique)
|
||||||
addBlob(t, store, blobShared)
|
addBlob(t, store, blobShared)
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
|
||||||
|
|
||||||
@@ -373,6 +383,10 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
|
|||||||
store := newTestStorer()
|
store := newTestStorer()
|
||||||
addManifest(t, store, "snapshot-001", nil)
|
addManifest(t, store, "snapshot-001", nil)
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(store)
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
@@ -393,7 +407,12 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
|||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tv := vaultik.NewForTesting(newTestStorer())
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
store := newTestStorer()
|
||||||
|
require.NoError(t, store.Put(context.Background(), markerFile,
|
||||||
|
bytes.NewReader(nil)))
|
||||||
|
|
||||||
|
tv := vaultik.NewForTesting(store)
|
||||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||||
|
|
||||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -219,9 +220,7 @@ func (v *Vaultik) finishRestore(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
|
||||||
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
|
||||||
"original UID/GID preserved.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesFailed > 0 {
|
if result.FilesFailed > 0 {
|
||||||
@@ -247,6 +246,43 @@ func (v *Vaultik) finishRestore(
|
|||||||
return unmatchedErr
|
return unmatchedErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnIfOwnershipNotPreserved warns once if some entry restored under
|
||||||
|
// targetDir has another owner or group on disk than the snapshot
|
||||||
|
// records. It is meant for a restore by a normal user, which changes no
|
||||||
|
// owner or group: a new entry is owned by that user, and its group is
|
||||||
|
// that user's, or the group of the directory it is created in on macOS
|
||||||
|
// and in a setgid directory on Linux. An entry that is not on disk, or
|
||||||
|
// whose filesystem reports no owner, is skipped.
|
||||||
|
func (v *Vaultik) warnIfOwnershipNotPreserved(
|
||||||
|
files []*database.File, targetDir string,
|
||||||
|
) {
|
||||||
|
for _, file := range files {
|
||||||
|
targetPath, err := containedRestorePath(
|
||||||
|
v.Fs, targetDir, file.Path.String())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := lstatIfPossible(v.Fs, targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if owner.Uid != file.UID || owner.Gid != file.GID {
|
||||||
|
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
|
||||||
|
"requires root. Re-run as root (e.g. with sudo) if you need " +
|
||||||
|
"original UID/GID preserved.")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reportUnmatchedPaths warns, by name, about each requested path that
|
// reportUnmatchedPaths warns, by name, about each requested path that
|
||||||
// matched nothing in the snapshot, and returns an error counting them,
|
// matched nothing in the snapshot, and returns an error counting them,
|
||||||
// or nil when there are none.
|
// or nil when there are none.
|
||||||
@@ -965,8 +1001,9 @@ type restoreSession struct {
|
|||||||
// root can chown a file to an arbitrary UID/GID — non-root chown
|
// root can chown a file to an arbitrary UID/GID — non-root chown
|
||||||
// always fails with EPERM. Attempting it anyway produces N
|
// always fails with EPERM. Attempting it anyway produces N
|
||||||
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
// guaranteed-failed syscalls + N noisy debug lines, so we skip
|
||||||
// the call entirely as non-root and emit one warning at the end
|
// the call entirely as non-root; warnIfOwnershipNotPreserved warns
|
||||||
// of the restore explaining that ownership was not preserved.
|
// once at the end of the restore if that left any owner or group
|
||||||
|
// other than recorded.
|
||||||
runningAsRoot bool
|
runningAsRoot bool
|
||||||
// directories holds every restored directory, for
|
// directories holds every restored directory, for
|
||||||
// applyDirectoryMetadata to finish after the restore loop.
|
// applyDirectoryMetadata to finish after the restore loop.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(root, "source")
|
dataDir := filepath.Join(root, "source")
|
||||||
storeDir := filepath.Join(root, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(root, "restored")
|
restoreDir := filepath.Join(root, "restored")
|
||||||
dbPath := filepath.Join(root, "index.sqlite")
|
dbPath := filepath.Join(root, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package vaultik //nolint:testpackage // calls the unexported ownership check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"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/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/316: a
|
||||||
|
// restore by a normal user warns that ownership was not preserved only
|
||||||
|
// when some restored entry has another owner or group on disk than the
|
||||||
|
// snapshot records.
|
||||||
|
|
||||||
|
const ownershipWarning = "did not preserve file ownership"
|
||||||
|
|
||||||
|
// TestOwnershipWarningSilentWhenEntriesMatch checks that a restore whose
|
||||||
|
// entries all end up with their recorded owner and group prints no
|
||||||
|
// warning.
|
||||||
|
func TestOwnershipWarningSilentWhenEntriesMatch(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
owner := ownerOf(t, targetDir)
|
||||||
|
|
||||||
|
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
||||||
|
{Path: "/a", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
||||||
|
{Path: "/b", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Empty(t, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOwnershipWarningWhenOwnerDiffers checks that the warning is printed
|
||||||
|
// when an entry recorded with another owner is restored owned by the
|
||||||
|
// restoring user, even though its group matches the recorded one.
|
||||||
|
func TestOwnershipWarningWhenOwnerDiffers(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
owner := ownerOf(t, targetDir)
|
||||||
|
|
||||||
|
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
||||||
|
{Path: "/a", Mode: plainFileMode, UID: otherOwnerID, GID: owner.Gid},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOwnershipWarningWhenDirectoryGivesItsGroup checks that the warning
|
||||||
|
// is printed, once, when entries recorded with the restoring user's
|
||||||
|
// owner and group take another group from the setgid directory they are
|
||||||
|
// restored into, as every new entry takes its directory's group on
|
||||||
|
// macOS.
|
||||||
|
func TestOwnershipWarningWhenDirectoryGivesItsGroup(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("giving a directory to another group needs root")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
user := ownerOf(t, targetDir)
|
||||||
|
|
||||||
|
require.NoError(t, os.Chown(targetDir, -1, int(otherOwnerID)))
|
||||||
|
require.NoError(t, os.Chmod(targetDir, os.ModeSetgid|writableTestMode))
|
||||||
|
|
||||||
|
out := restoreAsNormalUser(t, targetDir, []*database.File{
|
||||||
|
{Path: "/a", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
||||||
|
{Path: "/b", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerOf returns the owner and group path has on disk.
|
||||||
|
func ownerOf(t *testing.T, path string) *syscall.Stat_t {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
owner, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreAsNormalUser restores rows, all empty regular files, into
|
||||||
|
// targetDir without changing any owner or group, as restore does for a
|
||||||
|
// normal user, and returns what warnIfOwnershipNotPreserved prints.
|
||||||
|
func restoreAsNormalUser(
|
||||||
|
t *testing.T, targetDir string, rows []*database.File,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
rows, repos := makeFiles(ctx, t, rows)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
v := newContainmentVaultik(ctx, afero.NewOsFs())
|
||||||
|
v.UI = ui.NewWithColor(&out, false)
|
||||||
|
|
||||||
|
session := &restoreSession{
|
||||||
|
v: v,
|
||||||
|
ctx: ctx,
|
||||||
|
repos: repos,
|
||||||
|
opts: &RestoreOptions{TargetDir: targetDir},
|
||||||
|
result: &RestoreResult{},
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
require.NoError(t, session.restoreFile(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
v.warnIfOwnershipNotPreserved(rows, targetDir)
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
|||||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||||
|
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(storeDir)
|
storer, err := storage.NewFileStorer(storeDir)
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ func backupSingleChunkFiles(
|
|||||||
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
||||||
}
|
}
|
||||||
|
|
||||||
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
storer, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
|
|||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
originalsDir := filepath.Join(dataDir, "originals")
|
originalsDir := filepath.Join(dataDir, "originals")
|
||||||
copiesDir := filepath.Join(dataDir, "copies")
|
copiesDir := filepath.Join(dataDir, "copies")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
linkDir := filepath.Join(tempDir, "srclink")
|
linkDir := filepath.Join(tempDir, "srclink")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(64 * 1024)
|
chunkSize := int64(64 * 1024)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
|||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
dataDir := filepath.Join(tempDir, "src")
|
dataDir := filepath.Join(tempDir, "src")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -116,7 +116,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -199,7 +199,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
@@ -288,7 +288,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
+355
-82
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -39,6 +40,30 @@ var (
|
|||||||
// caller can report it apart from a failed backup.
|
// caller can report it apart from a failed backup.
|
||||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||||
|
|
||||||
|
// ErrFilesUnreadable is returned when --skip-errors skipped files or
|
||||||
|
// directories it could not read or list. The snapshot is complete and
|
||||||
|
// kept, so the caller can report it apart from a failed backup.
|
||||||
|
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
||||||
|
|
||||||
|
// ErrSourcePathEmpty is returned when --prune skipped the post-backup
|
||||||
|
// prune because the scan found nothing in a configured path of a
|
||||||
|
// snapshot, as in the mount point of a volume that is not mounted. The
|
||||||
|
// snapshots are complete and kept, so the caller can report it apart
|
||||||
|
// from a failed backup. A run that also skipped unreadable files
|
||||||
|
// returns ErrFilesUnreadable instead.
|
||||||
|
var ErrSourcePathEmpty = errors.New("source path(s) were empty")
|
||||||
|
|
||||||
|
// ErrVolumeNotMounted is returned when --prune skipped the post-backup
|
||||||
|
// prune because a directory that an earlier snapshot of the same name
|
||||||
|
// found as a mount point was not found as one in this run: the volume
|
||||||
|
// mounted there is not mounted, the run found no entry in it, or the
|
||||||
|
// directory is no longer backed up. The snapshots are complete and
|
||||||
|
// kept, so the caller can report it apart from a failed backup. A run
|
||||||
|
// that also skipped unreadable files or found a configured path empty
|
||||||
|
// returns ErrFilesUnreadable or ErrSourcePathEmpty instead.
|
||||||
|
var ErrVolumeNotMounted = errors.New(
|
||||||
|
"mount point(s) an earlier snapshot recorded were not found")
|
||||||
|
|
||||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||||
// local index for listing and purge operations.
|
// local index for listing and purge operations.
|
||||||
const listRecentLimit = 10000
|
const listRecentLimit = 10000
|
||||||
@@ -95,6 +120,13 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before PruneDatabase: it drops incomplete snapshots, which could
|
||||||
|
// leave the local index without one and so skip the marker check.
|
||||||
|
err = v.markDestination()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||||
// This is critical for data safety; PruneDatabase below does it.
|
// This is critical for data safety; PruneDatabase below does it.
|
||||||
hostname := v.Config.Hostname
|
hostname := v.Config.Hostname
|
||||||
@@ -117,30 +149,15 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return fmt.Errorf("prune database: %w", err)
|
return fmt.Errorf("prune database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which snapshots to process
|
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
||||||
snapshotNames := opts.Snapshots
|
if err != nil {
|
||||||
if len(snapshotNames) == 0 {
|
return err
|
||||||
snapshotNames = v.Config.SnapshotNames()
|
|
||||||
} else {
|
|
||||||
// Validate requested snapshot names exist
|
|
||||||
for _, name := range snapshotNames {
|
|
||||||
if _, ok := v.Config.Snapshots[name]; !ok {
|
|
||||||
return fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(snapshotNames) == 0 {
|
filesUnreadable, emptyPaths, unmountedPaths, err := v.createNamedSnapshots(
|
||||||
return errNoSnapshotsInConfig
|
opts, hostname, snapshotNames)
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
// Process each named snapshot
|
|
||||||
for snapIdx, snapName := range snapshotNames {
|
|
||||||
err = v.createNamedSnapshot(
|
|
||||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print overall summary if multiple snapshots
|
// Print overall summary if multiple snapshots
|
||||||
@@ -149,13 +166,109 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Prune {
|
switch {
|
||||||
|
case opts.Prune && (filesUnreadable > 0 || len(emptyPaths) > 0 ||
|
||||||
|
len(unmountedPaths) > 0):
|
||||||
|
return v.skipPostBackupPrune(
|
||||||
|
filesUnreadable, emptyPaths, unmountedPaths)
|
||||||
|
case opts.Prune:
|
||||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return v.finishCreateSnapshot(filesUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNamedSnapshots creates each named snapshot in turn. It returns
|
||||||
|
// the number of entries --skip-errors skipped because it could not read
|
||||||
|
// or list them, each configured path in which the scan found nothing,
|
||||||
|
// and, under --prune, each directory where an earlier snapshot found a
|
||||||
|
// volume mounted and this run did not, the paths named with their
|
||||||
|
// snapshot.
|
||||||
|
func (v *Vaultik) createNamedSnapshots(
|
||||||
|
opts *SnapshotCreateOptions, hostname string, snapshotNames []string,
|
||||||
|
) (int, []string, []string, error) {
|
||||||
|
filesUnreadable := 0
|
||||||
|
|
||||||
|
var emptyPaths, unmountedPaths []string
|
||||||
|
|
||||||
|
for snapIdx, snapName := range snapshotNames {
|
||||||
|
stats, err := v.createNamedSnapshot(
|
||||||
|
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filesUnreadable += stats.totalFilesUnreadable
|
||||||
|
|
||||||
|
for _, dir := range stats.emptyPaths {
|
||||||
|
emptyPaths = append(emptyPaths, dir+" of snapshot "+snapName)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range stats.unmountedPaths {
|
||||||
|
unmountedPaths = append(unmountedPaths,
|
||||||
|
dir+" of snapshot "+snapName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesUnreadable, emptyPaths, unmountedPaths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipPostBackupPrune says why the post-backup prune does not run, naming
|
||||||
|
// every cause, and returns the error the run ends with. An older snapshot
|
||||||
|
// may be the only one that still holds a file this run could not read,
|
||||||
|
// or what an empty path or a volume that is not mounted held. The
|
||||||
|
// previous snapshot is no guide: after one such run it lacks them too.
|
||||||
|
func (v *Vaultik) skipPostBackupPrune(
|
||||||
|
filesUnreadable int, emptyPaths, unmountedPaths []string,
|
||||||
|
) error {
|
||||||
|
log.Warn("Skipping post-backup prune",
|
||||||
|
"files_unreadable", filesUnreadable, "empty_paths", emptyPaths,
|
||||||
|
"unmounted_paths", unmountedPaths)
|
||||||
|
|
||||||
|
if len(emptyPaths) > 0 {
|
||||||
|
v.UI.Warningf("Skipped the post-backup prune because nothing was "+
|
||||||
|
"found in %s; older snapshots are kept.",
|
||||||
|
strings.Join(emptyPaths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unmountedPaths) > 0 {
|
||||||
|
v.UI.Warningf("Skipped the post-backup prune because this backup "+
|
||||||
|
"did not find a mount point at %s, where an earlier snapshot "+
|
||||||
|
"found one: the volume is not mounted, this backup found no "+
|
||||||
|
"entry in it, or the directory is no longer backed up. Older "+
|
||||||
|
"snapshots are kept. Mount the volume again, or, if that is on "+
|
||||||
|
"purpose, remove the older snapshots by hand with "+
|
||||||
|
"'vaultik snapshot purge'.",
|
||||||
|
strings.Join(unmountedPaths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if filesUnreadable > 0 {
|
||||||
|
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
|
||||||
|
"could not be read; older snapshots are kept.", filesUnreadable)
|
||||||
|
|
||||||
|
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(emptyPaths) > 0 {
|
||||||
|
return fmt.Errorf("%d %w", len(emptyPaths), ErrSourcePathEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%d %w", len(unmountedPaths), ErrVolumeNotMounted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishCreateSnapshot ends a run whose snapshots are all complete and
|
||||||
|
// kept. Like finishRestore, it returns an error when --skip-errors
|
||||||
|
// skipped anything it could not read or list; otherwise it prints the
|
||||||
|
// closing line. A file removed during the run is counted as failed in
|
||||||
|
// the summary but is not an error, with or without --skip-errors.
|
||||||
|
func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
|
||||||
|
if filesUnreadable > 0 {
|
||||||
|
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||||
|
}
|
||||||
|
|
||||||
// Terminus must obey the --cron invariant: silent on total
|
// Terminus must obey the --cron invariant: silent on total
|
||||||
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
||||||
// the success path), but if any warnings fired during the run we
|
// the success path), but if any warnings fired during the run we
|
||||||
@@ -171,10 +284,33 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// snapshotNamesToCreate returns the requested snapshot names, each checked
|
||||||
|
// against the config, or every configured name when none was requested.
|
||||||
|
func (v *Vaultik) snapshotNamesToCreate(requested []string) ([]string, error) {
|
||||||
|
snapshotNames := requested
|
||||||
|
if len(snapshotNames) == 0 {
|
||||||
|
snapshotNames = v.Config.SnapshotNames()
|
||||||
|
} else {
|
||||||
|
// Validate requested snapshot names exist
|
||||||
|
for _, name := range snapshotNames {
|
||||||
|
if _, ok := v.Config.Snapshots[name]; !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snapshotNames) == 0 {
|
||||||
|
return nil, errNoSnapshotsInConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshotNames, nil
|
||||||
|
}
|
||||||
|
|
||||||
// runPostBackupPrune drops older snapshots of the given names and removes
|
// runPostBackupPrune drops older snapshots of the given names and removes
|
||||||
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
|
// from remote storage the orphan blobs and the directories under
|
||||||
// snapshots newer than that duration are kept. Otherwise only the latest
|
// metadata/ with no manifest, as PruneBlobs does. If keepNewerThan is set
|
||||||
// snapshot of each name is kept.
|
// (e.g. "4w"), snapshots newer than that duration are kept. Otherwise only
|
||||||
|
// the latest snapshot of each name is kept.
|
||||||
func (v *Vaultik) runPostBackupPrune(
|
func (v *Vaultik) runPostBackupPrune(
|
||||||
snapshotNames []string, keepNewerThan string,
|
snapshotNames []string, keepNewerThan string,
|
||||||
) error {
|
) error {
|
||||||
@@ -201,7 +337,7 @@ func (v *Vaultik) runPostBackupPrune(
|
|||||||
|
|
||||||
err = v.PruneBlobs(&PruneOptions{Force: true})
|
err = v.PruneBlobs(&PruneOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pruning orphaned blobs: %w", err)
|
return fmt.Errorf("pruning the destination store: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -223,16 +359,28 @@ type snapshotStats struct {
|
|||||||
totalBlobsUploaded int
|
totalBlobsUploaded int
|
||||||
uploadDuration time.Duration
|
uploadDuration time.Duration
|
||||||
|
|
||||||
|
// The part of totalFilesFailed that --skip-errors skipped because it
|
||||||
|
// could not be read or listed.
|
||||||
|
totalFilesUnreadable int
|
||||||
|
|
||||||
|
// The configured paths in which the scan found nothing.
|
||||||
|
emptyPaths []string
|
||||||
|
|
||||||
|
// Under --prune, the directories where an earlier snapshot of the
|
||||||
|
// same name found a volume mounted and this one did not.
|
||||||
|
unmountedPaths []string
|
||||||
|
|
||||||
// The sizes of all blobs the snapshot references, set by
|
// The sizes of all blobs the snapshot references, set by
|
||||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||||
blobSize int64
|
blobSize int64
|
||||||
blobUncompressedSize int64
|
blobUncompressedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNamedSnapshot creates a single named snapshot
|
// createNamedSnapshot creates a single named snapshot and returns the
|
||||||
|
// statistics of its scan.
|
||||||
func (v *Vaultik) createNamedSnapshot(
|
func (v *Vaultik) createNamedSnapshot(
|
||||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||||
) error {
|
) (*snapshotStats, error) {
|
||||||
snapshotStartTime := time.Now()
|
snapshotStartTime := time.Now()
|
||||||
|
|
||||||
if total > 1 {
|
if total > 1 {
|
||||||
@@ -241,21 +389,27 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||||
EnableProgress: !opts.Cron,
|
EnableProgress: !opts.Cron,
|
||||||
UI: v.UI,
|
UI: v.UI,
|
||||||
Fs: v.Fs,
|
Fs: v.Fs,
|
||||||
Exclude: v.Config.GetExcludes(snapName),
|
Exclude: v.Config.GetExcludes(snapName),
|
||||||
SkipErrors: opts.SkipErrors,
|
SkipErrors: opts.SkipErrors,
|
||||||
|
PreviousSnapshotID: previousSnapshotID,
|
||||||
})
|
})
|
||||||
|
|
||||||
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
|
||||||
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating snapshot: %w", err)
|
return nil, fmt.Errorf("creating snapshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||||
@@ -263,12 +417,12 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Snapshot complete",
|
log.Info("Snapshot complete",
|
||||||
@@ -281,11 +435,78 @@ func (v *Vaultik) createNamedSnapshot(
|
|||||||
|
|
||||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||||
|
|
||||||
return nil
|
if opts.Prune {
|
||||||
|
stats.unmountedPaths, err = v.findUnmountedPaths(snapName, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnmountedPaths returns each directory that an earlier snapshot
|
||||||
|
// named snapName in the local index recorded as a mount point and
|
||||||
|
// snapshot snapshotID did not, as when the volume mounted there is not
|
||||||
|
// mounted.
|
||||||
|
func (v *Vaultik) findUnmountedPaths(
|
||||||
|
snapName, snapshotID string,
|
||||||
|
) ([]string, error) {
|
||||||
|
found, err := v.Repositories.Snapshots.GetMountPoints(v.ctx, snapshotID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmounted []string
|
||||||
|
|
||||||
|
for _, s := range snapshots {
|
||||||
|
if s.ID.String() == snapshotID ||
|
||||||
|
parseSnapshotName(s.ID.String(), s.Hostname.String()) != snapName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
recorded, err := v.Repositories.Snapshots.GetMountPoints(
|
||||||
|
v.ctx, s.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range recorded {
|
||||||
|
if !slices.Contains(found, dir) && !slices.Contains(unmounted, dir) {
|
||||||
|
unmounted = append(unmounted, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unmounted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// previousSnapshotID returns the ID of the most recent completed snapshot
|
||||||
|
// named snapName in the local index, or "" when there is none.
|
||||||
|
func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
|
||||||
|
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("listing snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range snapshots {
|
||||||
|
if s.CompletedAt != nil &&
|
||||||
|
parseSnapshotName(s.ID.String(), s.Hostname.String()) == snapName {
|
||||||
|
return s.ID.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSnapshotPaths resolves source directories to absolute paths
|
// resolveSnapshotPaths resolves source directories to absolute paths
|
||||||
// with symlink resolution.
|
// with symlink resolution. A path that is a symlink whose target does
|
||||||
|
// not exist is an error.
|
||||||
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
||||||
snapConfig := v.Config.Snapshots[snapName]
|
snapConfig := v.Config.Snapshots[snapName]
|
||||||
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
|
||||||
@@ -298,12 +519,18 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
|
|||||||
|
|
||||||
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
// A path that does not exist is left for the scan to report.
|
||||||
resolvedPath = absPath
|
// One that exists is a symlink whose target is gone, as one
|
||||||
} else {
|
// into a volume that is not mounted: the scan would record
|
||||||
|
// only the link, and --prune would then drop the older
|
||||||
|
// snapshots that hold the files.
|
||||||
|
_, lstatErr := os.Lstat(absPath)
|
||||||
|
if !os.IsNotExist(err) || lstatErr == nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"failed to resolve symlinks for %s: %w", absPath, err)
|
"failed to resolve symlinks for %s: %w", absPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedPath = absPath
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedDirs = append(resolvedDirs, resolvedPath)
|
resolvedDirs = append(resolvedDirs, resolvedPath)
|
||||||
@@ -349,6 +576,7 @@ func (v *Vaultik) scanAllDirectories(
|
|||||||
stats.totalBlobs += result.BlobsCreated
|
stats.totalBlobs += result.BlobsCreated
|
||||||
stats.totalFilesSkipped += result.FilesSkipped
|
stats.totalFilesSkipped += result.FilesSkipped
|
||||||
stats.totalFilesFailed += result.FilesFailed
|
stats.totalFilesFailed += result.FilesFailed
|
||||||
|
stats.totalFilesUnreadable += result.FilesUnreadable
|
||||||
stats.totalBytesSkipped += result.BytesSkipped
|
stats.totalBytesSkipped += result.BytesSkipped
|
||||||
stats.totalFilesDeleted += result.FilesDeleted
|
stats.totalFilesDeleted += result.FilesDeleted
|
||||||
stats.totalBytesDeleted += result.BytesDeleted
|
stats.totalBytesDeleted += result.BytesDeleted
|
||||||
@@ -356,6 +584,10 @@ func (v *Vaultik) scanAllDirectories(
|
|||||||
stats.totalBytesUploaded += result.BytesUploaded
|
stats.totalBytesUploaded += result.BytesUploaded
|
||||||
stats.uploadDuration += result.UploadDuration
|
stats.uploadDuration += result.UploadDuration
|
||||||
|
|
||||||
|
if result.EntriesFound == 0 {
|
||||||
|
stats.emptyPaths = append(stats.emptyPaths, dir)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Directory scan complete",
|
log.Info("Directory scan complete",
|
||||||
"path", dir,
|
"path", dir,
|
||||||
"files", result.FilesScanned,
|
"files", result.FilesScanned,
|
||||||
@@ -1223,7 +1455,7 @@ const pruneCommandHint = "vaultik prune"
|
|||||||
// snapshotRemoveCommandHint is the command suggested, with the
|
// snapshotRemoveCommandHint is the command suggested, with the
|
||||||
// snapshot's ID, when a remove could not reach the destination store:
|
// snapshot's ID, when a remove could not reach the destination store:
|
||||||
// running it again removes the snapshot's metadata there, which
|
// running it again removes the snapshot's metadata there, which
|
||||||
// `vaultik prune` never does.
|
// `vaultik prune` never does while the manifest is there.
|
||||||
const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
const snapshotRemoveCommandHint = "vaultik snapshot remove"
|
||||||
|
|
||||||
// RemoveSnapshot removes a snapshot from the local index database and,
|
// RemoveSnapshot removes a snapshot from the local index database and,
|
||||||
@@ -1257,10 +1489,18 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
|
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checked before the local record is deleted: it may be the last
|
||||||
|
// snapshot in the local index, and the check applies only while the
|
||||||
|
// local index holds one.
|
||||||
|
var markerErr error
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
markerErr = v.checkDestinationMarker()
|
||||||
|
}
|
||||||
|
|
||||||
if inLocalIndex {
|
if inLocalIndex {
|
||||||
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
@@ -1271,7 +1511,7 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
||||||
snapshotID, inLocalIndex, opts)
|
snapshotID, inLocalIndex, markerErr, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
@@ -1308,7 +1548,9 @@ func (v *Vaultik) RemoveSnapshot(
|
|||||||
// remove: no row and, unless LocalOnly is set, no metadata on the
|
// remove: no row and, unless LocalOnly is set, no metadata on the
|
||||||
// destination store either. The destination store is consulted only
|
// destination store either. The destination store is consulted only
|
||||||
// when the local index has no row, so a snapshot in the local index can
|
// when the local index has no row, so a snapshot in the local index can
|
||||||
// still be removed from it while the destination store is unreachable.
|
// still be removed from it while the destination store is unreachable,
|
||||||
|
// and only after checkDestinationMarker passes, so an unplugged volume's
|
||||||
|
// empty mount point is not taken for a store without the snapshot.
|
||||||
func (v *Vaultik) findSnapshotToRemove(
|
func (v *Vaultik) findSnapshotToRemove(
|
||||||
snapshotID string, opts *RemoveOptions,
|
snapshotID string, opts *RemoveOptions,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
@@ -1328,6 +1570,11 @@ func (v *Vaultik) findSnapshotToRemove(
|
|||||||
errSnapshotNotFound, snapshotID)
|
errSnapshotNotFound, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
|
||||||
@@ -1366,11 +1613,15 @@ func (v *Vaultik) removeSnapshotDryRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||||
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
// snapshot, unless --force or --json is set. Returns false (treating
|
||||||
// user answers "y". The prompt and its answer bypass the UI: they are an
|
// EOF/read errors as "no") unless the user answers "y". The prompt and
|
||||||
// interactive exchange the operator must see, so --quiet does not
|
// its answer bypass the UI: they are an interactive exchange the
|
||||||
// silence them.
|
// operator must see, so --quiet does not silence them.
|
||||||
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
|
||||||
|
if opts.Force || opts.JSON {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if opts.LocalOnly {
|
if opts.LocalOnly {
|
||||||
v.stdoutf("Remove snapshot '%s' from local database "+
|
v.stdoutf("Remove snapshot '%s' from local database "+
|
||||||
"(remote untouched)? [y/N] ", snapshotID)
|
"(remote untouched)? [y/N] ", snapshotID)
|
||||||
@@ -1400,15 +1651,18 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
|
|||||||
// removeSnapshotRemote strips the snapshot's metadata from the
|
// removeSnapshotRemote strips the snapshot's metadata from the
|
||||||
// destination store. It returns true only when the delete finished
|
// destination store. It returns true only when the delete finished
|
||||||
// without an error and removed at least one object; under LocalOnly it
|
// without an error and removed at least one object; under LocalOnly it
|
||||||
// does nothing. When the snapshot is only on the destination store, a
|
// does nothing. markerErr is the result of checkDestinationMarker; when
|
||||||
// failure is returned as an error, because that removal is all the
|
// it is set, nothing is deleted and it is the failure. When the snapshot
|
||||||
// command does. When the snapshot was in the local index, its removal
|
// is only on the destination store, a failure is returned as an error,
|
||||||
// there has already happened, so a failure is only a warning telling the
|
// because that removal is all the command does. When the snapshot was in
|
||||||
// user to run `vaultik snapshot remove` for the snapshot again once the
|
// the local index, its removal there has already happened, so a failure
|
||||||
// destination store is reachable (`vaultik prune` never removes snapshot
|
// is only a warning telling the user to run `vaultik snapshot remove`
|
||||||
// metadata).
|
// for the snapshot again once the destination store is reachable
|
||||||
|
// (`vaultik prune` never removes snapshot metadata that has its
|
||||||
|
// manifest).
|
||||||
func (v *Vaultik) removeSnapshotRemote(
|
func (v *Vaultik) removeSnapshotRemote(
|
||||||
snapshotID string, inLocalIndex bool, opts *RemoveOptions,
|
snapshotID string, inLocalIndex bool, markerErr error,
|
||||||
|
opts *RemoveOptions,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
if opts.LocalOnly {
|
if opts.LocalOnly {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -1417,9 +1671,14 @@ func (v *Vaultik) removeSnapshotRemote(
|
|||||||
log.Info("Removing snapshot metadata from remote storage",
|
log.Info("Removing snapshot metadata from remote storage",
|
||||||
"snapshot_id", snapshotID)
|
"snapshot_id", snapshotID)
|
||||||
|
|
||||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
var deleted int
|
||||||
|
|
||||||
|
err := markerErr
|
||||||
|
if err == nil {
|
||||||
|
deleted, err = v.deleteRemoteSnapshotByKey(
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
|
||||||
if err != nil && !inLocalIndex {
|
if err != nil && !inLocalIndex {
|
||||||
return false, fmt.Errorf(
|
return false, fmt.Errorf(
|
||||||
"removing snapshot metadata from destination store: %w", err)
|
"removing snapshot metadata from destination store: %w", err)
|
||||||
@@ -1449,7 +1708,9 @@ func (v *Vaultik) removeSnapshotRemote(
|
|||||||
// database from the local index, and (with --remote) every snapshot
|
// database from the local index, and (with --remote) every snapshot
|
||||||
// metadata directory in remote storage. Both sides are processed so a
|
// metadata directory in remote storage. Both sides are processed so a
|
||||||
// "remove --all" leaves nothing behind, even when the local DB and
|
// "remove --all" leaves nothing behind, even when the local DB and
|
||||||
// remote storage have diverged.
|
// remote storage have diverged. Unless LocalOnly is set, it fails
|
||||||
|
// before it removes anything when the destination store cannot be
|
||||||
|
// listed or is not present by checkDestinationMarker.
|
||||||
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
||||||
err := v.EnsureStorageBinding()
|
err := v.EnsureStorageBinding()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1462,11 +1723,15 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
||||||
// destination store; failures are downgraded to a warning so a
|
// destination store. Only for a local-only remove is a failure
|
||||||
// permission-denied or unreachable remote can't block a local-only
|
// downgraded to a warning, so a permission-denied or unreachable
|
||||||
// remove.
|
// remote can't block it.
|
||||||
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
|
||||||
if remoteErr != nil {
|
if remoteErr != nil {
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
return nil, remoteErr
|
||||||
|
}
|
||||||
|
|
||||||
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
log.Warn("Could not list remote snapshots", "error", remoteErr)
|
||||||
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
|
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
|
||||||
}
|
}
|
||||||
@@ -1528,8 +1793,7 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
|
|||||||
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
// listAllRemoteSnapshotKeys collects the hashed remote keys
|
||||||
// (subdirectories under metadata/) currently present in the
|
// (subdirectories under metadata/) currently present in the
|
||||||
// destination store. Returns (nil, err) when the store cannot be
|
// destination store. Returns (nil, err) when the store cannot be
|
||||||
// listed; callers must treat that as "no remote info available," not
|
// listed, or is not present by checkDestinationMarker.
|
||||||
// fatal.
|
|
||||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||||
log.Info("Listing all remote snapshots")
|
log.Info("Listing all remote snapshots")
|
||||||
|
|
||||||
@@ -1578,6 +1842,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := v.checkDestinationMarker()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1708,10 +1977,12 @@ func (v *Vaultik) deleteIncompleteSnapshot(snapshotIDStr string, id any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeAllLocalSnapshots removes each known local snapshot (and, unless
|
// removeAllLocalSnapshots removes each known local snapshot's remote
|
||||||
// --local-only, its remote metadata), returning the number of snapshots
|
// metadata, unless --local-only, and then its local record, returning
|
||||||
// it could not remove. A snapshot left in the local index keeps its
|
// the number of snapshots it could not remove. The local record goes
|
||||||
// remote metadata too, since it is not an orphan remote key.
|
// only once the metadata is deleted: dropped first, it would leave that
|
||||||
|
// metadata on the destination store as a remote-only snapshot whose name
|
||||||
|
// is lost.
|
||||||
func (v *Vaultik) removeAllLocalSnapshots(
|
func (v *Vaultik) removeAllLocalSnapshots(
|
||||||
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
||||||
) int {
|
) int {
|
||||||
@@ -1720,6 +1991,19 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
|||||||
for _, snapshotID := range localSnaps {
|
for _, snapshotID := range localSnaps {
|
||||||
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
log.Info("Removing snapshot", "snapshot_id", snapshotID)
|
||||||
|
|
||||||
|
if !opts.LocalOnly {
|
||||||
|
_, err := v.deleteRemoteSnapshotByKey(
|
||||||
|
snapshot.RemoteSnapshotKey(snapshotID))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to remove snapshot metadata from remote",
|
||||||
|
"snapshot_id", snapshotID, "error", err)
|
||||||
|
|
||||||
|
failures++
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
err := v.deleteSnapshotFromLocalDB(snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to remove from local database",
|
log.Error("Failed to remove from local database",
|
||||||
@@ -1730,17 +2014,6 @@ func (v *Vaultik) removeAllLocalSnapshots(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !opts.LocalOnly {
|
|
||||||
_, err = v.deleteRemoteSnapshotByKey(
|
|
||||||
snapshot.RemoteSnapshotKey(snapshotID))
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to remove snapshot metadata from remote",
|
|
||||||
"snapshot_id", snapshotID, "error", err)
|
|
||||||
|
|
||||||
failures++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package vaultik_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"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/304: a
|
||||||
|
// backup counts a file as deleted only when the previous snapshot of
|
||||||
|
// the same name had it and it is gone, and never counts an excluded
|
||||||
|
// file.
|
||||||
|
|
||||||
|
// deletedCountEnv is a backup setup over the source directory src whose
|
||||||
|
// user-facing output is kept in out. Each test configures its snapshots
|
||||||
|
// with setSnapshot.
|
||||||
|
type deletedCountEnv struct {
|
||||||
|
v *vaultik.Vaultik
|
||||||
|
out *bytes.Buffer
|
||||||
|
src string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDeletedCountEnv writes files, keyed by path relative to src.
|
||||||
|
func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
srcDir := filepath.Join(tempDir, "src")
|
||||||
|
|
||||||
|
for rel, content := range files {
|
||||||
|
path := filepath.Join(srcDir, rel)
|
||||||
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := faultTestConfig()
|
||||||
|
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
||||||
|
cfg.Snapshots = map[string]config.SnapshotConfig{}
|
||||||
|
|
||||||
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
repos := database.NewRepositories(db)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
|
return &deletedCountEnv{v: v, out: out, src: srcDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSnapshot configures the snapshot name to back up the path rel
|
||||||
|
// beneath src, with the given excludes.
|
||||||
|
func (e *deletedCountEnv) setSnapshot(name, rel string, exclude ...string) {
|
||||||
|
e.v.Config.Snapshots[name] = config.SnapshotConfig{
|
||||||
|
Paths: []string{filepath.Join(e.src, rel)},
|
||||||
|
Exclude: exclude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backUp runs a backup of the named snapshot and returns its output.
|
||||||
|
func (e *deletedCountEnv) backUp(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
e.out.Reset()
|
||||||
|
require.NoError(t, e.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Snapshots: []string{name},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return e.out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file this snapshot excludes is not counted as deleted: neither one
|
||||||
|
// that another snapshot backs up, nor one that the previous snapshot of
|
||||||
|
// this name had before the exclude was added.
|
||||||
|
func TestBackupDoesNotCountExcludedFileAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"top.txt": []byte("top"),
|
||||||
|
"cache/tmp.bin": []byte("cache"),
|
||||||
|
"photos/a.jpg": []byte("photo a"),
|
||||||
|
"photos/b.jpg": []byte("photo b"),
|
||||||
|
"photos/c.jpg": []byte("photo c"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("photos", "photos")
|
||||||
|
env.setSnapshot("home", "", "/photos")
|
||||||
|
|
||||||
|
env.backUp(t, "photos")
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
||||||
|
|
||||||
|
env.setSnapshot("home", "", "/photos", "/cache")
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file inside a directory that an exclude added after a backup
|
||||||
|
// matches is not counted as deleted, though the pattern matches only the
|
||||||
|
// directory and the directory is gone from disk.
|
||||||
|
func TestBackupDoesNotCountFileInExcludedDirectoryAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"top.txt": []byte("top"),
|
||||||
|
"user/.local/share/Trash/old.txt": []byte("old"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
||||||
|
|
||||||
|
require.NoError(t, os.RemoveAll(
|
||||||
|
filepath.Join(env.src, "user/.local/share/Trash")))
|
||||||
|
|
||||||
|
env.setSnapshot("home", "", "*/.local/share/Trash")
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file whose directory was replaced by a regular file of the same name
|
||||||
|
// is counted as deleted.
|
||||||
|
func TestBackupCountsFileInDirectoryReplacedByFileAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"dir/inside.txt": []byte("inside"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 1, 0))
|
||||||
|
|
||||||
|
dir := filepath.Join(env.src, "dir")
|
||||||
|
require.NoError(t, os.RemoveAll(dir))
|
||||||
|
require.NoError(t, os.WriteFile(dir, []byte("now a file"), 0o600))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"),
|
||||||
|
"Files: 1 examined, 1 backed up, 0 unchanged, 1 deleted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under --skip-errors a file inside a directory that the walk cannot
|
||||||
|
// open is not counted as deleted: it is still on disk.
|
||||||
|
func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"open.txt": []byte("open"),
|
||||||
|
"locked/inside.txt": []byte("inside"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("work", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "work"), filesLine(2, 2, 0))
|
||||||
|
|
||||||
|
// A directory's mode does not lock out root, who runs the tests, so
|
||||||
|
// opening the directory is made to fail instead.
|
||||||
|
env.v.Fs = &openFailFs{
|
||||||
|
path: filepath.Join(env.src, "locked"),
|
||||||
|
err: os.ErrPermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
env.out.Reset()
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"work"},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup counts only files that the previous snapshot of its own name
|
||||||
|
// had, not one that a later snapshot of another name over the same path
|
||||||
|
// had and that is since removed.
|
||||||
|
func TestBackupIgnoresSnapshotOfAnotherName(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"kept.txt": []byte("kept"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
env.setSnapshot("other", "")
|
||||||
|
|
||||||
|
env.backUp(t, "home")
|
||||||
|
|
||||||
|
// Snapshot start times are kept in whole seconds: wait so that other's
|
||||||
|
// snapshot is strictly the most recent.
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
extra := filepath.Join(env.src, "extra.txt")
|
||||||
|
require.NoError(t, os.WriteFile(extra, []byte("extra"), 0o600))
|
||||||
|
env.backUp(t, "other")
|
||||||
|
require.NoError(t, os.Remove(extra))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file removed from disk is counted as deleted by the next backup of
|
||||||
|
// the snapshot that had it, and not by any later backup of that
|
||||||
|
// snapshot, nor by the first backup of another name over the same path.
|
||||||
|
func TestBackupCountsDeletedFileOnce(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
aContent := bytes.Repeat([]byte("a"), 1000)
|
||||||
|
bContent := bytes.Repeat([]byte("b"), 2000)
|
||||||
|
keptContent := bytes.Repeat([]byte("k"), 500)
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"a.txt": aContent,
|
||||||
|
"b.txt": bContent,
|
||||||
|
"kept.txt": keptContent,
|
||||||
|
})
|
||||||
|
env.setSnapshot("home", "")
|
||||||
|
env.setSnapshot("other", "")
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(3, 3, 0))
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(env.src, "a.txt")))
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(env.src, "b.txt")))
|
||||||
|
|
||||||
|
summary := env.backUp(t, "home")
|
||||||
|
assert.Contains(t, summary,
|
||||||
|
"Files: 1 examined, 0 backed up, 1 unchanged, 2 deleted.")
|
||||||
|
assert.Contains(t, summary,
|
||||||
|
fmt.Sprintf("Data: %s total (%s backed up), %s deleted.",
|
||||||
|
env.v.UI.Size(int64(len(keptContent))), env.v.UI.Size(0),
|
||||||
|
env.v.UI.Size(int64(len(aContent)+len(bContent)))))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||||
|
assert.Contains(t, env.backUp(t, "other"), filesLine(1, 0, 1))
|
||||||
|
}
|
||||||
@@ -49,13 +49,19 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
openErr error
|
openErr error
|
||||||
skipErrors bool
|
skipErrors bool
|
||||||
|
wantErr error // nil for none
|
||||||
}{
|
}{
|
||||||
// What a normal user gets opening a file with mode 000.
|
// What a normal user gets opening a file with mode 000.
|
||||||
{name: "unopenable under skip-errors",
|
{name: "unopenable under skip-errors",
|
||||||
openErr: os.ErrPermission, skipErrors: true},
|
openErr: os.ErrPermission, skipErrors: true,
|
||||||
|
wantErr: vaultik.ErrFilesUnreadable},
|
||||||
// What opening a file removed after phase 1 gives.
|
// What opening a file removed after phase 1 gives.
|
||||||
{name: "removed between the phases",
|
{name: "removed between the phases",
|
||||||
openErr: os.ErrNotExist, skipErrors: false},
|
openErr: os.ErrNotExist, skipErrors: false},
|
||||||
|
// A removal is not an error, so --skip-errors does not make the
|
||||||
|
// run fail. See https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
{name: "removed between the phases under skip-errors",
|
||||||
|
openErr: os.ErrNotExist, skipErrors: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -87,7 +93,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
"src": {Paths: []string{srcDir}},
|
"src": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
@@ -99,10 +105,11 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
|||||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||||
v.UI = ui.NewWithColor(out, false)
|
v.UI = ui.NewWithColor(out, false)
|
||||||
|
|
||||||
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
SkipErrors: tt.skipErrors,
|
SkipErrors: tt.skipErrors,
|
||||||
Snapshots: []string{"src"},
|
Snapshots: []string{"src"},
|
||||||
}))
|
})
|
||||||
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
|
||||||
summary := out.String()
|
summary := out.String()
|
||||||
assert.Contains(t, summary,
|
assert.Contains(t, summary,
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ const tabPadding = 3
|
|||||||
// attempt is made to recover or fabricate the human ID.
|
// attempt is made to recover or fabricate the human ID.
|
||||||
//
|
//
|
||||||
// Snapshots in the local index with no counterpart on the destination
|
// Snapshots in the local index with no counterpart on the destination
|
||||||
// store are reported as drift below the table.
|
// store are reported as drift below the table, unless the local index
|
||||||
|
// is bound to a different destination than the configured one: that is
|
||||||
|
// reported instead.
|
||||||
//
|
//
|
||||||
// If remote listing fails (unmounted volume, permission denied,
|
// If remote listing fails (unmounted volume, permission denied,
|
||||||
// network), we degrade to local-only with a warning. List never fails
|
// network), we degrade to local-only with a warning. List never fails
|
||||||
@@ -103,7 +105,26 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
stored, configured, err := v.storageBinding()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A local index bound to another destination describes that one, so
|
||||||
|
// its records are expected to be missing here, and prune refuses to
|
||||||
|
// run. The binding is reported instead of advising prune.
|
||||||
|
boundElsewhere := stored != "" && stored != configured
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
|
// stdout holds only the document, so this goes to the logger.
|
||||||
|
if boundElsewhere {
|
||||||
|
log.Warn("Local index is bound to a different backup "+
|
||||||
|
"destination; to proceed, revert storage_url (or the s3.* "+
|
||||||
|
"endpoint, bucket and prefix) to the bound destination, "+
|
||||||
|
"or run 'vaultik database delete'",
|
||||||
|
"bound_to", stored, "configured", configured)
|
||||||
|
}
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportJSONListingLimits(listing)
|
v.reportJSONListingLimits(listing)
|
||||||
}
|
}
|
||||||
@@ -125,8 +146,13 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if boundElsewhere {
|
||||||
|
v.UI.Warningf("%v\n%s", errStorageBindingMismatch,
|
||||||
|
buildBindingMismatchMessage(stored, configured))
|
||||||
|
}
|
||||||
|
|
||||||
if remoteErr == nil {
|
if remoteErr == nil {
|
||||||
v.reportListDrift(snapshots, listing)
|
v.reportListDrift(snapshots, listing, boundElsewhere)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -432,7 +458,9 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// reportListDrift prints the reconciliation notes the merged table
|
// reportListDrift prints the reconciliation notes the merged table
|
||||||
// cannot express on its own: local records with no counterpart on the
|
// cannot express on its own: local records with no counterpart on the
|
||||||
// destination store, plus counts of remote snapshots that were
|
// destination store, plus counts of remote snapshots that were
|
||||||
// unreadable or omitted.
|
// unreadable or omitted. Local records are not reported when
|
||||||
|
// boundElsewhere is set: ListSnapshots has already said why they are
|
||||||
|
// missing.
|
||||||
//
|
//
|
||||||
// This is what remains of the old reportRemoteDrift, and it no longer
|
// This is what remains of the old reportRemoteDrift, and it no longer
|
||||||
// touches the destination store. Its remote-only half collapsed into
|
// touches the destination store. Its remote-only half collapsed into
|
||||||
@@ -441,6 +469,7 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
|||||||
// so `snapshot list` lists the destination exactly once per invocation.
|
// so `snapshot list` lists the destination exactly once per invocation.
|
||||||
func (v *Vaultik) reportListDrift(
|
func (v *Vaultik) reportListDrift(
|
||||||
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
||||||
|
boundElsewhere bool,
|
||||||
) {
|
) {
|
||||||
var localOnly []string
|
var localOnly []string
|
||||||
|
|
||||||
@@ -450,7 +479,7 @@ func (v *Vaultik) reportListDrift(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(localOnly) > 0 {
|
if len(localOnly) > 0 && !boundElsewhere {
|
||||||
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
||||||
"destination store:", len(localOnly))
|
"destination store:", len(localOnly))
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ func newListEnv(t *testing.T) *listEnv {
|
|||||||
store := newObservingStorer()
|
store := newObservingStorer()
|
||||||
stdout := &bytes.Buffer{}
|
stdout := &bytes.Buffer{}
|
||||||
|
|
||||||
|
// A backup wrote to this destination, so it carries the marker.
|
||||||
|
require.NoError(t, store.Put(ctx, markerFile, strings.NewReader("")))
|
||||||
|
|
||||||
v := &vaultik.Vaultik{
|
v := &vaultik.Vaultik{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
AgeSecretKey: "",
|
AgeSecretKey: "",
|
||||||
@@ -421,6 +424,74 @@ func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
|
|||||||
assert.NotContains(t, out, "snapshot cleanup")
|
assert.NotContains(t, out, "snapshot cleanup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListSnapshots_BoundElsewhereDoesNotAdvisePrune covers a changed
|
||||||
|
// storage_url: the local index is still bound to the old destination,
|
||||||
|
// so its records are missing from the new one. prune refuses to run in
|
||||||
|
// that state, so the listing must give the choices prune gives instead
|
||||||
|
// of advising it.
|
||||||
|
func TestListSnapshots_BoundElsewhereDoesNotAdvisePrune(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
oldURL = "file:///mnt/backups/old"
|
||||||
|
newURL = "file:///mnt/backups/new"
|
||||||
|
)
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
env.v.Config.StorageURL = newURL
|
||||||
|
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||||
|
database.LocalMetaKeyStorageURL, oldURL))
|
||||||
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
err := env.v.ListSnapshots(false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out := env.stdout.String()
|
||||||
|
|
||||||
|
assert.Contains(t, out, listLocalID)
|
||||||
|
assert.Contains(t, out,
|
||||||
|
"local index is bound to a different backup destination")
|
||||||
|
assert.Contains(t, out, oldURL)
|
||||||
|
assert.Contains(t, out, newURL)
|
||||||
|
assert.Contains(t, out, "revert storage_url")
|
||||||
|
assert.Contains(t, out, "vaultik database delete")
|
||||||
|
assert.NotContains(t, out, "vaultik prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSnapshots_JSONReportsBoundElsewhere is the --json side of the
|
||||||
|
// test above. Every local row comes out with remote_present false, which
|
||||||
|
// a script reads as stale local records unless it is told, on stderr,
|
||||||
|
// that the local index is bound to a different destination.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
||||||
|
func TestListSnapshots_JSONReportsBoundElsewhere(t *testing.T) {
|
||||||
|
const (
|
||||||
|
oldURL = "file:///mnt/backups/old"
|
||||||
|
newURL = "file:///mnt/backups/new"
|
||||||
|
)
|
||||||
|
|
||||||
|
env := newListEnv(t)
|
||||||
|
env.v.Config.StorageURL = newURL
|
||||||
|
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||||
|
database.LocalMetaKeyStorageURL, oldURL))
|
||||||
|
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
stderr := captureProcessStderr(t, func() {
|
||||||
|
require.NoError(t, env.v.ListSnapshots(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
rows := decodeListJSON(t, env.stdout.String())
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
assert.Equal(t, listLocalID, rows[0].ID)
|
||||||
|
|
||||||
|
assert.Contains(t, stderr, "bound to a different backup destination")
|
||||||
|
assert.Contains(t, stderr, oldURL)
|
||||||
|
assert.Contains(t, stderr, newURL)
|
||||||
|
assert.Contains(t, stderr, "revert storage_url")
|
||||||
|
assert.Contains(t, stderr, "vaultik database delete")
|
||||||
|
}
|
||||||
|
|
||||||
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
||||||
// doc comment: an unreachable destination is a warning plus local-only
|
// doc comment: an unreachable destination is a warning plus local-only
|
||||||
// output, never a failure.
|
// output, never a failure.
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
package vaultik_test
|
package vaultik_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,3 +35,379 @@ func TestSnapshotCreateOptions_PruneFlag(t *testing.T) {
|
|||||||
t.Error("Expected Prune to be false")
|
t.Error("Expected Prune to be false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backup under --prune that finds nothing in a configured path, as in
|
||||||
|
// the mount point of a volume that is not mounted, does not prune: run
|
||||||
|
// after run, the snapshot that holds the files is kept with everything
|
||||||
|
// it stored, and the run fails. That holds whether the empty path is the
|
||||||
|
// snapshot's only path or one of two. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// emptied is the path, relative to the source directory, that is
|
||||||
|
// emptied after the first backup.
|
||||||
|
const (
|
||||||
|
snapshotName = "media"
|
||||||
|
emptied = "music"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
paths []string // relative to the source directory
|
||||||
|
}{
|
||||||
|
{name: "only path", paths: []string{emptied}},
|
||||||
|
{name: "one of two paths", paths: []string{emptied, "docs"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
emptied + "/a.mp3": []byte("song a"),
|
||||||
|
emptied + "/b.mp3": []byte("song b"),
|
||||||
|
"docs/notes.txt": []byte("notes"),
|
||||||
|
})
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(tt.paths))
|
||||||
|
|
||||||
|
for _, rel := range tt.paths {
|
||||||
|
paths = append(paths, filepath.Join(env.src, rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
||||||
|
Paths: paths,
|
||||||
|
}
|
||||||
|
env.backUp(t, snapshotName)
|
||||||
|
|
||||||
|
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||||
|
stored, err := env.v.Storage.List(ctx, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// A volume that is not mounted leaves its mount point behind
|
||||||
|
// as an empty directory.
|
||||||
|
emptiedDir := filepath.Join(env.src, emptied)
|
||||||
|
require.NoError(t, os.RemoveAll(emptiedDir))
|
||||||
|
require.NoError(t, os.Mkdir(emptiedDir, 0o750))
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
env.out.Reset()
|
||||||
|
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true,
|
||||||
|
Snapshots: []string{snapshotName},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrSourcePathEmpty)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Skipped the post-backup prune because nothing was found in ")
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
emptied+" of snapshot "+snapshotName+";")
|
||||||
|
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 3)
|
||||||
|
assert.Equal(t, firstID, snapshots[2].ID.String(),
|
||||||
|
"the snapshot that holds the files is kept")
|
||||||
|
|
||||||
|
for _, key := range stored {
|
||||||
|
_, err := env.v.Storage.Stat(ctx, key)
|
||||||
|
require.NoError(t, err, "%s is kept", key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup under --prune of a configured path that is a symlink whose
|
||||||
|
// target is gone, as one into a volume that is not mounted, fails before
|
||||||
|
// anything is pruned: the snapshot that holds the files is kept with
|
||||||
|
// everything it stored. See https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
func TestBackupThroughSymlinkWhoseTargetIsGoneDoesNotPrune(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const snapshotName = "photos"
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"volume/a.jpg": []byte("photo a"),
|
||||||
|
"volume/b.jpg": []byte("photo b"),
|
||||||
|
})
|
||||||
|
|
||||||
|
target := filepath.Join(env.src, "volume")
|
||||||
|
link := filepath.Join(env.src, "photos")
|
||||||
|
require.NoError(t, os.Symlink(target, link))
|
||||||
|
env.setSnapshot(snapshotName, "photos")
|
||||||
|
env.backUp(t, snapshotName)
|
||||||
|
|
||||||
|
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||||
|
stored, err := env.v.Storage.List(ctx, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The volume is not mounted, so the symlink points at nothing.
|
||||||
|
require.NoError(t, os.RemoveAll(target))
|
||||||
|
|
||||||
|
env.out.Reset()
|
||||||
|
err = env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true,
|
||||||
|
Snapshots: []string{snapshotName},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
assert.Contains(t, err.Error(), link)
|
||||||
|
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 1)
|
||||||
|
assert.Equal(t, firstID, snapshots[0].ID.String(),
|
||||||
|
"the snapshot that holds the files is kept")
|
||||||
|
|
||||||
|
for _, key := range stored {
|
||||||
|
_, err := env.v.Storage.Stat(ctx, key)
|
||||||
|
require.NoError(t, err, "%s is kept", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup under --skip-errors --prune that both skipped a file it could
|
||||||
|
// not read and found nothing in a configured path names the empty path
|
||||||
|
// too when it skips the prune. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
func TestBackupWithEmptySourcePathAndUnreadableFileNamesBoth(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const snapshotName = "media"
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"docs/notes.txt": []byte("notes"),
|
||||||
|
})
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(env.src, "music"), 0o750))
|
||||||
|
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
|
||||||
|
Paths: []string{
|
||||||
|
filepath.Join(env.src, "docs"),
|
||||||
|
filepath.Join(env.src, "music"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mode does not lock out root, who runs the tests, so opening the
|
||||||
|
// file is made to fail instead.
|
||||||
|
env.v.Fs = &openFailFs{
|
||||||
|
path: filepath.Join(env.src, "docs", "notes.txt"),
|
||||||
|
err: os.ErrPermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true,
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{snapshotName},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Skipped the post-backup prune because 1 file(s) could not be read")
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Skipped the post-backup prune because nothing was found in ")
|
||||||
|
assert.Contains(t, env.out.String(), "music of snapshot "+snapshotName+";")
|
||||||
|
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup under --prune that finds something in each configured path
|
||||||
|
// prunes the previous snapshot, also when a path is a single file. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/388.
|
||||||
|
func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
snapshotName = "album"
|
||||||
|
file = "pictures/one.jpg" // relative to the source directory
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string // relative to the source directory
|
||||||
|
}{
|
||||||
|
{name: "directory", path: filepath.Dir(file)},
|
||||||
|
{name: "single file", path: file},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
file: []byte("picture"),
|
||||||
|
})
|
||||||
|
env.setSnapshot(snapshotName, tt.path)
|
||||||
|
env.backUp(t, snapshotName)
|
||||||
|
|
||||||
|
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||||
|
|
||||||
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true,
|
||||||
|
Snapshots: []string{snapshotName},
|
||||||
|
}))
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 1, "the previous snapshot is pruned")
|
||||||
|
assert.NotEqual(t, firstID, snapshots[0].ID.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// automountFs is the real filesystem with a volume at volumeDir that is
|
||||||
|
// mounted on first access, as by an autofs map that shows its mount
|
||||||
|
// points before mounting them: listing volumeDir mounts the volume or,
|
||||||
|
// while cannotMount, fails with "no such file or directory".
|
||||||
|
type automountFs struct {
|
||||||
|
afero.OsFs
|
||||||
|
|
||||||
|
volumeDir string
|
||||||
|
cannotMount bool
|
||||||
|
mounted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
|
||||||
|
func (f *automountFs) Open(name string) (afero.File, error) {
|
||||||
|
if name == f.volumeDir && f.cannotMount {
|
||||||
|
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == f.volumeDir {
|
||||||
|
f.mounted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.OsFs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup under --prune in which a volume that an earlier snapshot found
|
||||||
|
// mounted below the configured path is missing does not prune: run after
|
||||||
|
// run, the snapshot that holds the volume's files is kept with everything
|
||||||
|
// it stored, and the run fails. Once the volume is back, the prune runs.
|
||||||
|
// The volume is mounted on first access and every backup starts with it
|
||||||
|
// not mounted, so its directory is on its parent's device until the walk
|
||||||
|
// lists it. While the volume is missing, its directory is left empty, or
|
||||||
|
// listing it fails with "no such file or directory", as when an autofs
|
||||||
|
// volume cannot be mounted, which the walk counts as a directory removed
|
||||||
|
// during the backup. See https://git.eeqj.de/sneak/vaultik/issues/395.
|
||||||
|
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
listingFails bool
|
||||||
|
}{
|
||||||
|
{name: "directory left empty", listingFails: false},
|
||||||
|
{name: "listing fails", listingFails: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
backUpWithVolumeNotMounted(t, tt.listingFails)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backUpWithVolumeNotMounted runs the backups of
|
||||||
|
// TestBackupWithVolumeNotMountedDoesNotPrune. While the volume is
|
||||||
|
// missing, listing its directory fails if listingFails; otherwise the
|
||||||
|
// directory is empty.
|
||||||
|
func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// volume is the directory, below the configured path, where the
|
||||||
|
// volume is mounted.
|
||||||
|
const (
|
||||||
|
snapshotName = "data"
|
||||||
|
volume = "nas"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
volume + "/a.txt": []byte("file a"),
|
||||||
|
"notes.txt": []byte("notes"),
|
||||||
|
})
|
||||||
|
env.setSnapshot(snapshotName, "")
|
||||||
|
prune := &vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true, Snapshots: []string{snapshotName},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The volume's directory is on the volume's device, 2, while the
|
||||||
|
// volume is mounted. Everything else is on device 1.
|
||||||
|
volumeDir := filepath.Join(env.src, volume)
|
||||||
|
fs := &automountFs{volumeDir: volumeDir}
|
||||||
|
newScanner := env.v.ScannerFactory
|
||||||
|
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
|
||||||
|
fs.mounted = false
|
||||||
|
params.Fs = fs
|
||||||
|
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
|
||||||
|
if info.Name() == volume && fs.mounted {
|
||||||
|
return 2, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return newScanner(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.backUp(t, snapshotName)
|
||||||
|
|
||||||
|
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
|
||||||
|
stored, err := env.v.Storage.List(ctx, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The volume goes missing.
|
||||||
|
fs.cannotMount = listingFails
|
||||||
|
|
||||||
|
if !listingFails {
|
||||||
|
require.NoError(t, os.RemoveAll(volumeDir))
|
||||||
|
require.NoError(t, os.Mkdir(volumeDir, 0o750))
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
env.out.Reset()
|
||||||
|
|
||||||
|
err := env.v.CreateSnapshot(prune)
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrVolumeNotMounted)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"did not find a mount point at "+volumeDir+" of snapshot "+snapshotName)
|
||||||
|
assert.Contains(t, env.out.String(), "'vaultik snapshot purge'")
|
||||||
|
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 3)
|
||||||
|
assert.Equal(t, firstID, snapshots[2].ID.String(),
|
||||||
|
"the snapshot that holds the volume's files is kept")
|
||||||
|
|
||||||
|
for _, key := range stored {
|
||||||
|
_, err := env.v.Storage.Stat(ctx, key)
|
||||||
|
require.NoError(t, err, "%s is kept", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The volume is back.
|
||||||
|
fs.cannotMount = false
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
|
||||||
|
require.NoError(t, env.v.CreateSnapshot(prune))
|
||||||
|
|
||||||
|
snapshots, err = env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
ctx, listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 1, "the older snapshots are pruned")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vaultik_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|||||||
"tree": {Paths: []string{srcDir}},
|
"tree": {Paths: []string{srcDir}},
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, cfg.IndexPath)
|
db, err := database.New(ctx, cfg.IndexPath)
|
||||||
@@ -73,3 +74,210 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
|||||||
require.Len(t, snapshots, 1)
|
require.Len(t, snapshots, 1)
|
||||||
assert.Nil(t, snapshots[0].CompletedAt)
|
assert.Nil(t, snapshots[0].CompletedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backup under --skip-errors that skips a file it cannot open, or a
|
||||||
|
// directory it cannot list, keeps the snapshot, counts what it skipped
|
||||||
|
// as failed and returns an error instead of finishing successfully. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
skipped string // relative to the source directory
|
||||||
|
}{
|
||||||
|
{name: "file it cannot open", skipped: "skipdir/file.txt"},
|
||||||
|
{name: "directory it cannot list", skipped: "skipdir"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"stored.txt": []byte("stored"),
|
||||||
|
"skipdir/file.txt": []byte("in skipdir"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("work", "")
|
||||||
|
|
||||||
|
// A mode does not lock out root, who runs the tests, so
|
||||||
|
// opening the skipped path is made to fail instead.
|
||||||
|
env.v.Fs = &openFailFs{
|
||||||
|
path: filepath.Join(env.src, tt.skipped),
|
||||||
|
err: os.ErrPermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"work"},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.")
|
||||||
|
assert.NotContains(t, env.out.String(), "Finished successfully.")
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
context.Background(), listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, snapshots, 1)
|
||||||
|
assert.NotNil(t, snapshots[0].CompletedAt, "the snapshot is kept")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeOnLookupFs is the real filesystem, except that path is removed
|
||||||
|
// just before it is looked up. The walk looks an entry up after listing
|
||||||
|
// its directory, so path is listed but gone when the walk reaches it.
|
||||||
|
type removeOnLookupFs struct {
|
||||||
|
afero.OsFs
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *removeOnLookupFs) LstatIfPossible(
|
||||||
|
name string,
|
||||||
|
) (os.FileInfo, bool, error) {
|
||||||
|
if name == f.path {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.OsFs.LstatIfPossible(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file removed before the walk reaches it is counted once, as failed,
|
||||||
|
// like a file removed before its data is read, and the backup finishes
|
||||||
|
// successfully, with or without --skip-errors. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
func TestBackupCountsFileRemovedBeforeWalkReachesItAsFailed(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
skipErrors bool
|
||||||
|
}{
|
||||||
|
{name: "under skip-errors", skipErrors: true},
|
||||||
|
{name: "without skip-errors", skipErrors: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"stays.txt": []byte("stays"),
|
||||||
|
"removed.txt": []byte("removed"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("walk", "")
|
||||||
|
|
||||||
|
// The previous snapshot has the file, so a removal the walk
|
||||||
|
// also counted as deleted would show.
|
||||||
|
env.backUp(t, "walk")
|
||||||
|
|
||||||
|
env.v.Fs = &removeOnLookupFs{
|
||||||
|
path: filepath.Join(env.src, "removed.txt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
env.out.Reset()
|
||||||
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: tt.skipErrors,
|
||||||
|
Snapshots: []string{"walk"},
|
||||||
|
}))
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
||||||
|
assert.Contains(t, env.out.String(), "Finished successfully.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file an exclude pattern matches is not counted when it is removed
|
||||||
|
// before the walk reaches it. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||||
|
func TestBackupIgnoresExcludedFileRemovedBeforeWalkReachesIt(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"stays.txt": []byte("stays"),
|
||||||
|
"removed.tmp": []byte("removed"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("walk", "", "*.tmp")
|
||||||
|
env.v.Fs = &removeOnLookupFs{
|
||||||
|
path: filepath.Join(env.src, "removed.tmp"),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"walk"},
|
||||||
|
}))
|
||||||
|
assert.Contains(t, env.out.String(), filesLine(1, 1, 0))
|
||||||
|
assert.Contains(t, env.out.String(), "Finished successfully.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup under --skip-errors --prune that skipped a file it could not
|
||||||
|
// read does not prune: the previous snapshot, the only one that holds
|
||||||
|
// the file, is kept, and the output says the prune was skipped. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/382.
|
||||||
|
func TestSkipErrorsBackupDoesNotPruneWhenItSkips(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"other.txt": []byte("other"),
|
||||||
|
"skipped.txt": []byte("skipped"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("photos", "")
|
||||||
|
env.backUp(t, "photos")
|
||||||
|
|
||||||
|
// An unchanged file is not read again, so the file is changed before
|
||||||
|
// it becomes unreadable.
|
||||||
|
skipped := filepath.Join(env.src, "skipped.txt")
|
||||||
|
require.NoError(t, os.WriteFile(skipped, []byte("changed"), 0o600))
|
||||||
|
env.v.Fs = &openFailFs{path: skipped, err: os.ErrPermission}
|
||||||
|
|
||||||
|
env.out.Reset()
|
||||||
|
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true,
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"photos"},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||||
|
assert.Contains(t, env.out.String(),
|
||||||
|
"Skipped the post-backup prune because 1 file(s) could not be read")
|
||||||
|
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
context.Background(), listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 2, "the previous snapshot is kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file removed during a backup under --skip-errors --prune is not a
|
||||||
|
// skipped file, so the backup prunes as usual. See
|
||||||
|
// https://git.eeqj.de/sneak/vaultik/issues/382.
|
||||||
|
func TestSkipErrorsBackupPrunesWhenAFileIsRemovedDuringIt(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newDeletedCountEnv(t, map[string][]byte{
|
||||||
|
"other.txt": []byte("other"),
|
||||||
|
"removed.txt": []byte("removed"),
|
||||||
|
})
|
||||||
|
env.setSnapshot("photos", "")
|
||||||
|
env.backUp(t, "photos")
|
||||||
|
|
||||||
|
env.v.Fs = &removeOnLookupFs{path: filepath.Join(env.src, "removed.txt")}
|
||||||
|
|
||||||
|
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||||
|
Prune: true,
|
||||||
|
SkipErrors: true,
|
||||||
|
Snapshots: []string{"photos"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||||
|
context.Background(), listRecentTestLimit)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, snapshots, 1, "the previous snapshot is pruned")
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
@@ -84,7 +85,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
|
|||||||
"split": {Paths: []string{dirOne, dirTwo}},
|
"split": {Paths: []string{dirOne, dirTwo}},
|
||||||
}
|
}
|
||||||
|
|
||||||
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
inner, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
store := faultstore.New(inner)
|
store := faultstore.New(inner)
|
||||||
@@ -188,6 +189,42 @@ func (e *summaryEnv) dataLine(total, backedUp int64) string {
|
|||||||
e.v.UI.Size(total), e.v.UI.Size(backedUp))
|
e.v.UI.Size(total), e.v.UI.Size(backedUp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enumeratedLine returns the line printed when the walk of a source path
|
||||||
|
// ends.
|
||||||
|
func (e *summaryEnv) enumeratedLine(
|
||||||
|
files int, total int64, toBackUp int, toBackUpSize int64,
|
||||||
|
) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Enumerated %d snapshot source files (%s total), %d to back up (%s).",
|
||||||
|
files, e.v.UI.Size(total), toBackUp, e.v.UI.Size(toBackUpSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotPaths returns the paths the snapshot lists, sorted.
|
||||||
|
func (e *summaryEnv) snapshotPaths(t *testing.T, snapshotID string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := e.db.Conn().QueryContext(context.Background(), `
|
||||||
|
SELECT f.path FROM snapshot_files sf JOIN files f ON f.id = sf.file_id
|
||||||
|
WHERE sf.snapshot_id = ? ORDER BY f.path`, snapshotID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var path string
|
||||||
|
|
||||||
|
require.NoError(t, rows.Scan(&path))
|
||||||
|
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, rows.Err())
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
// A first backup stores copy.bin's chunks while backing up a.bin, so
|
// A first backup stores copy.bin's chunks while backing up a.bin, so
|
||||||
// copy.bin's chunks are deduplicated within the run. Each file and byte
|
// copy.bin's chunks are deduplicated within the run. Each file and byte
|
||||||
// is still counted once.
|
// is still counted once.
|
||||||
@@ -248,6 +285,85 @@ func TestSnapshotSummaryIncrementalRunWithDeduplicatedChunks(t *testing.T) {
|
|||||||
assert.Equal(t, uncompressed, snap.BlobUncompressedSize)
|
assert.Equal(t, uncompressed, snap.BlobUncompressedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A backup of a tree with directories and a symlink, none of which
|
||||||
|
// changed since the last backup, backs up nothing, and the snapshot
|
||||||
|
// still lists every one of them. Both sides of the line that ends the
|
||||||
|
// walk count the same entries, the files and the symlink, and no
|
||||||
|
// directory's size. See https://git.eeqj.de/sneak/vaultik/issues/305.
|
||||||
|
func TestSnapshotSummaryUnchangedRunWithDirectoriesAndSymlink(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newSummaryEnv(t)
|
||||||
|
|
||||||
|
const linkTarget = "a.bin"
|
||||||
|
|
||||||
|
require.NoError(t, os.Symlink(linkTarget,
|
||||||
|
filepath.Join(filepath.Dir(env.aPath), "link")))
|
||||||
|
|
||||||
|
// A symlink's size is the length of its target.
|
||||||
|
total := env.totalSize + int64(len(linkTarget))
|
||||||
|
|
||||||
|
first := env.backUp(t, "first", false)
|
||||||
|
assert.Contains(t, first, env.enumeratedLine(4, total, 4, total))
|
||||||
|
assert.Contains(t, first, filesLine(4, 4, 0))
|
||||||
|
|
||||||
|
second := env.backUp(t, "second", false)
|
||||||
|
assert.Contains(t, second, env.enumeratedLine(4, total, 0, 0))
|
||||||
|
assert.Contains(t, second, filesLine(4, 0, 4))
|
||||||
|
assert.Contains(t, second, env.dataLine(total, 0))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
env.snapshotPaths(t, env.snapshot(t, "first").ID.String()),
|
||||||
|
env.snapshotPaths(t, env.snapshot(t, "second").ID.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory whose mtime changed and a symlink whose target changed are
|
||||||
|
// recorded again by the next backup, which counts only the symlink as
|
||||||
|
// backed up.
|
||||||
|
func TestSnapshotSummaryChangedDirectoryAndSymlink(t *testing.T) {
|
||||||
|
log.Initialize(log.Config{})
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newSummaryEnv(t)
|
||||||
|
|
||||||
|
// The local index keeps paths with symlinks resolved.
|
||||||
|
dir, err := filepath.EvalSymlinks(filepath.Dir(env.aPath))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The new link gets the old one's mtime, and a target, which need not
|
||||||
|
// exist, of the old target's length, a symlink's size: only the
|
||||||
|
// target differs.
|
||||||
|
linkTime := unix.NsecToTimeval(
|
||||||
|
time.Date(2029, time.March, 4, 5, 6, 7, 0, time.UTC).UnixNano())
|
||||||
|
link := filepath.Join(dir, "link")
|
||||||
|
require.NoError(t, os.Symlink("a.bin", link))
|
||||||
|
require.NoError(t, unix.Lutimes(link, []unix.Timeval{linkTime, linkTime}))
|
||||||
|
|
||||||
|
env.backUp(t, "first", false)
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(link))
|
||||||
|
require.NoError(t, os.Symlink("b.bin", link))
|
||||||
|
require.NoError(t, unix.Lutimes(link, []unix.Timeval{linkTime, linkTime}))
|
||||||
|
|
||||||
|
mtime := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
require.NoError(t, os.Chtimes(dir, mtime, mtime))
|
||||||
|
|
||||||
|
assert.Contains(t, env.backUp(t, "second", false), filesLine(4, 1, 3))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dirRow, err := env.repos.Files.GetByPath(ctx, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, dirRow)
|
||||||
|
assert.Equal(t, mtime.Unix(), dirRow.MTime.Unix())
|
||||||
|
|
||||||
|
linkRow, err := env.repos.Files.GetByPath(ctx, link)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, linkRow)
|
||||||
|
assert.Equal(t, "b.bin", linkRow.LinkTarget.String())
|
||||||
|
}
|
||||||
|
|
||||||
// Under --cron the progress reporter is off; the upload figures must
|
// Under --cron the progress reporter is off; the upload figures must
|
||||||
// still reach the summary and the snapshots row. The snapshot has two
|
// still reach the summary and the snapshots row. The snapshot has two
|
||||||
// paths, each backed up by its own scan.
|
// paths, each backed up by its own scan.
|
||||||
|
|||||||
@@ -45,17 +45,9 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
configured := v.Config.StorageURL
|
stored, configured, err := v.storageBinding()
|
||||||
if configured == "" {
|
|
||||||
// The s3.* form: bind on its endpoint, bucket and prefix,
|
|
||||||
// written the way storage_url would name the same destination.
|
|
||||||
s3 := v.Config.S3
|
|
||||||
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading local storage binding: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if stored == "" {
|
if stored == "" {
|
||||||
@@ -78,6 +70,27 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
|||||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageBinding returns the destination the local index is bound to
|
||||||
|
// ("" when no binding is recorded yet), then the configured destination
|
||||||
|
// written the same way. It records nothing, so a read-only command can
|
||||||
|
// call it.
|
||||||
|
func (v *Vaultik) storageBinding() (string, string, error) {
|
||||||
|
configured := v.Config.StorageURL
|
||||||
|
if configured == "" {
|
||||||
|
// The s3.* form: bind on its endpoint, bucket and prefix,
|
||||||
|
// written the way storage_url would name the same destination.
|
||||||
|
s3 := v.Config.S3
|
||||||
|
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("reading local storage binding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored, configured, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildBindingMismatchMessage assembles the multi-line explanation
|
// buildBindingMismatchMessage assembles the multi-line explanation
|
||||||
// shown when the local index is bound to a different destination than
|
// shown when the local index is bound to a different destination than
|
||||||
// the currently-configured one (the first line lives in the
|
// the currently-configured one (the first line lives in the
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
firstDir := filepath.Join(tempDir, "first")
|
firstDir := filepath.Join(tempDir, "first")
|
||||||
secondDir := filepath.Join(tempDir, "second")
|
secondDir := filepath.Join(tempDir, "second")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
restoreDir := filepath.Join(tempDir, "restored")
|
restoreDir := filepath.Join(tempDir, "restored")
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
|
|||||||
|
|
||||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||||
|
|
||||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
store, err := storage.NewFileStorer(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
db, err := database.New(ctx, dbPath)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
|||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
dataDir := filepath.Join(tempDir, "source")
|
dataDir := filepath.Join(tempDir, "source")
|
||||||
storeDir := filepath.Join(tempDir, "remote")
|
storeDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||||
|
|
||||||
chunkSize := int64(32 * 1024)
|
chunkSize := int64(32 * 1024)
|
||||||
|
|||||||
Reference in New Issue
Block a user