Skip to content

Commit b3ddf4f

Browse files
authored
Merge pull request #221 from timvaillancourt/1.2.0-backup-retention
1.2.0: Support backup rotation (#146)
2 parents 341904f + 5932233 commit b3ddf4f

File tree

6 files changed

+135
-41
lines changed

6 files changed

+135
-41
lines changed

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Features
3535
- `Nagios NSCA <https://sourceforge.net/p/nagios/nsca>`__ push
3636
notification support (*optional*)
3737
- Modular backup, archiving, upload and notification components
38+
- Rotation of backups by time or count
3839
- Support for MongoDB Authentication and SSL database connections
3940
- Multi-threaded, single executable
4041
- Auto-scales to number of available CPUs by default

conf/mongodb-consistent-backup.example.conf

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ production:
1717
location: /var/lib/mongodb-consistent-backup
1818
# mongodump:
1919
# binary: [path] (default: /usr/bin/mongodump)
20-
# compression: [auto|none|gzip] (default: auto = enable gzip if supported)
20+
# compression: [auto|none|gzip] (default: auto - enable gzip if supported)
21+
#rotate:
22+
# max_backups: [1+]
23+
# max_days: [0.1+]
2124
# threads: [1-16] (default: auto-generated, shards/cpu)
2225
#replication:
2326
# max_lag_secs: [1+] (default: 10)

mongodb_consistent_backup/Common/Config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ def makeParser(self):
7373
parser.add_argument("--ssl.client_cert_file", dest="ssl.client_cert_file", help="Path to Client SSL Certificate file in PEM format (for optional client ssl auth)", default=None, type=str)
7474
parser.add_argument("-L", "--log-dir", dest="log_dir", help="Path to write log files to (default: disabled)", default='', type=str)
7575
parser.add_argument("--lock-file", dest="lock_file", help="Location of lock file (default: /tmp/mongodb-consistent-backup.lock)", default='/tmp/mongodb-consistent-backup.lock', type=str)
76+
parser.add_argument("--rotate.max_backups", dest="rotate.max_backups", help="Maximum number of backups to keep in backup directory (default: unlimited)", default=0, type=int)
77+
parser.add_argument("--rotate.max_days", dest="rotate.max_days", help="Maximum age in days for backups in backup directory (default: unlimited)", default=0, type=float)
7678
parser.add_argument("--sharding.balancer.wait_secs", dest="sharding.balancer.wait_secs", help="Maximum time to wait for balancer to stop, in seconds (default: 300)", default=300, type=int)
7779
parser.add_argument("--sharding.balancer.ping_secs", dest="sharding.balancer.ping_secs", help="Interval to check balancer state, in seconds (default: 3)", default=3, type=int)
7880
return self.makeParserLoadSubmodules(parser)

mongodb_consistent_backup/Main.py

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
from Notify import Notify
1515
from Oplog import Tailer, Resolver
1616
from Replication import Replset, ReplsetSharded
17+
from Rotate import Rotate
1718
from Sharding import Sharding
18-
from State import StateRoot, StateBackup, StateBackupReplset, StateDoneStamp, StateOplog
19+
from State import StateRoot, StateBackup, StateBackupReplset, StateOplog
1920
from Upload import Upload
2021

2122

@@ -91,14 +92,13 @@ def setup_signal_handlers(self):
9192

9293
def set_backup_dirs(self):
9394
self.backup_root_directory = os.path.join(self.config.backup.location, self.config.backup.name)
94-
self.backup_latest_symlink = os.path.join(self.backup_root_directory, "latest")
95-
self.backup_previous_symlink = os.path.join(self.backup_root_directory, "previous")
9695
self.backup_root_subdirectory = os.path.join(self.config.backup.name, self.backup_time)
9796
self.backup_directory = os.path.join(self.config.backup.location, self.backup_root_subdirectory)
9897

9998
def setup_state(self):
100-
StateRoot(self.backup_root_directory, self.config).write(True)
101-
self.state = StateBackup(self.backup_directory, self.config, self.backup_time, self.uri, sys.argv)
99+
self.state_root = StateRoot(self.backup_root_directory, self.config)
100+
self.state = StateBackup(self.backup_directory, self.config, self.backup_time, self.uri, sys.argv)
101+
self.state_root.write(True)
102102
self.state.write()
103103

104104
def setup_notifier(self):
@@ -151,21 +151,9 @@ def stop_timer(self):
151151
self.timer.stop(self.timer_name)
152152
self.state.set('timers', self.timer.dump())
153153

154-
def read_symlink_latest(self):
155-
if os.path.islink(self.backup_latest_symlink):
156-
return os.readlink(self.backup_latest_symlink)
157-
158-
def update_symlinks(self):
159-
latest = self.read_symlink_latest()
160-
if latest:
161-
logging.info("Updating %s previous symlink to: %s" % (self.config.backup.name, latest))
162-
if os.path.islink(self.backup_previous_symlink):
163-
os.remove(self.backup_previous_symlink)
164-
os.symlink(latest, self.backup_previous_symlink)
165-
if os.path.islink(self.backup_latest_symlink):
166-
os.remove(self.backup_latest_symlink)
167-
logging.info("Updating %s latest symlink to: %s" % (self.config.backup.name, self.backup_directory))
168-
return os.symlink(self.backup_directory, self.backup_latest_symlink)
154+
def rotate_backups(self):
155+
rotater = Rotate(self.config, self.state_root, self.state)
156+
rotater.run()
169157

170158
# TODO Rename class to be more exact as this assumes something went wrong
171159
# noinspection PyUnusedLocal
@@ -451,6 +439,7 @@ def run(self):
451439

452440
# stop timer
453441
self.stop_timer()
442+
self.state.set("completed", True)
454443

455444
# send notifications of backup state
456445
try:
@@ -466,8 +455,7 @@ def run(self):
466455
self.notify.close()
467456
self.exception("Problem running Notifier! Error: %s" % e, e)
468457

469-
StateDoneStamp(self.backup_directory, self.config).write()
470-
self.update_symlinks()
458+
self.rotate_backups()
471459

472460
self.logger.rotate()
473461
logging.info("Completed %s in %.2f sec" % (self.program_name, self.timer.duration(self.timer_name)))
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import logging
2+
import os
3+
4+
from math import ceil
5+
from shutil import rmtree
6+
from time import time
7+
8+
from mongodb_consistent_backup.Errors import OperationError
9+
10+
11+
class Rotate(object):
12+
def __init__(self, config, state_root, state_bkp):
13+
self.config = config
14+
self.state_root = state_root
15+
self.state_bkp = state_bkp
16+
self.backup_name = self.config.backup.name
17+
self.max_backups = self.config.rotate.max_backups
18+
self.max_days = self.config.rotate.max_days
19+
20+
self.previous = None
21+
self.backups = self.backups_by_unixts()
22+
self.latest = state_bkp.get()
23+
24+
self.base_dir = os.path.join(self.config.backup.location, self.config.backup.name)
25+
self.latest_symlink = os.path.join(self.base_dir, "latest")
26+
self.previous_symlink = os.path.join(self.base_dir, "previous")
27+
28+
self.max_secs = 0
29+
if self.max_days > 0:
30+
seconds = float(self.max_days) * 86400.00
31+
self.max_secs = int(ceil(seconds))
32+
33+
def backups_by_unixts(self):
34+
backups = {}
35+
for name in self.state_root.backups:
36+
backup = self.state_root.backups[name]
37+
backup_time = backup["updated_at"]
38+
backups[backup_time] = backup
39+
if not self.previous or backup_time > self.previous["updated_at"]:
40+
self.previous = backup
41+
return backups
42+
43+
def remove(self, ts):
44+
if ts in self.backups:
45+
backup = self.backups[ts]
46+
if os.path.isdir(backup["path"]):
47+
logging.debug("Removing backup path: %s" % backup["path"])
48+
rmtree(backup["path"])
49+
else:
50+
raise OperationError("Backup path %s does not exist!" % backup["path"])
51+
if self.previous == backup:
52+
self.previous = None
53+
del self.backups[ts]
54+
55+
def rotate(self):
56+
if self.max_days == 0 and self.max_backups == 0:
57+
logging.info("Backup rotation is disabled, skipping")
58+
return
59+
logging.info("Rotating backups (max_backups=%i, max_days=%.2f)" % (self.max_backups, self.max_days))
60+
kept_backups = 1
61+
now = int(time())
62+
remove_backups = {}
63+
for ts in sorted(self.backups.iterkeys(), reverse=True):
64+
backup = self.backups[ts]
65+
name = backup["name"].encode("ascii", "ignore")
66+
if self.max_backups == 0 or kept_backups < self.max_backups:
67+
if self.max_secs > 0 and (now - ts) > self.max_secs:
68+
remove_backups[name] = ts
69+
continue
70+
logging.debug("Keeping previous backup %s" % name)
71+
kept_backups += 1
72+
else:
73+
remove_backups[name] = ts
74+
if len(remove_backups) > 0:
75+
logging.info("Backup(s) exceeds max backup count or age, removing: %s" % sorted(remove_backups.keys()))
76+
for name in remove_backups:
77+
self.remove(remove_backups[name])
78+
79+
def symlink(self):
80+
try:
81+
if os.path.islink(self.latest_symlink):
82+
os.remove(self.latest_symlink)
83+
logging.info("Updating %s latest symlink to current backup path: %s" % (self.backup_name, self.latest["path"]))
84+
os.symlink(self.latest["path"], self.latest_symlink)
85+
86+
if os.path.islink(self.previous_symlink):
87+
os.remove(self.previous_symlink)
88+
if self.previous:
89+
logging.info("Updating %s previous symlink to: %s" % (self.backup_name, self.previous["path"]))
90+
os.symlink(self.previous["path"], self.previous_symlink)
91+
except Exception, e:
92+
logging.error("Error creating backup symlinks: %s" % e)
93+
raise OperationError(e)
94+
95+
def run(self):
96+
self.rotate()
97+
self.symlink()

mongodb_consistent_backup/State.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ def merge(self, new, old):
3737
merged.update(new)
3838
return merged
3939

40-
def load(self, load_one=False):
40+
def load(self, load_one=False, filename=None):
4141
f = None
42+
if not filename:
43+
filename = self.state_file
4244
try:
43-
f = open(self.state_file, "r")
45+
f = open(filename, "r")
4446
data = decode_all(f.read())
4547
if load_one and len(data) > 0:
4648
return data[0]
@@ -51,6 +53,15 @@ def load(self, load_one=False):
5153
if f:
5254
f.close()
5355

56+
def get(self, key=None):
57+
if key in self.state:
58+
return self.state[key]
59+
return self.state
60+
61+
def set(self, name, summary):
62+
self.state[name] = summary
63+
self.write(True)
64+
5465
def write(self, do_merge=False):
5566
f = None
5667
try:
@@ -94,6 +105,7 @@ def __init__(self, base_dir, config, backup_time, seed_uri, argv=None):
94105
StateBase.__init__(self, base_dir, config)
95106
self.base_dir = base_dir
96107
self.state['backup'] = True
108+
self.state['completed'] = False
97109
self.state['name'] = backup_time
98110
self.state['method'] = config.backup.method
99111
self.state['path'] = base_dir
@@ -118,16 +130,14 @@ def __init__(self, base_dir, config, backup_time, seed_uri, argv=None):
118130
def init(self):
119131
logging.info("Initializing backup state directory: %s" % self.base_dir)
120132

121-
def set(self, name, summary):
122-
self.state[name] = summary
123-
self.write(True)
124-
125133

126134
class StateRoot(StateBase):
127135
def __init__(self, base_dir, config):
128136
StateBase.__init__(self, base_dir, config)
129137
self.base_dir = base_dir
130138
self.state['root'] = True
139+
self.backups = {}
140+
self.completed_backups = 0
131141

132142
self.init()
133143

@@ -136,7 +146,6 @@ def init(self):
136146
self.load_backups()
137147

138148
def load_backups(self):
139-
backups = []
140149
if os.path.isdir(self.base_dir):
141150
for subdir in os.listdir(self.base_dir):
142151
try:
@@ -145,16 +154,10 @@ def load_backups(self):
145154
continue
146155
state_path = os.path.join(bkp_path, self.meta_name)
147156
state_file = os.path.join(state_path, "meta.bson")
148-
done_path = os.path.join(state_path, "done.bson")
149-
if os.path.isdir(state_path) and os.path.isfile(state_file) and os.path.isfile(done_path):
150-
backups.append(state_file)
157+
self.backups[subdir] = self.load(True, state_file)
158+
if self.backups[subdir]["completed"]:
159+
self.completed_backups += 1
151160
except:
152161
continue
153-
logging.info("Found %i existing completed backups for set" % len(backups))
154-
return backups
155-
156-
157-
class StateDoneStamp(StateBase):
158-
def __init__(self, base_dir, config):
159-
StateBase.__init__(self, base_dir, config, "done.bson")
160-
self.state = {'done': True}
162+
logging.info("Found %i existing completed backups for set" % self.completed_backups)
163+
return self.backups

0 commit comments

Comments
 (0)