diff --git a/README.md b/README.md index 5d2188b..5c58a8f 100644 --- a/README.md +++ b/README.md @@ -398,7 +398,8 @@ key is skipped with a warning and is not printed. If a listed orphaned blob figures are reported as unknown; `--json` gives them as `null`, lists the remote key of each unreadable manifest in `unreadable_manifests` and counts the manifests under skipped names in -`skipped_manifest_count`. +`skipped_manifest_count`. An unreadable manifest also leaves its +snapshot's blob count and blob size unknown, `null` in `--json`. * `--json`: Output as JSON **`remote nuke`**: Delete every snapshot's metadata and every blob from the diff --git a/TODO.md b/TODO.md index 906798c..ef17e48 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,15 @@ the tag exists and is exercised; what is left is merging `next` to # Completed Steps +- 2026-10-08: Made `remote info` report a snapshot's blob count and + blob size as unknown when its manifest cannot be read + ([issue #272](https://git.eeqj.de/sneak/vaultik/issues/272)). The + orphan figures were already unknown in that case, but the snapshot's + row still gave 0 blobs and 0 B, in the table and in `--json`. The row + now reads `unknown` and `--json` gives `null`. A directory with no + manifest still shows 0, since the orphan figures count its blobs as + orphaned. + - 2026-10-08: Made a second `snapshot create` of one name succeed when it starts in the same second as the first ([issue #270](https://git.eeqj.de/sneak/vaultik/issues/270)). The diff --git a/internal/vaultik/info.go b/internal/vaultik/info.go index a39659d..9ee6ad9 100644 --- a/internal/vaultik/info.go +++ b/internal/vaultik/info.go @@ -181,8 +181,11 @@ type SnapshotMetadataInfo struct { ManifestSize int64 `json:"manifest_size"` DatabaseSize int64 `json:"database_size"` TotalSize int64 `json:"total_size"` - BlobCount int `json:"blob_count"` - BlobsSize int64 `json:"blobs_size"` + + // Both stay nil (null in the JSON) when the snapshot's manifest was + // listed but could not be read. + BlobCount *int `json:"blob_count"` + BlobsSize *int64 `json:"blobs_size"` // Set when the listing holds this snapshot's manifest.json.zst. A // backup interrupted before its manifest upload leaves a directory @@ -380,6 +383,10 @@ func (v *Vaultik) collectReferencedBlobsFromManifests( for _, snapshotID := range snapshotIDs { info := snapshotMetadata[snapshotID] if !info.hasManifest { + // The orphan figures count this directory's blobs as + // orphaned, so it references none. + info.BlobCount, info.BlobsSize = new(int), new(int64) + continue } @@ -395,7 +402,7 @@ func (v *Vaultik) collectReferencedBlobsFromManifests( continue } - info.BlobCount = manifest.BlobCount + blobCount := manifest.BlobCount var blobsSize int64 @@ -404,7 +411,8 @@ func (v *Vaultik) collectReferencedBlobsFromManifests( blobsSize += blob.CompressedSize } - info.BlobsSize = blobsSize + info.BlobCount = &blobCount + info.BlobsSize = &blobsSize } return referencedBlobs, unreadable @@ -516,13 +524,23 @@ func (v *Vaultik) printRemoteInfoTable(result *RemoteInfoResult) { v.stdoutf("%s", separator) for _, info := range result.Snapshots { + blobCount := countUnknown + if info.BlobCount != nil { + blobCount = humanize.Comma(int64(*info.BlobCount)) + } + + blobsSize := countUnknown + if info.BlobsSize != nil { + blobsSize = ubytes(*info.BlobsSize) + } + v.stdoutf(rowFormat, truncateString(info.SnapshotID, snapshotIDColWidth), ubytes(info.ManifestSize), ubytes(info.DatabaseSize), ubytes(info.TotalSize), - humanize.Comma(int64(info.BlobCount)), - ubytes(info.BlobsSize), + blobCount, + blobsSize, ) } diff --git a/internal/vaultik/remote_info_test.go b/internal/vaultik/remote_info_test.go index 2c6c8da..ceb5f11 100644 --- a/internal/vaultik/remote_info_test.go +++ b/internal/vaultik/remote_info_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "testing" "time" @@ -69,6 +70,74 @@ func TestRemoteInfo_UnreadableManifestLeavesOrphansUnknown(t *testing.T) { assert.Equal(t, []any{unreadableKey}, doc["unreadable_manifests"]) } +// TestRemoteInfo_UnreadableManifestLeavesSnapshotBlobsUnknown checks +// that the row of a snapshot whose manifest cannot be read gives its +// blob count and blob size as unknown in the table and as null in +// --json, not as 0. A directory without a manifest still shows 0: the +// orphan figures count its blobs as orphaned, so it references none. +func TestRemoteInfo_UnreadableManifestLeavesSnapshotBlobsUnknown(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + env := newListEnv(t) + readableKey := env.addRemote(t, listRemoteID, + time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) + + unreadableKey := snapshot.RemoteSnapshotKey(listLocalID) + require.NoError(t, env.store.Put(context.Background(), + "metadata/"+unreadableKey+"/manifest.json.zst", + bytes.NewReader([]byte("not a valid manifest")))) + + noManifestKey := snapshot.RemoteSnapshotKey("testhost_home_2026-03-03T10:00:00Z") + require.NoError(t, env.store.Put(context.Background(), + "metadata/"+noManifestKey+"/db.zst.age", + bytes.NewReader([]byte("not a valid database")))) + + require.NoError(t, env.v.RemoteInfo(false)) + + // The table truncates the remote key, so a row is found by a prefix. + wantUnknown := map[string]int{readableKey: 0, unreadableKey: 2, noManifestKey: 0} + for key, want := range wantUnknown { + var row string + + for line := range strings.SplitSeq(env.stdout.String(), "\n") { + if strings.HasPrefix(line, key[:16]) { + row = line + } + } + + require.NotEmpty(t, row, "no table row for %s", key) + assert.Equal(t, want, strings.Count(row, "unknown"), "row: %q", row) + } + + env.stdout.Reset() + require.NoError(t, env.v.RemoteInfo(true)) + + var doc struct { + Snapshots []map[string]any `json:"snapshots"` + } + + require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &doc)) + require.Len(t, doc.Snapshots, len(wantUnknown)) + + for _, entry := range doc.Snapshots { + switch entry["snapshot_id"] { + case readableKey: + assert.InDelta(t, 1, entry["blob_count"], 0) + assert.InDelta(t, fiveMegabytes, entry["blobs_size"], 0) + case noManifestKey: + assert.InDelta(t, 0, entry["blob_count"], 0) + assert.InDelta(t, 0, entry["blobs_size"], 0) + default: + assert.Equal(t, unreadableKey, entry["snapshot_id"]) + assert.Contains(t, entry, "blob_count") + assert.Nil(t, entry["blob_count"]) + assert.Contains(t, entry, "blobs_size") + assert.Nil(t, entry["blobs_size"]) + } + } +} + // TestRemoteInfo_SkipsNonConformingMetadataName checks that a directory // under metadata/ whose name is not a remote key is left out of the // report, and that the orphan figures are unknown when it holds a