157 lines
4.4 KiB
Go
157 lines
4.4 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"gitea.hostxtra.co.uk/vantage/vantage-shared/backup"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func newBackupCmd() *cobra.Command {
|
|
var (
|
|
out string
|
|
exclude []string
|
|
allowNoKey bool
|
|
)
|
|
|
|
c := &cobra.Command{
|
|
Use: "backup",
|
|
Short: "Write an archive of the database",
|
|
Long: "backup writes every collection in the database to a gzipped tar\n" +
|
|
"archive, along with a fingerprint of KEY_ENCRYPTION_KEY.\n\n" +
|
|
"The key itself is never written. The fingerprint is what lets a later\n" +
|
|
"restore refuse rather than produce a database whose secrets nobody\n" +
|
|
"can read.\n\n" +
|
|
"Pass --out - to stream to stdout, which is how this composes with\n" +
|
|
"restic, age, or aws s3 cp -.",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
ctx := c.Context()
|
|
g, err := resolveGlobals(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := connect(ctx, g)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Disconnect(context.Background())
|
|
|
|
w, dest, name, err := backupDestination(out, g.Database)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
dest.Cleanup()
|
|
}
|
|
}()
|
|
|
|
m, err := backup.Dump(ctx, backup.DumpOptions{
|
|
Client: client,
|
|
Database: g.Database,
|
|
Exclude: exclude,
|
|
KeyHex: g.KeyHex,
|
|
AllowNoKey: allowNoKey,
|
|
VantageVersion: c.Root().Version,
|
|
Out: w,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := dest.Commit(); err != nil {
|
|
return err
|
|
}
|
|
committed = true
|
|
|
|
// Progress goes to stderr so --out - stays a clean pipe.
|
|
var docs int64
|
|
for _, coll := range m.Collections {
|
|
docs += coll.Documents
|
|
}
|
|
fmt.Fprintf(c.ErrOrStderr(), "wrote %s: %d collections, %d documents\n",
|
|
name, len(m.Collections), docs)
|
|
if m.KeyFingerprint == nil {
|
|
fmt.Fprintln(c.ErrOrStderr(),
|
|
"warning: no key recorded; nothing in this archive proves its "+
|
|
"ciphertext can ever be read")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
c.Flags().StringVar(&out, "out", ".", "directory to write the archive into, or - for stdout")
|
|
c.Flags().StringSliceVar(&exclude, "exclude", nil,
|
|
"collections to leave out, comma separated (recorded in the manifest)")
|
|
c.Flags().BoolVar(&allowNoKey, "allow-no-key", false,
|
|
"back up without KEY_ENCRYPTION_KEY set; only for a deployment storing no encrypted data")
|
|
return c
|
|
}
|
|
|
|
// backupDestination resolves --out to a writer, a closer and a name to print.
|
|
func backupDestination(out, database string) (io.Writer, destination, string, error) {
|
|
if out == "-" {
|
|
return os.Stdout, stdoutDestination{}, "stdout", nil
|
|
}
|
|
name := archiveName(database, time.Now().UTC())
|
|
path := filepath.Join(out, name)
|
|
// Written under a temporary name and renamed on success, the same
|
|
// discipline the agent uses for authorized_keys: a failed backup must not
|
|
// leave a partial file named exactly like a good archive.
|
|
tmp := path + ".partial"
|
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return nil, nil, "", fmt.Errorf("create %s: %w", tmp, err)
|
|
}
|
|
d := &fileDestination{f: f, tmp: tmp, final: path}
|
|
return f, d, path, nil
|
|
}
|
|
|
|
// fileDestination finishes a file-backed backup. Commit renames the temporary
|
|
// file into place; Cleanup removes it if Commit was never called.
|
|
type fileDestination struct {
|
|
f *os.File
|
|
tmp string
|
|
final string
|
|
}
|
|
|
|
func (d *fileDestination) Commit() error {
|
|
if err := d.f.Close(); err != nil {
|
|
return fmt.Errorf("close %s: %w", d.tmp, err)
|
|
}
|
|
if err := os.Rename(d.tmp, d.final); err != nil {
|
|
return fmt.Errorf("rename %s: %w", d.tmp, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *fileDestination) Cleanup() {
|
|
d.f.Close()
|
|
os.Remove(d.tmp)
|
|
}
|
|
|
|
// destination is how the two --out modes finish. stdout commits by doing
|
|
// nothing; there is no partial file to clean up either.
|
|
type destination interface {
|
|
Commit() error
|
|
Cleanup()
|
|
}
|
|
|
|
type stdoutDestination struct{}
|
|
|
|
func (stdoutDestination) Commit() error { return nil }
|
|
func (stdoutDestination) Cleanup() {}
|
|
|
|
// archiveName is sortable and carries no colon, because an operator will copy
|
|
// these onto a Windows share sooner or later and a colon is not a legal
|
|
// filename character there.
|
|
func archiveName(database string, at time.Time) string {
|
|
return fmt.Sprintf("vantage-backup-%s-%s.tar.gz", database, at.Format("20060102T150405Z"))
|
|
}
|