-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsnapback.py
235 lines (181 loc) · 7.82 KB
/
snapback.py
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
#!/usr/bin/env python
# -*- coding:utf-8 -*-
#
# Snapshot like Backup using rsync and hard links
#
# Based on information from the article "Easy Automated Snapshot-Style Backups with Linux and Rsync", by Mike Rubel
# http://www.mikerubel.org/computers/rsync_snapshots/
#
# crontab configuration example:
# ############
# 00 8-18 * * 1-5 root python snapback.py --name mybackup --tag hourly --keep 8 /my/files /snapshots_dir
# 00 21 * * 1-5 root python snapback.py --name mybackup --tag daily --keep 20 /my/files /snapshots_dir
# 00 21 * * 6 root python snapback.py --name mybackup --tag weekly --keep 4 /my/files /snapshots_dir
# 00 21 * * 7 root [ $(date +\%d) -le 07 ] && python snapback.py --name mybackup --tag monthly --keep 6 /my/files /snapshots_dir
#
import argparse
import fcntl
import logging
import os
import platform
import re
import shutil
import subprocess
import sys
import time
exit_code = 0
def main():
if platform.system().lower() != "linux":
logging.error("This script uses hard links and can only be used on linux")
sys.exit(1)
parser = argparse.ArgumentParser(description="Snapshot like Backup using rsync and hard links")
parser.add_argument("--name", help="Backup name", required=True)
parser.add_argument("--tag", help="Backup tag", required=True)
parser.add_argument("--keep", dest="keep", help="How many snapshots to keep", type=int, default=0)
parser.add_argument("--exclude", action="append", help="Passed to rsync exclude, you may use as many --exclude options on the command line as you like", default=[])
parser.add_argument("source", help="Source dir")
parser.add_argument("dest", help="Directory in which the backup snapshots will be created")
args = parser.parse_args()
configure_logging()
logging.info("Starting backup name:{} tag:{} keep:{} from:{} to:{}".format(args.name, args.tag, args.keep, args.source, args.dest))
start_time = time.time()
# Lock file
lock_file = "/tmp/snapback_{}.lock".format(args.name)
try:
fp = open(lock_file, "w")
fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
logging.error("Another backup instance for '{}' is running".format(args.name))
logging.error("or delete stale lock file '{}'".format(lock_file))
logging.error("Backup operation skipped")
sys.exit(1)
if not os.path.exists(args.dest):
os.makedirs(args.dest)
elif not os.path.isdir(args.dest):
logging.error("{} is not a directory".format(args.dest))
sys.exit(1)
logging.info("Calling sync")
res = sync(source=args.source, dest=args.dest, name=args.name, tag=args.tag, excludes=args.exclude)
logging.info("Calling rotate")
rotate(dest=args.dest, name=args.name, tag=args.tag, keep=args.keep)
# Remove lock file
os.remove(lock_file)
elapsed_time = time.strftime("%H:%M:%S", time.gmtime(time.time() - start_time))
logging.info("Finished")
logging.info("Elapsed time: {}".format(elapsed_time))
sys.exit(res)
def configure_logging():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
if "DEBUG" in os.environ:
formatter = logging.Formatter("%(asctime)s %(levelname)-8s|%(module)s.%(funcName)s (%(lineno)d)> %(message)s", "%Y-%m-%d %H:%M:%S")
else:
formatter = logging.Formatter("%(asctime)s %(levelname)s %(module)s %(message)s", "%Y-%m-%d %H:%M:%S")
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)
handler.setFormatter(formatter)
error_handler = logging.StreamHandler(sys.stderr)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
logger.addHandler(handler)
logger.addHandler(error_handler)
def launch_command(cmd_line):
"""
Launch cmd_line printing piped output
:param cmd_line: string command to be launched
:return: int return code of the launched command
"""
# output = subprocess.check_output(cmd_line, stderr=subprocess.STDOUT)
pipe = subprocess.Popen(cmd_line, stdout=subprocess.PIPE, universal_newlines=True)
with pipe.stdout:
for line in iter(pipe.stdout.readline, ""):
logging.info("{}: {}".format(cmd_line[0], line.rstrip("\n")))
return_code = pipe.wait() # wait for the command to finish and get return code
if return_code > 0:
logging.error("ERROR returned by '{}'".format(" ".join(cmd_line)))
return return_code
def touch(path):
"""
Equivalent to unix touch
:param path: string file or directory path
:return: nothing
"""
logging.info("Touching {}".format(path))
if os.path.isdir(path):
os.utime(path, None)
else:
with open(path, "a"):
os.utime(path, None)
def rotate(dest=None, name=None, tag=None, keep=-1):
"""
Remove older backups
:param dest: dir containing backups
:param name: backup name
:param tag: backup tag
:param keep: int: how many snapshots to keep
"""
# lowest significant keep value is 1
if keep < 1:
return
snapshots_list = sorted([folder for folder in next(os.walk(dest))[1] if re.match(r"snapback_{}_[0-9]+_{}".format(name, tag), folder)])
delete_list = snapshots_list[:-keep]
if not delete_list:
logging.info("No snapshots to be deleted")
return
for snapshot in delete_list:
snapshot_path = os.path.join(dest, snapshot)
logging.info("Deleting {}".format(snapshot_path))
shutil.rmtree(snapshot_path)
log_path = snapshot_path + ".log"
logging.info("Deleting log {}".format(log_path))
if os.path.exists(log_path):
os.remove(log_path)
def sync(source=None, dest=None, name=None, tag=None, excludes=None):
"""
Executes rsync
:param source: Source directory
:param dest: Directory in which the backup snapshots will be created
:param name: Backup snapshot base name (es. mybackup)
:param tag: Backup snapshot tag name (es. daily)
:param excludes: rsync excludes list
:return: command exit code
"""
if excludes is None:
excludes = []
timestamp = time.strftime("%Y%m%d%I%M%S")
current_snapshot = os.path.join(dest, "snapback_{}_{}_{}".format(name, timestamp, tag))
current_logfile = current_snapshot + ".log"
snapshots_list = sorted([folder for folder in next(os.walk(dest))[1] if re.match(r"^snapback_{}_[0-9]+_.*$".format(name), folder)])
if len(snapshots_list):
last_snapshot = os.path.join(dest, snapshots_list[-1])
logging.info("Copy-linking {} to {}".format(last_snapshot, current_snapshot))
cmd_line = ["cp", "-a", "-l", last_snapshot, current_snapshot]
result = launch_command(cmd_line)
if result > 0:
logging.error("Errors copy-linking {}".format(source))
return result
# Make sure src end with a / to make sure
# we are going to backup the source following symlinks
source = source.rstrip("/") + "/"
current_snapshot += "/"
if not os.path.exists(dest):
os.makedirs(dest)
cmd_line = ["rsync", "-rltD", "--human-readable", "--stats", "--log-file={}".format(current_logfile), "--delete", "--delete-excluded"]
for exclude in excludes:
cmd_line.append("--exclude={}".format(exclude))
cmd_line.append(source)
cmd_line.append(current_snapshot)
logging.info("Syncing {} to {}".format(source, current_snapshot))
result = launch_command(cmd_line)
if result > 0:
logging.error("Errors syncing {}".format(source))
return result
logging.info("Sync finished, log file: {}".format(current_logfile))
# Update date on current snapshot directory
touch(current_snapshot)
date = time.strftime("%Y%m%d_%I%M%S")
date_file = os.path.join(current_snapshot, "_backup_{}".format(date))
touch(date_file)
return 0
if __name__ == "__main__":
main()