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 }