Rewritten from scratch against the code on next: the packages, the fx wiring, and the steps of a backup, a restore, a verify and a prune. Names that no longer exist in the code are gone: the S3 client as the upload step, blob.BlobWithReader, PurgeSnapshots and the repository interfaces. The storage layer is described through storage.Storer. Model: fable-5-1
This commit is contained in:
+316
-351
@@ -1,411 +1,376 @@
|
|||||||
# 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 the
|
||||||
|
packages, how they are wired together, and what happens during a backup, a
|
||||||
|
restore, a verify and a prune.
|
||||||
|
|
||||||
## Overview
|
Two other documents cover the stored data:
|
||||||
|
|
||||||
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).
|
- [docs/DATAMODEL.md](docs/DATAMODEL.md) describes the local index.
|
||||||
|
- [docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md) describes the layout of the
|
||||||
|
destination.
|
||||||
|
|
||||||
## Data Flow
|
## 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 one repository
|
||||||
|
type for each 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 build 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
|
`PruneDatabase` must succeed before anything is scanned. An unfinished snapshot
|
||||||
// vaultik/snapshot.go: CreateSnapshot()
|
left in the index would make the backup skip files that were never fully
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
stored.
|
||||||
EnableProgress: !opts.Cron,
|
|
||||||
Fs: v.Fs,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 7. Chunker (`chunker.Chunker`)
|
### One snapshot
|
||||||
- **Created by**: `chunker.NewChunker(avgChunkSize)`
|
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
|
||||||
- **Configuration**:
|
|
||||||
- `avgChunkSize`: From config (default 10MB)
|
|
||||||
- `minChunkSize`: avgChunkSize / 4
|
|
||||||
- `maxChunkSize`: avgChunkSize * 4
|
|
||||||
|
|
||||||
#### 8. Packer (`blob.Packer`)
|
`createNamedSnapshot` does these steps:
|
||||||
- **Created by**: `blob.NewPacker(PackerConfig)`
|
|
||||||
- **When**: Inside `snapshot.NewScanner()`
|
|
||||||
- **Configuration**:
|
|
||||||
- `MaxBlobSize`: Maximum blob size before finalization (typically 10GB)
|
|
||||||
- `CompressionLevel`: zstd level (1-19)
|
|
||||||
- `Recipients`: age public keys for encryption
|
|
||||||
|
|
||||||
```go
|
- `resolveSnapshotPaths` makes each configured path absolute and resolves
|
||||||
// snapshot/scanner.go: NewScanner()
|
symlinks.
|
||||||
packerCfg := blob.PackerConfig{
|
- `SnapshotManager.CreateSnapshotWithName` creates the snapshot record. The
|
||||||
MaxBlobSize: cfg.MaxBlobSize,
|
record is not yet marked complete.
|
||||||
CompressionLevel: cfg.CompressionLevel,
|
- `Scanner.Scan` backs up each path in turn.
|
||||||
Recipients: cfg.AgeRecipients,
|
- `finalizeSnapshotMetadata` uploads the snapshot's metadata and marks the
|
||||||
Repositories: cfg.Repositories,
|
snapshot complete.
|
||||||
Fs: cfg.FS,
|
|
||||||
}
|
|
||||||
packer, err := blob.NewPacker(packerCfg)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Module Responsibilities
|
A new `Scanner` is created for each snapshot, from `ScannerFactory`. It owns a
|
||||||
|
`Chunker` and a `Packer`.
|
||||||
|
|
||||||
### `internal/cli`
|
### Scanning one path
|
||||||
Entry point for fx application. Combines all modules and handles signal interrupts.
|
|
||||||
|
|
||||||
Key functions:
|
`Scanner.Scan` works in these steps:
|
||||||
- `NewApp(AppOptions)` → Creates fx.App with all modules
|
|
||||||
- `RunApp(ctx, app)` → Starts app, handles graceful shutdown
|
|
||||||
- `RunWithApp(ctx, opts)` → Convenience wrapper
|
|
||||||
|
|
||||||
### `internal/vaultik`
|
- `repairInterruptedBlobs` deletes the records of blobs whose upload never
|
||||||
Main orchestrator containing all dependencies and command implementations.
|
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 methods:
|
The walk handles each kind of entry differently:
|
||||||
- `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`
|
- A regular file or a symlink is compared with its record in the index.
|
||||||
Content-defined chunking using FastCDC algorithm.
|
- A directory is recorded with its metadata. It has no content to store.
|
||||||
|
- A device node, a named pipe or a socket is skipped.
|
||||||
|
- An entry that matches an exclude pattern is skipped. For a directory, so is
|
||||||
|
everything in it.
|
||||||
|
|
||||||
Key types:
|
### Deduplication
|
||||||
- `Chunk` → Hash, Data, Offset, Size
|
|
||||||
- `Chunker` → avgChunkSize, minChunkSize, maxChunkSize
|
|
||||||
|
|
||||||
Key methods:
|
Data is stored once at two levels.
|
||||||
- `NewChunker(avgChunkSize)` → Constructor
|
|
||||||
- `ChunkReaderStreaming(reader, callback)` → Stream chunks with callback (preferred)
|
|
||||||
- `ChunkReader(reader)` → Return all chunks at once (memory-intensive)
|
|
||||||
|
|
||||||
### `internal/blob`
|
- **Files.** A file is not read again when its record in the index still matches
|
||||||
Blob packing: accumulates chunks, compresses, encrypts, tracks metadata.
|
it. The record matches when the size, modification time, mode, owner, group
|
||||||
|
and link target are all the same.
|
||||||
|
- **Chunks.** A chunk is named by the SHA-256 hash of its content. A chunk whose
|
||||||
|
hash is already in the index is not stored again.
|
||||||
|
|
||||||
Key types:
|
One case overrides the file check. A file is read again when it lists a chunk
|
||||||
- `Packer` → Thread-safe blob accumulator
|
that no uploaded blob holds. This happens after the only snapshot that used
|
||||||
- `ChunkRef` → Hash + Data for adding to packer
|
such a blob is removed. `loadFilesToRechunk` finds these files.
|
||||||
- `FinishedBlob` → Completed blob ready for upload
|
|
||||||
- `BlobWithReader` → FinishedBlob + io.Reader for streaming upload
|
|
||||||
|
|
||||||
Key methods:
|
### Chunks
|
||||||
- `NewPacker(PackerConfig)` → Constructor
|
|
||||||
- `AddChunk(ChunkRef)` → Add chunk to current blob
|
|
||||||
- `FinalizeBlob()` → Compress, encrypt, hash current blob
|
|
||||||
- `Flush()` → Finalize any in-progress blob
|
|
||||||
- `SetBlobHandler(func)` → Set callback for upload
|
|
||||||
|
|
||||||
### `internal/snapshot`
|
`chunker.Chunker` cuts a file with the FastCDC algorithm. The cut points depend
|
||||||
|
on the content, so an insertion early in a file moves only nearby chunks.
|
||||||
|
|
||||||
#### Scanner
|
`chunk_size` sets the average chunk size. A chunk is between a quarter of that
|
||||||
Orchestrates the backup process for a directory.
|
size and four times that size. With the default of 10 MB, a chunk is between 2.5
|
||||||
|
MB and 40 MB.
|
||||||
|
|
||||||
Key methods:
|
`ChunkReaderStreaming` passes each chunk to a callback as it is found. The whole
|
||||||
- `NewScanner(ScannerConfig)` → Constructor (creates Chunker + Packer)
|
file is never held in memory.
|
||||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
|
||||||
|
|
||||||
Scan phases:
|
### Blobs
|
||||||
1. **Phase 1**: Walk directory, identify files needing processing
|
|
||||||
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the same name lists, the walk did not find, and are gone from disk; a file that is excluded, or inside an excluded directory, is not counted
|
|
||||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
|
||||||
|
|
||||||
#### SnapshotManager
|
`blob.Packer` collects new chunks into a blob. For each blob it does this:
|
||||||
Manages snapshot lifecycle and metadata export.
|
|
||||||
|
|
||||||
Key methods:
|
- It gives the blob a UUID and creates a temporary file.
|
||||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
- It adds each chunk's bytes to a running SHA-256 hash.
|
||||||
- `PopulateSnapshotBlobs(ctx, snapshotID)` → Record every blob the snapshot references
|
- In the same pass it compresses the bytes with zstd and encrypts them with
|
||||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
age. The result goes to the temporary file.
|
||||||
- `MarkSnapshotComplete(ctx, snapshotID)` → Record completion, only after a successful export
|
- It records each chunk's offset and length within the blob.
|
||||||
- `CompleteSnapshot(ctx, snapshotID)` → Convenience: populate blobs, then mark complete (no export between)
|
- It closes the blob when the next chunk would pass `blob_size_limit`, and at
|
||||||
|
the end of each path.
|
||||||
|
|
||||||
### `internal/database`
|
The blob's name is `hex(SHA256(SHA256(uncompressed contents)))`. It is computed
|
||||||
SQLite database for local index. Single-writer mode for thread safety.
|
from the chunk data before compression and encryption.
|
||||||
|
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#blobs) says what follows from
|
||||||
|
that.
|
||||||
|
|
||||||
Key types:
|
When a blob is closed, `Scanner.handleBlobReady` stores it:
|
||||||
- `DB` → Database connection wrapper
|
|
||||||
- `Repositories` → Collection of all repository interfaces
|
|
||||||
|
|
||||||
Repository interfaces:
|
- It looks the blob up at the destination. A blob that is already there is not
|
||||||
- `FilesRepository` → CRUD for File records
|
uploaded again.
|
||||||
- `ChunksRepository` → CRUD for Chunk records
|
- Otherwise it uploads the temporary file to
|
||||||
- `BlobsRepository` → CRUD for Blob records
|
`blobs/<hash[0:2]>/<hash[2:4]>/<hash>`.
|
||||||
- `SnapshotsRepository` → CRUD for Snapshot records
|
- It records the upload in the index and links the blob to the snapshot.
|
||||||
- Plus join table repositories (FileChunks, BlobChunks, etc.)
|
- It deletes the temporary file.
|
||||||
|
|
||||||
## Snapshot Creation Flow
|
A file's record is written to the index only after every blob that holds its
|
||||||
|
chunks is uploaded.
|
||||||
|
|
||||||
```
|
### Snapshot metadata
|
||||||
CreateSnapshot(opts)
|
|
||||||
│
|
|
||||||
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
|
|
||||||
│
|
|
||||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
|
||||||
│
|
|
||||||
├─► SnapshotManager.CreateSnapshot() // Create DB record
|
|
||||||
│
|
|
||||||
├─► For each source directory:
|
|
||||||
│ │
|
|
||||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 1: scanPhase()
|
|
||||||
│ │ │ Walk directory
|
|
||||||
│ │ │ Check file metadata changes
|
|
||||||
│ │ │ Build list of files to process
|
|
||||||
│ │ │
|
|
||||||
│ │ ├─► Phase 1b: detectDeletedFiles()
|
|
||||||
│ │ │
|
|
||||||
│ │ └─► 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
|
`finalizeSnapshotMetadata` does these steps in order:
|
||||||
|
|
||||||
1. **File-level**: Files and symlinks unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid, and a symlink's target), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
|
- `PopulateSnapshotBlobs` records every blob the snapshot uses.
|
||||||
|
- `UpdateSnapshotStatsExtended` stores the snapshot's totals.
|
||||||
|
- `ExportSnapshotMetadata` uploads the metadata.
|
||||||
|
- `MarkSnapshotComplete` marks the snapshot complete.
|
||||||
|
|
||||||
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.
|
Completion is recorded last on purpose. A backup that stops during the upload
|
||||||
|
leaves the snapshot unfinished, and the next backup removes it and stores its
|
||||||
|
data again.
|
||||||
|
|
||||||
3. **Blob-level**: Blobs contain only unique chunks. Duplicate chunks within a blob are skipped.
|
`ExportSnapshotMetadata` works on a copy of the index, never on the index
|
||||||
|
itself:
|
||||||
|
|
||||||
## Storage Layout in S3
|
- It copies the index into a temporary directory with `VACUUM INTO`.
|
||||||
|
- It deletes from the copy everything that does not belong to this snapshot.
|
||||||
|
- 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
|
||||||
bucket/
|
therefore marks a backup that did not finish.
|
||||||
├── vaultik-destination.txt # Marker written by every backup
|
|
||||||
│
|
|
||||||
├── blobs/
|
|
||||||
│ └── {hash[0:2]}/
|
|
||||||
│ └── {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)
|
|
||||||
```
|
|
||||||
|
|
||||||
A destination that answers a look-up of `vaultik-destination.txt` with not
|
## Restore
|
||||||
found is treated as not present while the local index holds snapshots stored
|
|
||||||
there (`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
|
|
||||||
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
|
|
||||||
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
|
|
||||||
destination that cannot be listed. This keeps those commands from taking an
|
|
||||||
unplugged volume's empty mount point for a destination whose snapshots are
|
|
||||||
all gone. When the look-up fails for any reason other than not found, for
|
|
||||||
example when S3 refuses it to credentials that allow only PUT, the check is
|
|
||||||
skipped, so a host with such credentials does not notice an emptied
|
|
||||||
destination; see
|
|
||||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
|
|
||||||
The `file://` backend never creates its destination directory, so a write to a
|
|
||||||
missing one fails.
|
|
||||||
|
|
||||||
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
|
`Vaultik.Restore` does these steps:
|
||||||
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
|
|
||||||
written to the store as a directory name. See
|
|
||||||
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#remote-key-derivation) for the
|
|
||||||
derivation and a worked example.
|
|
||||||
|
|
||||||
## Thread Safety
|
- `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.
|
||||||
|
|
||||||
- `Packer`: Thread-safe via mutex. Multiple goroutines can call `AddChunk()`.
|
The restore does not read the local index or the manifest. It finds each file's
|
||||||
- `Scanner`: Uses `packerMu` mutex to coordinate blob finalization.
|
chunks and blobs through the downloaded database.
|
||||||
- `Database`: Single-writer mode (`MaxOpenConns=1`) ensures SQLite thread safety.
|
|
||||||
- `Repositories.WithTx()`: Handles transaction lifecycle automatically.
|
### 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 the database records 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 each snapshot from the local index whose
|
||||||
|
directory 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 all access to it is in
|
||||||
|
turn.
|
||||||
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user