diff --git a/README.md b/README.md index 32a0c81..14a7bb1 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,14 @@ vaultik snapshot restore ~/vaultik-restore # 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w ``` +On a destination shared between hosts or users, the prune in that cron line +relies on `prune_grace_period` (default `24h`) to leave the blobs of a backup +another one is still running, so it must be longer than the longest backup +run. It does not protect a blob that backup reuses from an earlier run that +stopped before its manifest: the prune can delete such a blob, and the new +snapshot then cannot be restored. To not depend on the grace period, give each +host its own prefix or directory. See [locking](#locking). + ## restoring on another machine Restoring on a host that never ran the backup — a replacement machine @@ -192,7 +200,22 @@ process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid` (`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only one of them runs at a time: a second one exits immediately with an "already running" error rather than waiting, so two writers can never -corrupt the local index or the destination store. +corrupt the local index. + +The lock covers one user on one machine. It does not stop another user, +or another host, from writing to the same destination store at the same +time. A backup uploads its blobs before the manifest that lists them, so a +prune from elsewhere at that moment finds blobs no snapshot lists. It +deletes such a blob only once it is older than `prune_grace_period` +(default `24h`), which therefore must be longer than the longest backup run +of any host or user sharing the destination. The grace period does not +protect a blob a backup reuses from an earlier run that stopped before its +manifest: a backup skips the upload of a blob already on the destination, so +that blob keeps the time of its first upload, and a prune from elsewhere +during the backup can delete it; the new snapshot then cannot be restored. +`remote nuke` deletes everything regardless. To not depend on the grace +period, give each host or user its own prefix (or directory) on the +destination. Read-only commands — `info`, `snapshot list`, `snapshot verify`, and `remote info` — do not take the lock and are never blocked, so they run @@ -296,7 +319,8 @@ value where a list belongs or a word where a number belongs), is refused. * `--cron`: Silent on total success; warnings and errors are still printed (for crontab) * `--prune`: After backup, drop older snapshots of each backed-up name and - remove orphaned blobs from remote storage. By default keeps only the latest + remove orphaned blobs older than `prune_grace_period` from remote storage, + as `prune` does. By default keeps only the latest snapshot per name; use `--keep-newer-than` for a rolling window. When `--skip-errors` skipped a file or directory it could not read or list, the prune does not run and every older snapshot is kept, since one of them may @@ -423,9 +447,15 @@ local snapshot whose remote metadata is missing is dropped from the local index; (2) delete orphaned local rows (files, chunks, blobs no longer referenced by any completed snapshot); (3) list every remote manifest on the destination store to compute the still-referenced blob -set and delete any blob not in that set. Step (3) reads all remote -manifests — network cost scales with the number of snapshots. `snapshot -create --prune` runs the same cleanup automatically; this is the +set and delete any blob not in that set that is older than +`prune_grace_period` (default `24h`). A newer one is kept and counted: 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. Step (3) reads all remote manifests — network cost scales with +the number of snapshots. +`snapshot create --prune` runs the same cleanup automatically; this is the manual entry point for the same work. If the destination store cannot be listed, or the local index holds snapshots and the destination has no `vaultik-destination.txt` (an unplugged volume's empty mount point), @@ -436,6 +466,9 @@ exits non-zero. * `--json`: Output stats as JSON. Also skips the confirmation prompt, as `--force` does. A blob that cannot be deleted is counted in `blobs_failed`, and the document is printed before the non-zero exit. + A blob kept for being newer than `prune_grace_period`, or because the + listing of the destination gives no time for it, is counted in + `blobs_too_new` and not in `blobs_found`. **`info`**: Display system configuration, storage settings, encryption recipients, and local database statistics. @@ -510,6 +543,14 @@ being written, a killed upload can leave a truncated object under its name, which a later backup takes for complete. A leftover `.partial` file is ignored and can be deleted. +A destination shared between hosts or users, each with its own local index, +relies on `prune_grace_period` to keep one's prune from deleting the blobs of +another's backup still running, so it must be longer than the longest backup +run. It does not protect a blob that backup reuses from an earlier run that +stopped before its manifest, which the prune can delete. To not depend on the +grace period, give each host its own prefix (or directory). See +[locking](#locking). + Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is still supported for backward compatibility. `storage_url` takes precedence if both are set. @@ -596,7 +637,8 @@ format does and does not protect. 1. List all snapshot manifests 2. Build set of all referenced blob hashes 3. List all blobs in storage -4. Delete any blob not in the referenced set +4. Delete any blob not in the referenced set that is older than + `prune_grace_period` ### chunking and deduplication @@ -641,6 +683,7 @@ complete annotated example also lives in | `chunk_size` | `10MB` | Average chunk size for content-defined chunking | | `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit. 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` | | `compression_level` | `3` | zstd compression level (1-19) | +| `prune_grace_period` | `24h` | How old a blob that no snapshot lists must be before `prune` deletes it. A backup uploads its blobs before the manifest that lists them, so on a destination shared between hosts or users this must be longer than the longest backup run (see [locking](#locking)). 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. A duration such as `36h` or `90m`; `0s` deletes such blobs at once | | `hostname` | system hostname | Hostname used in snapshot IDs | | `index_path` | platform data dir | Local SQLite index path | diff --git a/config.example.yml b/config.example.yml index 55dae8e..a2d8f84 100644 --- a/config.example.yml +++ b/config.example.yml @@ -328,6 +328,18 @@ storage_url: "rclone://myremote/path/to/backups" # Higher = better compression but slower # Default: 3 compression_level: 5 + +# How old a blob that no snapshot lists must be before prune deletes it +# A backup uploads its blobs before the manifest that lists them, 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 them at once +# Default: 24h +#prune_grace_period: 24h + # Hostname to use in backup metadata # Default: system hostname #hostname: myserver diff --git a/docs/DATAMODEL.md b/docs/DATAMODEL.md index 9f94fde..4f0ad45 100644 --- a/docs/DATAMODEL.md +++ b/docs/DATAMODEL.md @@ -228,7 +228,8 @@ The restore process doesn't use the local database. Instead: 1. **Identify Unreferenced Blobs** - Query blobs not referenced by any remaining snapshot - - Delete from S3 and local database + - Delete from S3, once older than `prune_grace_period`, and from the local + database ### 6. Incomplete Snapshot Cleanup diff --git a/docs/REPOSTRUCTURE.md b/docs/REPOSTRUCTURE.md index 6a0661a..b7c6946 100644 --- a/docs/REPOSTRUCTURE.md +++ b/docs/REPOSTRUCTURE.md @@ -166,7 +166,7 @@ The prune operation is safe because: 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 +4. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`). A backup uploads its blobs before the manifest that lists them, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run takes longer than the grace period. The grace period does not protect a blob a backup reuses from an earlier run that stopped before its manifest: the backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check ## Restoration Requirements diff --git a/internal/cli/config.go b/internal/cli/config.go index 468d031..638b6ef 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -239,6 +239,16 @@ storage_url: "" # Default: 3 # compression_level: 3 +# How old a blob that no snapshot lists must be before prune deletes it. +# A backup uploads its blobs before the manifest that lists them, 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 them at once. +# Default: 24h +# prune_grace_period: 24h + # Hostname used in snapshot IDs. Default: system hostname. # hostname: myserver diff --git a/internal/cli/entry_prune_quiet_test.go b/internal/cli/entry_prune_quiet_test.go index 92e7a3b..30e1a33 100644 --- a/internal/cli/entry_prune_quiet_test.go +++ b/internal/cli/entry_prune_quiet_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,8 +15,8 @@ import ( const flagCron = "--cron" // plantUnreferencedBlob writes a blob that no snapshot references to the -// destination store at storeDir, so that a prune deletes it. Returns its -// path. +// destination store at storeDir, dated long before any prune grace period, +// so that a prune deletes it. Returns its path. func plantUnreferencedBlob(t *testing.T, storeDir string) string { 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.WriteFile(path, []byte("unreferenced"), 0o600)) + longAgo := time.Unix(0, 0) + require.NoError(t, os.Chtimes(path, longAgo, longAgo)) + return path } diff --git a/internal/cli/prune.go b/internal/cli/prune.go index 707617b..77f38b6 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -20,7 +20,12 @@ Local cleanup drops incomplete snapshots and any files, chunks, or blobs no longer referenced by a completed snapshot. Remote cleanup scans every snapshot manifest in the destination store, builds the 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. Snapshot create --prune runs the same cleanup automatically; this command is the manual entry point for the same work (e.g. after a diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 7464681..efb176b 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -132,8 +132,8 @@ on Linux), then /etc/vaultik/config.yml.`, "Run in cron mode (silent unless warning or error)") cmd.Flags().BoolVar(&opts.Prune, "prune", false, "After backup, drop older snapshots of the same name and remove "+ - "orphaned blobs (not after a backup that skipped unreadable "+ - "files)") + "orphaned blobs older than prune_grace_period (not after a "+ + "backup that skipped unreadable files)") cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "", "With --prune: keep snapshots newer than this duration "+ "(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+ diff --git a/internal/config/config.go b/internal/config/config.go index 64778c5..a2b86b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "path/filepath" "sort" "strings" + "time" "filippo.io/age" "git.eeqj.de/sneak/smartconfig" @@ -38,6 +39,7 @@ const ( defaultChunkSize = Size(10 * 1000 * 1000) // 10MB defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB defaultCompressionLevel = 3 + defaultPruneGracePeriod = 24 * time.Hour minChunkSize = 1000 * 1000 // 1MB minCompressionLevel = 1 maxCompressionLevel = 19 @@ -67,6 +69,8 @@ var ( errFileURLNotAbsolute = errors.New( "a file:// storage_url needs an absolute path, " + "as in file:///path/to/backup") + errNegativePruneGracePeriod = errors.New( + "prune_grace_period must not be negative") errStorageNotConfigured = errors.New( "storage not configured; set storage_url or provide s3.endpoint + " + "s3.bucket + credentials") @@ -187,6 +191,13 @@ type Config struct { Snapshots map[string]SnapshotConfig `yaml:"snapshots"` CompressionLevel int `yaml:"compression_level"` + // PruneGracePeriod is how old a blob no manifest lists must be before + // a prune deletes it. A backup lists its blobs in a manifest only + // once it has uploaded them all, so a younger one 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. // Takes precedence over S3Config if set. // Supported formats: @@ -288,6 +299,7 @@ func Parse(data []byte) (*Config, error) { ChunkSize: defaultChunkSize, IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"), CompressionLevel: defaultCompressionLevel, + PruneGracePeriod: defaultPruneGracePeriod, S3: S3Config{PartSize: defaultS3PartSize}, } @@ -356,6 +368,7 @@ func Parse(data []byte) (*Config, error) { // exceeds the configured limit // - Compression level must be between 1 and 19 // - 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. func (c *Config) Validate() error { @@ -405,6 +418,10 @@ func (c *Config) Validate() error { return errBadS3PartSize } + if c.PruneGracePeriod < 0 { + return errNegativePruneGracePeriod + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 897b7e7..3f1a26b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "sneak.berlin/go/vaultik/internal/chunker" "sneak.berlin/go/vaultik/internal/log" @@ -412,6 +413,73 @@ func TestDocumentedSizeExamples(t *testing.T) { } } +// TestValidatePruneGracePeriod checks that a negative prune_grace_period +// is refused rather than acting like 0s, which deletes unreferenced blobs +// 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 // relative path fails at load. Accepted, it would resolve against the // working directory, so a backup run from another directory would write diff --git a/internal/s3/client.go b/internal/s3/client.go index 0867a8a..fe74a82 100644 --- a/internal/s3/client.go +++ b/internal/s3/client.go @@ -8,6 +8,7 @@ import ( "io" "strings" "sync/atomic" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -302,7 +303,10 @@ func IsNotFound(err error) bool { type ObjectInfo struct { Key string 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. @@ -343,8 +347,9 @@ func (c *Client) ListObjectsStream( } ch <- ObjectInfo{ - Key: key, - Size: *obj.Size, + Key: key, + Size: *obj.Size, + ModTime: aws.ToTime(obj.LastModified), } } } diff --git a/internal/storage/conformance_test.go b/internal/storage/conformance_test.go index e5c35f9..8e8ebbe 100644 --- a/internal/storage/conformance_test.go +++ b/internal/storage/conformance_test.go @@ -8,6 +8,7 @@ import ( "reflect" "sort" "testing" + "time" "sneak.berlin/go/vaultik/internal/storage" ) @@ -19,15 +20,17 @@ import ( // constructor, so the contract is defined once. // // It exercises the public Storer interface: round-trip, stat, list with -// prefix filtering, overwrite, delete, delete-of-missing, and not-found on -// Get and Stat. Each section takes its own fresh backend instance, so the -// order of sections never matters and no section sees another's objects. +// prefix filtering, the modification time ListStream reports, overwrite, +// delete, delete-of-missing, and not-found on Get and Stat. Each section +// 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) { t.Helper() conformanceRoundTrip(t, newStorer(t)) conformanceOverwrite(t, newStorer(t)) conformanceList(t, newStorer(t)) + conformanceListStreamModTime(t, newStorer(t)) conformanceDelete(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 // a missing key is not an error. func conformanceDelete(t *testing.T, s storage.Storer) { diff --git a/internal/storage/file.go b/internal/storage/file.go index af97ef7..734a748 100644 --- a/internal/storage/file.go +++ b/internal/storage/file.go @@ -235,8 +235,9 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec // Normalize path separators relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/") ch <- ObjectInfo{ - Key: relPath, - Size: info.Size(), + Key: relPath, + Size: info.Size(), + ModTime: info.ModTime(), } } diff --git a/internal/storage/rclone.go b/internal/storage/rclone.go index 4ca2be1..39413cb 100644 --- a/internal/storage/rclone.go +++ b/internal/storage/rclone.go @@ -53,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e 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 fsys, err := fs.NewFs(ctx, rclonePath) if err != nil { @@ -211,8 +219,9 @@ func (r *RcloneStorer) ListStream( if prefix == "" || strings.HasPrefix(key, prefix) { ch <- ObjectInfo{ - Key: key, - Size: obj.Size(), + Key: key, + Size: obj.Size(), + ModTime: obj.ModTime(ctx), } } }) diff --git a/internal/storage/rclone_test.go b/internal/storage/rclone_test.go index 1f2d9ee..32ac7a2 100644 --- a/internal/storage/rclone_test.go +++ b/internal/storage/rclone_test.go @@ -5,11 +5,17 @@ import ( "context" "errors" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" + "sync/atomic" "testing" + "time" + "github.com/johannesboyne/gofakes3" + "github.com/johannesboyne/gofakes3/backend/s3mem" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/config" "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) } } + +// 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) + } +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go index 4f2b53b..bbe133b 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -89,9 +89,10 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI for info := range s.client.ListObjectsStream(ctx, prefix, false) { ch <- ObjectInfo{ - Key: info.Key, - Size: info.Size, - Err: info.Err, + Key: info.Key, + Size: info.Size, + ModTime: info.ModTime, + Err: info.Err, } } }() diff --git a/internal/storage/storer.go b/internal/storage/storer.go index 85081f3..3417cb7 100644 --- a/internal/storage/storer.go +++ b/internal/storage/storer.go @@ -14,6 +14,7 @@ import ( "context" "errors" "io" + "time" ) // ErrNotFound is returned when an object does not exist. @@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error type ObjectInfo struct { Key string // Object key/path 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. @@ -67,7 +71,8 @@ type Storer interface { // For large result sets, prefer ListStream. 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. // If an error occurs during listing, the final item will have Err set. ListStream(ctx context.Context, prefix string) <-chan ObjectInfo diff --git a/internal/vaultik/info.go b/internal/vaultik/info.go index 64656d8..9cd034f 100644 --- a/internal/vaultik/info.go +++ b/internal/vaultik/info.go @@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage( return err } - for _, size := range allBlobs { + for _, blob := range allBlobs { result.TotalBlobCount++ - result.TotalBlobSize += size + result.TotalBlobSize += blob.Size } // 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 ) - for hash, size := range allBlobs { + for hash, blob := range allBlobs { if _, referenced := referencedBlobs[hash]; !referenced { orphanedCount++ - orphanedSize += size + orphanedSize += blob.Size } } diff --git a/internal/vaultik/prune.go b/internal/vaultik/prune.go index b7148e9..56619d7 100644 --- a/internal/vaultik/prune.go +++ b/internal/vaultik/prune.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "strings" + "time" "sneak.berlin/go/vaultik/internal/log" + "sneak.berlin/go/vaultik/internal/storage" ) // PruneOptions contains options for the prune command @@ -163,6 +165,10 @@ type PruneBlobsResult struct { BlobsDeleted int `json:"blobs_deleted"` BlobsFailed int `json:"blobs_failed,omitempty"` 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"` } // Prune removes orphaned data from the local index database AND @@ -208,9 +214,15 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error { return err } - unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced) + unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs( + allBlobs, allBlobsReferenced) - result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)} + result := &PruneBlobsResult{ + BlobsFound: len(unreferencedBlobs), + BlobsTooNew: tooNew, + } + + v.reportBlobsTooNew(tooNew, opts) if len(unreferencedBlobs) == 0 { log.Info("No unreferenced blobs found") @@ -344,11 +356,12 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) { return snapshotIDs, nil } -// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage -func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) { +// listAllRemoteBlobs returns a map of all blob hashes in remote storage +// to their listing entries. +func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) { 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/") for object := range blobObjectCh { @@ -372,7 +385,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) { continue } - allBlobs[parts[3]] = object.Size + allBlobs[parts[3]] = object } log.Info("Found blobs in storage", "count", len(allBlobs)) @@ -380,30 +393,59 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) { return allBlobs, nil } -// findUnreferencedBlobs returns blob hashes not referenced by any -// manifest and their total size. +// findUnreferencedBlobs returns the hashes of the blobs no manifest +// 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( - allBlobs map[string]int64, referenced map[string]bool, -) ([]string, int64) { + allBlobs map[string]storage.ObjectInfo, referenced map[string]bool, +) ([]string, int64, int) { var ( unreferenced []string totalSize int64 + tooNew int ) - for hash, size := range allBlobs { - if !referenced[hash] { - unreferenced = append(unreferenced, hash) - totalSize += size + cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod) + + for hash, blob := range allBlobs { + if referenced[hash] { + continue } + + if blob.ModTime.IsZero() || blob.ModTime.After(cutoff) { + tooNew++ + + continue + } + + unreferenced = append(unreferenced, hash) + totalSize += blob.Size } - return unreferenced, totalSize + return unreferenced, totalSize, tooNew +} + +// 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 // populates the result. 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") @@ -418,7 +460,7 @@ func (v *Vaultik) deleteUnreferencedBlobs( } result.BlobsDeleted++ - result.BytesFreed += allBlobs[hash] + result.BytesFreed += allBlobs[hash].Size if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 { log.Info("Deletion progress", diff --git a/internal/vaultik/prune_grace_period_test.go b/internal/vaultik/prune_grace_period_test.go new file mode 100644 index 0000000..e5004bf --- /dev/null +++ b/internal/vaultik/prune_grace_period_test.go @@ -0,0 +1,166 @@ +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 but not yet its manifest, so no manifest lists those blobs. The +// prune must leave them, 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") + + 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) +} diff --git a/internal/vaultik/remove_snapshot_test.go b/internal/vaultik/remove_snapshot_test.go index c9cc7fe..9d43aec 100644 --- a/internal/vaultik/remove_snapshot_test.go +++ b/internal/vaultik/remove_snapshot_test.go @@ -26,11 +26,14 @@ import ( type testStorer struct { mu sync.Mutex data map[string][]byte + // putTimes holds when Put stored each object; ListStream reports it. + putTimes map[string]time.Time } func newTestStorer() *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.putTimes[key] = time.Now().UTC() return nil } @@ -87,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error { defer s.mu.Unlock() delete(s.data, key) + delete(s.putTimes, key) return nil } @@ -120,8 +125,9 @@ func (s *testStorer) ListStream( for key, data := range s.data { if prefix == "" || strings.HasPrefix(key, prefix) { ch <- storage.ObjectInfo{ - Key: key, - Size: int64(len(data)), + Key: key, + Size: int64(len(data)), + ModTime: s.putTimes[key], } } }