Files

185 lines
5.2 KiB
Go

package backup
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// DumpOptions configures one backup.
type DumpOptions struct {
Client *mongo.Client
Database string
Exclude []string
// KeyHex is KEY_ENCRYPTION_KEY. It is fingerprinted and discarded; it is
// never written to the archive.
KeyHex string
// AllowNoKey permits a backup of a deployment that stores no encrypted
// material. The manifest then records a null fingerprint, which restore
// reports rather than treating as a match.
AllowNoKey bool
VantageVersion string
Out io.Writer
}
// Dump writes a complete archive of one database to opt.Out.
//
// Collections are enumerated live rather than read from a list. A backup tool
// has no equivalent of AssertNoScopedCollectionMissed to catch a hardcoded list
// drifting, and the first symptom of that drift would be a restore silently
// missing a collection added since the list was written.
func Dump(ctx context.Context, opt DumpOptions) (Manifest, error) {
fingerprint, err := dumpFingerprint(opt)
if err != nil {
return Manifest{}, err
}
db := opt.Client.Database(opt.Database)
names, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
return Manifest{}, fmt.Errorf("list collections: %w", err)
}
sort.Strings(names)
excluded := map[string]bool{}
for _, e := range opt.Exclude {
excluded[e] = true
}
serverVersion, err := mongoServerVersion(ctx, opt.Client)
if err != nil {
return Manifest{}, err
}
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
w := NewWriter(opt.Out)
entries := make([]CollectionEntry, 0, len(names))
for _, name := range names {
if excluded[name] {
continue
}
entry, err := dumpCollection(ctx, w, db, name)
if err != nil {
return Manifest{}, err
}
entries = append(entries, entry)
}
m := Manifest{
FormatVersion: FormatVersion,
CreatedAt: time.Now().UTC(),
VantageVersion: opt.VantageVersion,
Hostname: hostname,
MongoDB: opt.Database,
MongoServerVersion: serverVersion,
KeyFingerprint: fingerprint,
Collections: entries,
Excluded: append([]string{}, opt.Exclude...),
}
if err := w.Close(m); err != nil {
return Manifest{}, err
}
return m, nil
}
// dumpFingerprint applies the key policy before any output is produced. An
// archive of ciphertext whose key was never recorded is worse than no archive,
// because it looks like a backup.
func dumpFingerprint(opt DumpOptions) (*string, error) {
if opt.KeyHex == "" {
if opt.AllowNoKey {
return nil, nil
}
return nil, fmt.Errorf("%w: pass --allow-no-key only if this deployment stores no encrypted data", ErrNoKey)
}
fp, err := FingerprintHex(opt.KeyHex)
if err != nil {
return nil, err
}
return &fp, nil
}
func dumpCollection(ctx context.Context, w *Writer, db *mongo.Database, name string) (CollectionEntry, error) {
cur, err := db.Collection(name).Find(ctx, bson.M{})
if err != nil {
return CollectionEntry{}, fmt.Errorf("find %s: %w", name, err)
}
defer cur.Close(ctx)
var docs [][]byte
for cur.Next(ctx) {
// cur.Current is only valid until the next Next, and it is written to
// the archive verbatim rather than through a map, so every BSON type
// survives exactly as the server stored it.
docs = append(docs, append([]byte(nil), cur.Current...))
}
if err := cur.Err(); err != nil {
return CollectionEntry{}, fmt.Errorf("iterate %s: %w", name, err)
}
entry, err := w.WriteCollection(name, docs)
if err != nil {
return CollectionEntry{}, err
}
if err := dumpIndexes(ctx, w, db, name); err != nil {
return CollectionEntry{}, err
}
return entry, nil
}
func dumpIndexes(ctx context.Context, w *Writer, db *mongo.Database, name string) error {
cur, err := db.Collection(name).Indexes().List(ctx)
if err != nil {
return fmt.Errorf("list indexes on %s: %w", name, err)
}
defer cur.Close(ctx)
// The specs are read as raw BSON and re-encoded as extended JSON, one
// element per index, so key order and every option the server reported -
// partialFilterExpression, collation, weights and the rest - survive
// verbatim. Decoding into bson.M would lose compound key order, and
// reconstructing an index from a hand-picked set of options would drop
// whatever was not picked.
var specs []bson.Raw
if err := cur.All(ctx, &specs); err != nil {
return fmt.Errorf("read indexes on %s: %w", name, err)
}
encoded := make([]json.RawMessage, 0, len(specs))
for _, spec := range specs {
ej, err := bson.MarshalExtJSON(spec, false, false)
if err != nil {
return fmt.Errorf("encode indexes on %s: %w", name, err)
}
encoded = append(encoded, ej)
}
raw, err := json.Marshal(encoded)
if err != nil {
return fmt.Errorf("encode indexes on %s: %w", name, err)
}
return w.WriteIndexes(name, raw)
}
func mongoServerVersion(ctx context.Context, client *mongo.Client) (string, error) {
var res struct {
Version string `bson:"version"`
}
err := client.Database("admin").RunCommand(ctx, bson.D{{Key: "buildInfo", Value: 1}}).Decode(&res)
if err != nil {
return "", fmt.Errorf("buildInfo: %w", err)
}
return res.Version, nil
}