/
db.go
312 lines (269 loc) · 8.65 KB
/
db.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
// Copyright 2014 Canonical Ltd.
// Licensed under the AGPLv3, see LICENCE file for details.
package backups
import (
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/dustin/go-humanize"
"github.com/juju/collections/set"
"github.com/juju/errors"
"github.com/juju/mgo/v3"
"github.com/juju/mgo/v3/bson"
"github.com/juju/juju/mongo"
)
// db is a surrogate for the proverbial DB layer abstraction that we
// wish we had for juju state. To that end, the package holds the DB
// implementation-specific details and functionality needed for backups.
// Currently that means mongo-specific details. However, as a stand-in
// for a future DB layer abstraction, the db package does not expose any
// low-level details publicly. Thus the backups implementation remains
// oblivious to the underlying DB implementation.
var runCommandFn = runCommand
// DBInfo wraps all the DB-specific information backups needs to dump
// the database. This includes a simplification of the information in
// authentication.MongoInfo.
type DBInfo struct {
// Address is the DB system's host address.
Address string
// Username is used when connecting to the DB system.
Username string
// Password is used when connecting to the DB system.
Password string
// Targets is a list of databases to dump.
Targets set.Strings
// ApproxSizeMB is the storage needed to back up the database.
ApproxSizeMB int
}
// ignoredDatabases is the list of databases that should not be
// backed up, admin might be removed later, after determining
// mongo version.
var ignoredDatabases = set.NewStrings(
"admin",
"backups",
"presence", // note: this is still backed up anyway
)
// DBSession is a subset of mgo.Session.
type DBSession interface {
DatabaseNames() ([]string, error)
DB(name string) Database
}
// Database is a subset of mgo.Database.
type Database interface {
Run(cmd interface{}, result interface{}) error
}
// NewDBInfo returns the information needed by backups to dump
// the database.
func NewDBInfo(mgoInfo *mongo.MongoInfo, session DBSession) (*DBInfo, error) {
targets, err := getBackupTargetDatabases(session)
if err != nil {
return nil, errors.Trace(err)
}
var totalDataSize float64
for _, target := range targets.Values() {
var result bson.M
err := session.DB(target).Run(bson.D{
{"dbStats", 1},
{"scale", humanize.MiByte},
}, &result)
if err != nil {
return nil, errors.Trace(err)
}
dataSize, ok := result["dataSize"].(float64)
if !ok {
return nil, errors.Errorf("missing 'dataSize' value in db stats for database %q", target)
}
logger.Debugf("dataSize for %q is %dMiB", target, dataSize)
totalDataSize += dataSize
}
info := DBInfo{
Address: mgoInfo.Addrs[0],
Password: mgoInfo.Password,
Targets: targets,
ApproxSizeMB: int(totalDataSize),
}
// TODO(dfc) Backup should take a Tag.
if mgoInfo.Tag != nil {
info.Username = mgoInfo.Tag.String()
}
return &info, nil
}
func getBackupTargetDatabases(session DBSession) (set.Strings, error) {
dbNames, err := session.DatabaseNames()
if err != nil {
return nil, errors.Annotate(err, "unable to get DB names")
}
targets := set.NewStrings(dbNames...).Difference(ignoredDatabases)
return targets, nil
}
const (
dumpName = "mongodump"
snapToolPrefix = "juju-db."
snapTmpDir = "/tmp/snap-private-tmp/snap.juju-db"
)
// DBDumper is any type that dumps something to a dump dir.
type DBDumper interface {
// Dump something to dumpDir.
Dump(dumpDir string) error
// IsSnap returns true if we are using the juju-db snap.
IsSnap() bool
}
var getMongodumpPath = func() (string, error) {
return getMongoToolPath(dumpName, os.Stat, exec.LookPath)
}
var getMongodPath = func() (string, error) {
finder := mongo.NewMongodFinder()
path, err := finder.InstalledAt()
return path, err
}
func getMongoToolPath(toolName string, stat func(name string) (os.FileInfo, error), lookPath func(file string) (string, error)) (string, error) {
mongod, err := getMongodPath()
if err != nil {
return "", errors.Annotate(err, "failed to get mongod path")
}
mongodDir := filepath.Dir(mongod)
// Try "juju-db.tool" (how it's named in the Snap).
mongoTool := filepath.Join(mongodDir, snapToolPrefix+toolName)
if _, err := stat(mongoTool); err == nil {
return mongoTool, nil
}
logger.Tracef("didn't find MongoDB tool %q in %q", snapToolPrefix+toolName, mongodDir)
path, err := lookPath(toolName)
if err != nil {
return "", errors.Trace(err)
}
return path, nil
}
type mongoDumper struct {
*DBInfo
// binPath is the path to the dump executable.
binPath string
}
// NewDBDumper returns a new value with a Dump method for dumping the
// juju state database.
func NewDBDumper(info *DBInfo) (DBDumper, error) {
mongodumpPath, err := getMongodumpPath()
if err != nil {
return nil, errors.Annotate(err, "mongodump not available")
}
dumper := mongoDumper{
DBInfo: info,
binPath: mongodumpPath,
}
return &dumper, nil
}
func (md *mongoDumper) options(dumpDir string) []string {
options := []string{
"--ssl",
"--tlsInsecure",
"--authenticationDatabase", "admin",
"--host", md.Address,
"--username", md.Username,
"--password", md.Password,
"--out", dumpDir,
"--oplog",
}
return options
}
func (md *mongoDumper) dump(dumpDir string) error {
// Works around https://bugs.launchpad.net/snapd/+bug/1999109
// If running the juju-db.mongodump snap and staging to /tmp,
// it outputs to /tmp/snap-private-tmp/snap.juju-db/<dumpDir>
dumpDirArg := dumpDir
if md.IsSnap() && strings.HasPrefix(dumpDirArg, snapTmpDir) {
dumpDirArg = strings.TrimPrefix(dumpDirArg, snapTmpDir)
}
options := md.options(dumpDirArg)
if err := runCommandFn(md.binPath, options...); err != nil {
return errors.Annotate(err, "error dumping databases")
}
return nil
}
// IsSnap returns true if we are using the juju-db snap.
func (md *mongoDumper) IsSnap() bool {
return filepath.Base(md.binPath) == snapToolPrefix+dumpName
}
// Dump dumps the juju state-related databases. To do this we dump all
// databases and then remove any ignored databases from the dump results.
func (md *mongoDumper) Dump(baseDumpDir string) error {
logger.Tracef("dumping Mongo database to %q", baseDumpDir)
if err := md.dump(baseDumpDir); err != nil {
return errors.Trace(err)
}
found, err := listDatabases(baseDumpDir)
if err != nil {
return errors.Trace(err)
}
// Strip the ignored database from the dump dir.
ignored := found.Difference(md.Targets)
err = stripIgnored(ignored, baseDumpDir)
return errors.Trace(err)
}
// stripIgnored removes the ignored DBs from the mongo dump files.
// This involves deleting DB-specific directories.
//
// NOTE(fwereade): the only directories we actually delete are "admin"
// and "backups"; and those only if they're in the `ignored` set. I have
// no idea why the code was structured this way; but I am, as requested
// as usual by management, *not* fixing anything about backup beyond the
// bug du jour.
//
// Basically, the ignored set is a filthy lie, and all the work we do to
// generate it is pure obfuscation.
func stripIgnored(ignored set.Strings, dumpDir string) error {
for _, dbName := range ignored.Values() {
switch dbName {
case "backups", "admin":
dirname := filepath.Join(dumpDir, dbName)
logger.Tracef("stripIgnored deleting dir %q", dirname)
if err := os.RemoveAll(dirname); err != nil {
return errors.Trace(err)
}
}
}
return nil
}
// listDatabases returns the name of each sub-directory of the dump
// directory. Each corresponds to a database dump generated by
// mongodump. Note that, while mongodump is unlikely to change behavior
// in this regard, this is not a documented guaranteed behavior.
func listDatabases(dumpDir string) (set.Strings, error) {
dirEntries, err := os.ReadDir(dumpDir)
if err != nil {
return nil, errors.Trace(err)
}
logger.Tracef("%d files found in dump dir", len(dirEntries))
for _, entry := range dirEntries {
fi, err := entry.Info()
if err != nil {
logger.Errorf("failed to read file info: %s", entry.Name())
continue
}
logger.Tracef("file found in dump dir: %q dir=%v size=%d",
fi.Name(), fi.IsDir(), fi.Size())
}
if len(dirEntries) < 2 {
// Should be *at least* oplog.bson and a data directory
return nil, errors.Errorf("too few files in dump dir %s (%d)", dumpDir, len(dirEntries))
}
databases := make(set.Strings)
for _, entry := range dirEntries {
if !entry.IsDir() {
// Notably, oplog.bson is thus excluded here.
continue
}
databases.Add(entry.Name())
}
return databases, nil
}
// MongoDB represents a mgo.DB.
type MongoDB interface {
UpsertUser(*mgo.User) error
}
// MongoSession represents mgo.Session.
type MongoSession interface {
Run(cmd interface{}, result interface{}) error
Close()
DB(string) *mgo.Database
}