feat: Add the backup archive manifest
KeyFingerprint is a pointer so an archive that recorded no key is a state restore can report, not a default it silently treats as a match.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatVersion is the archive format this build reads and writes. Restore
|
||||
// refuses anything else rather than guessing at a layout it does not know.
|
||||
const FormatVersion = 1
|
||||
|
||||
// ManifestName is the archive member holding the manifest.
|
||||
const ManifestName = "manifest.json"
|
||||
|
||||
// ErrUnknownFormat is returned for an archive this build cannot read.
|
||||
var ErrUnknownFormat = errors.New("unsupported archive format version")
|
||||
|
||||
// CollectionEntry describes one collection in the archive. Bytes and SHA256
|
||||
// cover the uncompressed .bson member, which is what restore verifies before
|
||||
// writing anything.
|
||||
type CollectionEntry struct {
|
||||
Name string `json:"name"`
|
||||
Documents int64 `json:"documents"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// Manifest is the archive's index and its provenance.
|
||||
//
|
||||
// KeyFingerprint is a pointer so "this archive recorded no key" is a distinct
|
||||
// state from "this archive recorded the empty string". A null here is a real
|
||||
// condition an operator must be told about, not a default.
|
||||
type Manifest struct {
|
||||
FormatVersion int `json:"format_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
VantageVersion string `json:"vantage_version"`
|
||||
Hostname string `json:"hostname"`
|
||||
MongoDB string `json:"mongo_db"`
|
||||
MongoServerVersion string `json:"mongo_server_version"`
|
||||
KeyFingerprint *string `json:"key_fingerprint"`
|
||||
Collections []CollectionEntry `json:"collections"`
|
||||
Excluded []string `json:"excluded"`
|
||||
}
|
||||
|
||||
// Check validates what can be validated without reading the rest of the archive.
|
||||
func (m Manifest) Check() error {
|
||||
if m.FormatVersion != FormatVersion {
|
||||
return fmt.Errorf("%w: archive is version %d, this build reads version %d",
|
||||
ErrUnknownFormat, m.FormatVersion, FormatVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collection looks up one entry by name.
|
||||
func (m Manifest) Collection(name string) (CollectionEntry, bool) {
|
||||
for _, c := range m.Collections {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return CollectionEntry{}, false
|
||||
}
|
||||
|
||||
// CiphertextCollections names the collections holding AES-GCM ciphertext.
|
||||
//
|
||||
// It exists to be printed. When a restore proceeds under a key that does not
|
||||
// match the archive, this is the list of what will be unreadable afterwards,
|
||||
// and an operator deserves to see it before the write rather than discover it
|
||||
// a week later.
|
||||
func CiphertextCollections() []string {
|
||||
return []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManifestJSONShape(t *testing.T) {
|
||||
fp := "abc"
|
||||
m := Manifest{
|
||||
FormatVersion: FormatVersion,
|
||||
CreatedAt: time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC),
|
||||
VantageVersion: "dev",
|
||||
Hostname: "box",
|
||||
MongoDB: "vantage",
|
||||
MongoServerVersion: "7.0.5",
|
||||
KeyFingerprint: &fp,
|
||||
Collections: []CollectionEntry{{Name: "servers", Documents: 3, Bytes: 120, SHA256: "dead"}},
|
||||
Excluded: []string{"audit_logs"},
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"format_version":1`, `"created_at":"2026-09-07T12:00:00Z"`,
|
||||
`"key_fingerprint":"abc"`, `"mongo_server_version":"7.0.5"`,
|
||||
`"excluded":["audit_logs"]`,
|
||||
} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("manifest JSON missing %s\ngot: %s", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestNullFingerprint(t *testing.T) {
|
||||
raw, err := json.Marshal(Manifest{FormatVersion: FormatVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"key_fingerprint":null`) {
|
||||
t.Fatalf("absent key must marshal as null, got: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestCheckRejectsOtherVersions(t *testing.T) {
|
||||
if err := (Manifest{FormatVersion: FormatVersion}).Check(); err != nil {
|
||||
t.Fatalf("current version rejected: %v", err)
|
||||
}
|
||||
for _, v := range []int{0, 2, 99} {
|
||||
if err := (Manifest{FormatVersion: v}).Check(); !errors.Is(err, ErrUnknownFormat) {
|
||||
t.Fatalf("version %d: got %v, want ErrUnknownFormat", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestCollectionLookup(t *testing.T) {
|
||||
m := Manifest{Collections: []CollectionEntry{{Name: "keys", Documents: 1}}}
|
||||
if _, ok := m.Collection("keys"); !ok {
|
||||
t.Fatal("known collection not found")
|
||||
}
|
||||
if _, ok := m.Collection("nope"); ok {
|
||||
t.Fatal("unknown collection reported as found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCiphertextCollections(t *testing.T) {
|
||||
got := CiphertextCollections()
|
||||
want := []string{"keys", "secrets", "auth_providers", "console_sessions", "settings"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user