16 "github.com/mjl-/bstore"
18 "github.com/mjl-/mox/dmarcdb"
19 "github.com/mjl-/mox/mox-"
20 "github.com/mjl-/mox/moxvar"
21 "github.com/mjl-/mox/mtastsdb"
22 "github.com/mjl-/mox/queue"
23 "github.com/mjl-/mox/store"
24 "github.com/mjl-/mox/tlsrptdb"
27func backupctl(ctx context.Context, ctl *ctl) {
36 // Convention in this function: variables containing "src" or "dst" are file system
37 // paths that can be passed to os.Open and such. Variables with dirs/paths without
38 // "src" or "dst" are incomplete paths relative to the source or destination data
41 dstDataDir := ctl.xread()
42 verbose := ctl.xread() == "verbose"
44 // Set when an error is encountered. At the end, we warn if set.
47 // We'll be writing output, and logging both to mox and the ctl stream.
48 writer := ctl.writer()
50 // Format easily readable output for the user.
51 formatLog := func(prefix, text string, err error, attrs ...slog.Attr) []byte {
53 fmt.Fprint(&b, prefix)
56 fmt.Fprint(&b, ": "+err.Error())
58 for _, a := range attrs {
59 fmt.Fprintf(&b, "; %s=%v", a.Key, a.Value)
65 // Log an error to both the mox service as the user running "mox backup".
66 pkglogx := func(prefix, text string, err error, attrs ...slog.Attr) {
67 ctl.log.Errorx(text, err, attrs...)
69 _, werr := writer.Write(formatLog(prefix, text, err, attrs...))
70 ctl.xcheck(werr, "write to ctl")
73 // Log an error but don't mark backup as failed.
74 xwarnx := func(text string, err error, attrs ...slog.Attr) {
75 pkglogx("warning: ", text, err, attrs...)
78 // Log an error that causes the backup to be marked as failed. We typically
79 // continue processing though.
80 xerrx := func(text string, err error, attrs ...slog.Attr) {
82 pkglogx("error: ", text, err, attrs...)
85 // If verbose is enabled, log to the cli command. Always log as info level.
86 xvlog := func(text string, attrs ...slog.Attr) {
87 ctl.log.Info(text, attrs...)
89 _, werr := writer.Write(formatLog("", text, nil, attrs...))
90 ctl.xcheck(werr, "write to ctl")
94 if _, err := os.Stat(dstDataDir); err == nil {
95 xwarnx("destination data directory already exists", nil, slog.String("dir", dstDataDir))
98 srcDataDir := filepath.Clean(mox.DataDirPath("."))
100 // When creating a file in the destination, we first ensure its directory exists.
101 // We track which directories we created, to prevent needless syscalls.
102 createdDirs := map[string]struct{}{}
103 ensureDestDir := func(dstpath string) {
104 dstdir := filepath.Dir(dstpath)
105 if _, ok := createdDirs[dstdir]; !ok {
106 err := os.MkdirAll(dstdir, 0770)
108 xerrx("creating directory", err)
110 createdDirs[dstdir] = struct{}{}
114 // Backup a single file by copying (never hardlinking, the file may change).
115 backupFile := func(path string) {
117 srcpath := filepath.Join(srcDataDir, path)
118 dstpath := filepath.Join(dstDataDir, path)
120 sf, err := os.Open(srcpath)
122 xerrx("open source file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
127 ensureDestDir(dstpath)
128 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
130 xerrx("creating destination file (not backed up)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
138 if _, err := io.Copy(df, sf); err != nil {
139 xerrx("copying file (not backed up properly)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
145 xerrx("closing destination file (not backed up properly)", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
148 xvlog("backed up file", slog.String("path", path), slog.Duration("duration", time.Since(tmFile)))
151 // Back up the files in a directory (by copying).
152 backupDir := func(dir string) {
154 srcdir := filepath.Join(srcDataDir, dir)
155 dstdir := filepath.Join(dstDataDir, dir)
156 err := filepath.WalkDir(srcdir, func(srcpath string, d fs.DirEntry, err error) error {
158 xerrx("walking file (not backed up)", err, slog.String("srcpath", srcpath))
164 backupFile(srcpath[len(srcDataDir)+1:])
168 xerrx("copying directory (not backed up properly)", err,
169 slog.String("srcdir", srcdir),
170 slog.String("dstdir", dstdir),
171 slog.Duration("duration", time.Since(tmDir)))
174 xvlog("backed up directory", slog.String("dir", dir), slog.Duration("duration", time.Since(tmDir)))
177 // Backup a database by copying it in a readonly transaction.
178 // Always logs on error, so caller doesn't have to, but also returns the error so
179 // callers can see result.
180 backupDB := func(db *bstore.DB, path string) (rerr error) {
183 xerrx("backing up database", rerr, slog.String("path", path))
189 dstpath := filepath.Join(dstDataDir, path)
190 ensureDestDir(dstpath)
191 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
193 return fmt.Errorf("creating destination file: %v", err)
200 err = db.Read(ctx, func(tx *bstore.Tx) error {
201 // Using regular WriteTo seems fine, and fast. It just copies pages.
203 // bolt.Compact is slower, it writes all key/value pairs, building up new data
204 // structures. My compacted test database was ~60% of original size. Lz4 on the
205 // uncompacted database got it to 14%. Lz4 on the compacted database got it to 13%.
206 // Backups are likely archived somewhere with compression, so we don't compact.
208 // Tests with WriteTo and os.O_DIRECT were slower than without O_DIRECT, but
209 // probably because everything fit in the page cache. It may be better to use
210 // O_DIRECT when copying many large or inactive databases.
211 _, err := tx.WriteTo(df)
215 return fmt.Errorf("copying database: %v", err)
220 return fmt.Errorf("closing destination database after copy: %v", err)
222 xvlog("backed up database file", slog.String("path", path), slog.Duration("duration", time.Since(tmDB)))
226 // Try to create a hardlink. Fall back to copying the file (e.g. when on different file system).
227 warnedHardlink := false // We warn once about failing to hardlink.
228 linkOrCopy := func(srcpath, dstpath string) (bool, error) {
229 ensureDestDir(dstpath)
231 if err := os.Link(srcpath, dstpath); err == nil {
233 } else if os.IsNotExist(err) {
234 // No point in trying with regular copy, we would warn twice.
236 } else if !warnedHardlink {
237 xwarnx("creating hardlink to message failed, will be doing regular file copies and not warn again", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
238 warnedHardlink = true
241 // Fall back to copying.
242 sf, err := os.Open(srcpath)
244 return false, fmt.Errorf("open source path %s: %v", srcpath, err)
248 ctl.log.Check(err, "closing copied source file")
251 df, err := os.OpenFile(dstpath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
253 return false, fmt.Errorf("create destination path %s: %v", dstpath, err)
258 ctl.log.Check(err, "closing partial destination file")
261 if _, err := io.Copy(df, sf); err != nil {
262 return false, fmt.Errorf("coping: %v", err)
267 return false, fmt.Errorf("closing destination file: %v", err)
272 // Start making the backup.
273 tmStart := time.Now()
275 ctl.log.Print("making backup", slog.String("destdir", dstDataDir))
277 err := os.MkdirAll(dstDataDir, 0770)
279 xerrx("creating destination data directory", err)
282 if err := os.WriteFile(filepath.Join(dstDataDir, "moxversion"), []byte(moxvar.Version), 0660); err != nil {
283 xerrx("writing moxversion", err)
285 backupDB(dmarcdb.ReportsDB, "dmarcrpt.db")
286 backupDB(dmarcdb.EvalDB, "dmarceval.db")
287 backupDB(mtastsdb.DB, "mtasts.db")
288 backupDB(tlsrptdb.ReportDB, "tlsrpt.db")
289 backupDB(tlsrptdb.ResultDB, "tlsrptresult.db")
290 backupFile("receivedid.key")
292 // Acme directory is optional.
293 srcAcmeDir := filepath.Join(srcDataDir, "acme")
294 if _, err := os.Stat(srcAcmeDir); err == nil {
296 } else if err != nil && !os.IsNotExist(err) {
297 xerrx("copying acme/", err)
300 // Copy the queue database and all message files.
301 backupQueue := func(path string) {
302 tmQueue := time.Now()
304 if err := backupDB(queue.DB, path); err != nil {
305 xerrx("queue not backed up", err, slog.String("path", path), slog.Duration("duration", time.Since(tmQueue)))
309 dstdbpath := filepath.Join(dstDataDir, path)
310 db, err := bstore.Open(ctx, dstdbpath, &bstore.Options{MustExist: true}, queue.DBTypes...)
312 xerrx("open copied queue database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmQueue)))
319 ctl.log.Check(err, "closing new queue db")
323 // Link/copy known message files. Warn if files are missing or unexpected
324 // (though a message file could have been removed just now due to delivery, or a
325 // new message may have been queued).
327 seen := map[string]struct{}{}
328 var nlinked, ncopied int
329 err = bstore.QueryDB[queue.Msg](ctx, db).ForEach(func(m queue.Msg) error {
330 mp := store.MessagePath(m.ID)
331 seen[mp] = struct{}{}
332 srcpath := filepath.Join(srcDataDir, "queue", mp)
333 dstpath := filepath.Join(dstDataDir, "queue", mp)
334 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
335 xerrx("linking/copying queue message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
344 xerrx("processing queue messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
346 xvlog("queue message files linked/copied",
347 slog.Int("linked", nlinked),
348 slog.Int("copied", ncopied),
349 slog.Duration("duration", time.Since(tmMsgs)))
352 // Read through all files in queue directory and warn about anything we haven't handled yet.
354 srcqdir := filepath.Join(srcDataDir, "queue")
355 err = filepath.WalkDir(srcqdir, func(srcqpath string, d fs.DirEntry, err error) error {
357 xerrx("walking files in queue", err, slog.String("srcpath", srcqpath))
363 p := srcqpath[len(srcqdir)+1:]
364 if _, ok := seen[p]; ok {
370 qp := filepath.Join("queue", p)
371 xwarnx("backing up unrecognized file in queue directory", nil, slog.String("path", qp))
376 xerrx("walking queue directory (not backed up properly)", err, slog.String("dir", "queue"), slog.Duration("duration", time.Since(tmWalk)))
378 xvlog("walked queue directory", slog.Duration("duration", time.Since(tmWalk)))
381 xvlog("queue backed finished", slog.Duration("duration", time.Since(tmQueue)))
383 backupQueue(filepath.FromSlash("queue/index.db"))
385 backupAccount := func(acc *store.Account) {
388 tmAccount := time.Now()
390 // Copy database file.
391 dbpath := filepath.Join("accounts", acc.Name, "index.db")
392 err := backupDB(acc.DB, dbpath)
394 xerrx("copying account database", err, slog.String("path", dbpath), slog.Duration("duration", time.Since(tmAccount)))
397 // todo: should document/check not taking a rlock on account.
399 // Copy junkfilter files, if configured.
400 if jf, _, err := acc.OpenJunkFilter(ctx, ctl.log); err != nil {
401 if !errors.Is(err, store.ErrNoJunkFilter) {
402 xerrx("opening junk filter for account (not backed up)", err)
406 jfpath := filepath.Join("accounts", acc.Name, "junkfilter.db")
408 bloompath := filepath.Join("accounts", acc.Name, "junkfilter.bloom")
409 backupFile(bloompath)
412 ctl.log.Check(err, "closing junkfilter")
415 dstdbpath := filepath.Join(dstDataDir, dbpath)
416 db, err := bstore.Open(ctx, dstdbpath, &bstore.Options{MustExist: true}, store.DBTypes...)
418 xerrx("open copied account database", err, slog.String("dstpath", dstdbpath), slog.Duration("duration", time.Since(tmAccount)))
425 ctl.log.Check(err, "close account database")
429 // Link/copy known message files. Warn if files are missing or unexpected (though a
430 // message file could have been added just now due to delivery, or a message have
433 seen := map[string]struct{}{}
434 var nlinked, ncopied int
435 err = bstore.QueryDB[store.Message](ctx, db).FilterEqual("Expunged", false).ForEach(func(m store.Message) error {
436 mp := store.MessagePath(m.ID)
437 seen[mp] = struct{}{}
438 amp := filepath.Join("accounts", acc.Name, "msg", mp)
439 srcpath := filepath.Join(srcDataDir, amp)
440 dstpath := filepath.Join(dstDataDir, amp)
441 if linked, err := linkOrCopy(srcpath, dstpath); err != nil {
442 xerrx("linking/copying account message", err, slog.String("srcpath", srcpath), slog.String("dstpath", dstpath))
451 xerrx("processing account messages (not backed up properly)", err, slog.Duration("duration", time.Since(tmMsgs)))
453 xvlog("account message files linked/copied",
454 slog.Int("linked", nlinked),
455 slog.Int("copied", ncopied),
456 slog.Duration("duration", time.Since(tmMsgs)))
459 // Read through all files in account directory and warn about anything we haven't handled yet.
461 srcadir := filepath.Join(srcDataDir, "accounts", acc.Name)
462 err = filepath.WalkDir(srcadir, func(srcapath string, d fs.DirEntry, err error) error {
464 xerrx("walking files in account", err, slog.String("srcpath", srcapath))
470 p := srcapath[len(srcadir)+1:]
471 l := strings.Split(p, string(filepath.Separator))
473 mp := filepath.Join(l[1:]...)
474 if _, ok := seen[mp]; ok {
479 case "index.db", "junkfilter.db", "junkfilter.bloom":
482 ap := filepath.Join("accounts", acc.Name, p)
483 if strings.HasPrefix(p, "msg"+string(filepath.Separator)) {
484 xwarnx("backing up unrecognized file in account message directory (should be moved away)", nil, slog.String("path", ap))
486 xwarnx("backing up unrecognized file in account directory", nil, slog.String("path", ap))
492 xerrx("walking account directory (not backed up properly)", err, slog.String("srcdir", srcadir), slog.Duration("duration", time.Since(tmWalk)))
494 xvlog("walked account directory", slog.Duration("duration", time.Since(tmWalk)))
497 xvlog("account backup finished", slog.String("dir", filepath.Join("accounts", acc.Name)), slog.Duration("duration", time.Since(tmAccount)))
500 // For each configured account, open it, make a copy of the database and
501 // hardlink/copy the messages. We track the accounts we handled, and skip the
502 // account directories when handling "all other files" below.
503 accounts := map[string]struct{}{}
504 for _, accName := range mox.Conf.Accounts() {
505 acc, err := store.OpenAccount(ctl.log, accName)
507 xerrx("opening account for copying (will try to copy as regular files later)", err, slog.String("account", accName))
510 accounts[accName] = struct{}{}
514 // Copy all other files, that aren't part of the known files, databases, queue or accounts.
516 err = filepath.WalkDir(srcDataDir, func(srcpath string, d fs.DirEntry, err error) error {
518 xerrx("walking path", err, slog.String("path", srcpath))
522 if srcpath == srcDataDir {
525 p := srcpath[len(srcDataDir)+1:]
526 if p == "queue" || p == "acme" || p == "tmp" {
529 l := strings.Split(p, string(filepath.Separator))
530 if len(l) >= 2 && l[0] == "accounts" {
532 if _, ok := accounts[name]; ok {
537 // Only files are explicitly backed up.
543 case "dmarcrpt.db", "dmarceval.db", "mtasts.db", "tlsrpt.db", "tlsrptresult.db", "receivedid.key", "ctl":
546 case "lastknownversion": // Optional file, not yet handled.
548 xwarnx("backing up unrecognized file", nil, slog.String("path", p))
554 xerrx("walking other files (not backed up properly)", err, slog.Duration("duration", time.Since(tmWalk)))
556 xvlog("walking other files finished", slog.Duration("duration", time.Since(tmWalk)))
559 xvlog("backup finished", slog.Duration("duration", time.Since(tmStart)))
564 ctl.xwrite("errors were encountered during backup")