185 lines
5.2 KiB
Go
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
|
|
}
|