Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1071058f4e |
+2
-4
@@ -22,10 +22,8 @@ FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
# Build tooling: make, plus a C toolchain because `go test -race` needs cgo.
|
||||
# The sqlite driver is pure Go (modernc.org/sqlite), so no sqlite library or
|
||||
# CLI is required.
|
||||
RUN apk add --no-cache make build-base
|
||||
# Install build dependencies for CGO (mattn/go-sqlite3) and sqlite3 CLI (tests)
|
||||
RUN apk add --no-cache make build-base sqlite
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@@ -603,6 +603,7 @@ regardless of color setting (emoji are not color).
|
||||
and the pre-commit hook both run it. A `golangci-lint` installed on
|
||||
`PATH` is not a substitute and is never used on a host, whatever its
|
||||
version.
|
||||
* `sqlite3` CLI, which the test suite shells out to
|
||||
* S3-compatible object storage (or local filesystem, or rclone remote)
|
||||
|
||||
## development workflow
|
||||
@@ -633,8 +634,8 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
||||
development workflow, and the Makefile targets are thin shims that call
|
||||
them. We provide:
|
||||
|
||||
* `script/bootstrap` — install all development dependencies (go, Go
|
||||
module download). It deliberately does not install `golangci-lint`;
|
||||
* `script/bootstrap` — install all development dependencies (go, sqlite3,
|
||||
Go module download). It deliberately does not install `golangci-lint`;
|
||||
see `script/lint` below.
|
||||
* `script/setup` — make a fresh clone ready for development: runs
|
||||
`script/bootstrap`, then `script/install-precommit`
|
||||
|
||||
@@ -25,14 +25,6 @@ release" is exactly the contradiction
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-21: Made `snapshot create` VACUUM the per-snapshot metadata
|
||||
database through the `modernc.org/sqlite` driver instead of shelling
|
||||
out to the external `sqlite` command-line binary (issue #120). A
|
||||
backup no longer needs that binary on `PATH`, so `make check` passes
|
||||
on a stock `go install` host; `script/bootstrap` and the `Dockerfile`
|
||||
test image no longer install it, and a new test asserts the uploaded
|
||||
database keeps no pages from deleted rows. Dropped the now-false note
|
||||
on the 2026-08-07 entry below that said bootstrap installs it.
|
||||
- 2026-09-21: Made `.gitea/workflows/check.yml` run on pushes to `main`
|
||||
and `next` and on pull requests against either, so unit PRs (whose
|
||||
base is `next`) and `next` itself get a CI run instead of relying on a
|
||||
@@ -537,7 +529,7 @@ release" is exactly the contradiction
|
||||
was green was wrong.
|
||||
- 2026-08-07: Added the standard `.golangci.yml` and `.editorconfig`
|
||||
(issue #59); lint findings under the new config are tracked in issue
|
||||
#61.
|
||||
#61. `script/bootstrap` now installs sqlite3 (needed by tests).
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||
Makefile shims, README Entrypoints section
|
||||
- 2026-07-02: Consolidated CLI verbs, retired overlapping commands; bound
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ Tracks blob upload metrics.
|
||||
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
|
||||
3. Export to SQL dump using sqlite3
|
||||
4. Compress with zstd and encrypt with age
|
||||
5. Upload to S3 as `metadata/{snapshot-id}/db.zst.age`
|
||||
6. Generate blob manifest and upload as `metadata/{snapshot-id}/manifest.json.zst`
|
||||
|
||||
+30
-1
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -24,6 +25,11 @@ const configSetArgs = 2
|
||||
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
||||
const configDirMode = 0o755
|
||||
|
||||
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
||||
// so `config set` writes the file back with the same indentation rather than
|
||||
// yaml.Marshal's 4-space default.
|
||||
const configYAMLIndent = 2
|
||||
|
||||
var (
|
||||
errConfigExists = errors.New("config file already exists")
|
||||
errEmptyConfig = errors.New("empty config file")
|
||||
@@ -381,7 +387,7 @@ Examples:
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := yaml.Marshal(root)
|
||||
out, err := marshalConfigYAML(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
@@ -405,6 +411,29 @@ Examples:
|
||||
}
|
||||
}
|
||||
|
||||
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
||||
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
||||
// would reindent the whole file on the first `config set` despite the promise
|
||||
// to preserve formatting.
|
||||
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(configYAMLIndent)
|
||||
|
||||
err := enc.Encode(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = enc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
||||
// which preserves comments and ordering for round-tripping.
|
||||
func loadYAMLFile(path string) (*yaml.Node, error) {
|
||||
|
||||
@@ -188,6 +188,47 @@ func TestYAMLPathSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigSetPreservesFormatting asserts the `config set` write path
|
||||
// (marshalConfigYAML) round-trips a 2-space-indented file without reindenting
|
||||
// it to yaml.Marshal's 4-space default, and keeps comments.
|
||||
func TestConfigSetPreservesFormatting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := parseTestYAML(t)
|
||||
|
||||
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
|
||||
if err != nil {
|
||||
t.Fatalf("set s3.bucket: %v", err)
|
||||
}
|
||||
|
||||
out, err := marshalConfigYAML(root)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
text := string(out)
|
||||
|
||||
for _, want := range []string{"# top comment", "# inline comment"} {
|
||||
if !contains(text, want) {
|
||||
t.Errorf("round-tripped YAML dropped comment %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Nested map keys stay at 2-space indent; the bug reindented them to 4.
|
||||
if !contains(text, "\n bucket: newbucket") {
|
||||
t.Errorf("expected 2-space indent for s3.bucket, got:\n%s", text)
|
||||
}
|
||||
|
||||
if contains(text, "\n bucket:") {
|
||||
t.Errorf("s3.bucket reindented to 4 spaces:\n%s", text)
|
||||
}
|
||||
|
||||
// Sequence items under a key also stay at 2 spaces.
|
||||
if !contains(text, "\n - age1aaa") {
|
||||
t.Errorf("expected 2-space indent for sequence item, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func splitPath(s string) []string {
|
||||
return strings.Split(s, ".")
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package cli
|
||||
|
||||
import "time"
|
||||
|
||||
// SnapshotInfo represents snapshot information for listing
|
||||
//
|
||||
//nolint:tagliatelle // snake_case is the established output format
|
||||
type SnapshotInfo struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CompressedSize int64 `json:"compressed_size"`
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Package models defines shared value types describing files, chunks,
|
||||
// blobs, and snapshots as they move through the backup pipeline.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileInfo represents a file in the backup system
|
||||
type FileInfo struct {
|
||||
Path string
|
||||
MTime time.Time
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ChunkInfo represents a content-addressed chunk
|
||||
type ChunkInfo struct {
|
||||
Hash string // SHA256 hash
|
||||
Size int64
|
||||
Offset int64 // Offset within source file
|
||||
}
|
||||
|
||||
// ChunkRef represents a reference to a chunk in a blob or file
|
||||
type ChunkRef struct {
|
||||
ChunkHash string
|
||||
Offset int64
|
||||
Length int64
|
||||
}
|
||||
|
||||
// BlobInfo represents an encrypted blob containing multiple chunks
|
||||
type BlobInfo struct {
|
||||
Hash string // SHA256 hash of the blob content (content-addressable)
|
||||
CreatedAt time.Time
|
||||
Size int64
|
||||
ChunkCount int
|
||||
}
|
||||
|
||||
// Snapshot represents a backup snapshot
|
||||
type Snapshot struct {
|
||||
ID string // ISO8601 timestamp
|
||||
Hostname string
|
||||
Version string
|
||||
CreatedAt time.Time
|
||||
FileCount int64
|
||||
ChunkCount int64
|
||||
BlobCount int64
|
||||
TotalSize int64
|
||||
MetadataSize int64
|
||||
}
|
||||
|
||||
// SnapshotMetadata contains the full metadata for a snapshot
|
||||
type SnapshotMetadata struct {
|
||||
Snapshot *Snapshot
|
||||
Files map[string]*FileInfo
|
||||
Chunks map[string]*ChunkInfo
|
||||
Blobs map[string]*BlobInfo
|
||||
FileChunks map[string][]*ChunkRef // path -> chunks
|
||||
BlobChunks map[string][]*ChunkRef // blob hash -> chunks
|
||||
}
|
||||
|
||||
// Chunk represents a data chunk for processing
|
||||
type Chunk struct {
|
||||
Data []byte
|
||||
Hash string
|
||||
Offset int64
|
||||
Length int64
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/models"
|
||||
)
|
||||
|
||||
// TestModelsCompilation ensures all model types can be instantiated
|
||||
func TestModelsCompilation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test primarily serves as a compilation test
|
||||
// to ensure all types are properly defined
|
||||
|
||||
// Test FileInfo
|
||||
fi := &models.FileInfo{
|
||||
Path: "/test/file.txt",
|
||||
MTime: time.Now(),
|
||||
Size: 1024,
|
||||
}
|
||||
if fi.Path != "/test/file.txt" {
|
||||
t.Errorf("FileInfo.Path not set correctly")
|
||||
}
|
||||
|
||||
// Test ChunkInfo
|
||||
ci := &models.ChunkInfo{
|
||||
Hash: "abc123",
|
||||
Size: 512,
|
||||
Offset: 0,
|
||||
}
|
||||
if ci.Hash != "abc123" {
|
||||
t.Errorf("ChunkInfo.Hash not set correctly")
|
||||
}
|
||||
|
||||
// Test BlobInfo
|
||||
bi := &models.BlobInfo{
|
||||
Hash: "blob123",
|
||||
CreatedAt: time.Now(),
|
||||
Size: 1024,
|
||||
ChunkCount: 2,
|
||||
}
|
||||
if bi.Hash != "blob123" {
|
||||
t.Errorf("BlobInfo.Hash not set correctly")
|
||||
}
|
||||
|
||||
// Test Snapshot
|
||||
s := &models.Snapshot{
|
||||
ID: "2024-01-01T00:00:00Z",
|
||||
Hostname: "test-host",
|
||||
Version: "1.0.0",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if s.ID != "2024-01-01T00:00:00Z" {
|
||||
t.Errorf("Snapshot.ID not set correctly")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -668,31 +669,14 @@ func (sm *SnapshotManager) collectCleanupStats(
|
||||
|
||||
// vacuumDatabase runs VACUUM on the database to remove deleted data and compact
|
||||
// This is critical for security - ensures no stale/deleted data pages are uploaded
|
||||
//
|
||||
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
||||
// connection with no transaction in flight (VACUUM cannot run inside one).
|
||||
// The database opens in WAL mode, so VACUUM's rewrite lands in the WAL; the
|
||||
// checkpoint on Close flushes it into the main file, which is the file we
|
||||
// then compress and upload.
|
||||
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
||||
log.Debug("Running VACUUM on database", "path", dbPath)
|
||||
//nolint:gosec // G204: fixed argv; dbPath is our own temp file path
|
||||
cmd := exec.CommandContext(ctx, "sqlite3", dbPath, "VACUUM;")
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening database for VACUUM: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
cerr := db.Close()
|
||||
if cerr != nil {
|
||||
log.Debug("Failed to close database after VACUUM",
|
||||
"path", dbPath, "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = db.ExecWithLog(ctx, "VACUUM")
|
||||
if err != nil {
|
||||
return fmt.Errorf("running VACUUM: %w", err)
|
||||
return fmt.Errorf("running VACUUM: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
@@ -97,97 +96,6 @@ func verifyCleanedDB(
|
||||
}
|
||||
}
|
||||
|
||||
// TestVacuumDatabaseRemovesDeletedData proves the export path uploads a
|
||||
// compacted database: after rows carrying a recognizable marker are deleted
|
||||
// and vacuumDatabase runs, no page holding that marker survives in the file
|
||||
// on disk (the file compressFile later reads for upload).
|
||||
func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "snapshot.db")
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
|
||||
// A marker distinctive enough that its presence in the raw file can only
|
||||
// come from the rows inserted below.
|
||||
marker := []byte("VACUUM_PROBE_DEADBEEF_DELETED_ROW")
|
||||
payload := bytes.Repeat(marker, 128) // ~4 KiB per row
|
||||
|
||||
_, err = db.Conn().ExecContext(ctx,
|
||||
"CREATE TABLE vacuum_probe (id INTEGER PRIMARY KEY, payload BLOB)")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create probe table: %v", err)
|
||||
}
|
||||
|
||||
for range 512 {
|
||||
_, err = db.Conn().ExecContext(ctx,
|
||||
"INSERT INTO vacuum_probe (payload) VALUES (?)", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert probe row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Conn().ExecContext(ctx, "DELETE FROM vacuum_probe")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to delete probe rows: %v", err)
|
||||
}
|
||||
|
||||
// Close so the deletes reach the main file, mirroring the state
|
||||
// prepareExportDB hands to vacuumDatabase.
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to close database: %v", err)
|
||||
}
|
||||
|
||||
beforeInfo, err := fs.Stat(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat database before vacuum: %v", err)
|
||||
}
|
||||
|
||||
beforeBytes, err := afero.ReadFile(fs, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read database before vacuum: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(beforeBytes, marker) {
|
||||
t.Fatalf("expected deleted-row data to linger before vacuum")
|
||||
}
|
||||
|
||||
sm := &SnapshotManager{fs: fs}
|
||||
|
||||
err = sm.vacuumDatabase(ctx, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("vacuumDatabase failed: %v", err)
|
||||
}
|
||||
|
||||
afterBytes, err := afero.ReadFile(fs, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read database after vacuum: %v", err)
|
||||
}
|
||||
|
||||
if bytes.Contains(afterBytes, marker) {
|
||||
t.Fatalf("deleted-row data survived vacuum in the uploaded file")
|
||||
}
|
||||
|
||||
afterInfo, err := fs.Stat(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to stat database after vacuum: %v", err)
|
||||
}
|
||||
|
||||
if afterInfo.Size() >= beforeInfo.Size() {
|
||||
t.Fatalf("expected vacuum to shrink the file: before=%d after=%d",
|
||||
beforeInfo.Size(), afterInfo.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
||||
// Initialize logger
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
@@ -114,6 +114,9 @@ main() {
|
||||
# from CI. Nothing on the host is ever used as a linter, at any
|
||||
# version, so installing one here would buy nothing.
|
||||
|
||||
# sqlite3 CLI: the test suite shells out to it (VACUUM).
|
||||
if missing sqlite3; then pkg_install sqlite sqlite3 sqlite sqlite; fi
|
||||
|
||||
# goreleaser, at the version pinned by script/install-goreleaser and
|
||||
# verified against a hardcoded sha256. Package managers are not used
|
||||
# for it: they ship whatever version they happen to carry, and the
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Vaultik test configuration
|
||||
hostname: test-host
|
||||
index_path: /tmp/vaultik-test/index.db
|
||||
source_dirs:
|
||||
- /tmp/vaultik-test/source
|
||||
|
||||
# S3 configuration
|
||||
s3:
|
||||
endpoint: http://localhost:19000 # gofakes3 test endpoint
|
||||
bucket: test-bucket
|
||||
prefix: test-
|
||||
access_key_id: test-key
|
||||
secret_access_key: test-secret
|
||||
region: us-east-1
|
||||
|
||||
# Chunking configuration
|
||||
chunk_size: 65536 # 64KB average chunk size
|
||||
min_chunk_size: 32768 # 32KB minimum
|
||||
max_chunk_size: 131072 # 128KB maximum
|
||||
blob_size: 1048576 # 1MB blobs for testing
|
||||
|
||||
# Compression
|
||||
compression_level: 3
|
||||
|
||||
# Encryption
|
||||
# age_recipients:
|
||||
# - age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3mw88h
|
||||
@@ -1,24 +0,0 @@
|
||||
age_recipients:
|
||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj # sneak's long term age key
|
||||
- age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg # insecure integration test key
|
||||
source_dirs:
|
||||
- /tmp/vaultik-test-source
|
||||
exclude:
|
||||
- '*.log'
|
||||
- '*.tmp'
|
||||
- '.git'
|
||||
- 'node_modules'
|
||||
s3:
|
||||
endpoint: http://ber1app1.local:3900/
|
||||
bucket: vaultik-integration-test
|
||||
prefix: test-host/
|
||||
access_key_id: GKbc8e6d35fdf50847f155aca5
|
||||
secret_access_key: 217046bee47c050301e3cc13e3cba1a8a943cf5f37f8c7979c349c5254441d18
|
||||
region: us-east-1
|
||||
use_ssl: false
|
||||
part_size: 5242880 # 5MB
|
||||
index_path: /tmp/vaultik-integration-test.sqlite
|
||||
chunk_size: 10MB
|
||||
blob_size_limit: 10GB
|
||||
compression_level: 3
|
||||
hostname: test-host
|
||||
Reference in New Issue
Block a user