Update Python code to work with the new backup format.
[cumulus.git] / python / cumulus / cmd_util.py
1 # Cumulus: Efficient Filesystem Backup to the Cloud
2 # Copyright (C) 2006-2009, 2012 The Cumulus Developers
3 # See the AUTHORS file for a list of contributors.
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18
19 """Implementation of the Cumulus command-line utility program."""
20
21 import getpass, os, stat, sys, time
22 from optparse import OptionParser
23
24 import cumulus
25
26 # We support up to "Cumulus Snapshot v0.11" formats, but are also limited by
27 # the cumulus module.
28 FORMAT_VERSION = min(cumulus.FORMAT_VERSION, (0, 11))
29
30 def check_version(format):
31     ver = cumulus.parse_metadata_version(format)
32     if ver > FORMAT_VERSION:
33         raise RuntimeError("Unsupported Cumulus format: " + format)
34
35 # Read a passphrase from the user and store it in the LBS_GPG_PASSPHRASE
36 # environment variable.
37 def get_passphrase():
38     ENV_KEY = 'LBS_GPG_PASSPHRASE'
39     if not os.environ.has_key(ENV_KEY):
40         os.environ[ENV_KEY] = getpass.getpass()
41
42 def cmd_prune_db(args):
43     """ Delete old snapshots from the local database, though do not
44         actually schedule any segment cleaning.
45         Syntax: $0 --localdb=LOCALDB prune-db
46     """
47     db = cumulus.LocalDatabase(options.localdb)
48
49     # Delete old snapshots from the local database.
50     #db.garbage_collect()
51     #db.commit()
52
53 def cmd_clean(args, clean_threshold=7.0):
54     """ Run the segment cleaner.
55         Syntax: $0 --localdb=LOCALDB clean
56     """
57     db = cumulus.LocalDatabase(options.localdb)
58
59     # Delete old snapshots from the local database.
60     intent = float(options.intent)
61     for s in db.list_schemes():
62         db.prune_old_snapshots(s, intent)
63
64     # Expire segments which are poorly-utilized.
65     for s in db.get_segment_cleaning_list():
66         if s.cleaning_benefit > clean_threshold:
67             print "Cleaning segment %d (benefit %.2f)" % (s.id,
68                                                           s.cleaning_benefit)
69             db.mark_segment_expired(s)
70         else:
71             break
72     db.balance_expired_objects()
73     db.commit()
74
75 def cmd_list_snapshots(args):
76     """ List snapshots stored.
77         Syntax: $0 --data=DATADIR list-snapshots
78     """
79     store = cumulus.CumulusStore(options.store)
80     for s in sorted(store.list_snapshots()): print s
81
82 def cmd_list_snapshot_sizes(args):
83     """ List size of data needed for each snapshot.
84         Syntax: $0 --data=DATADIR list-snapshot-sizes
85     """
86     store = cumulus.CumulusStore(options.store)
87     backend = store.backend
88     previous = set()
89     size = 0
90     def get_size(segment):
91         return backend.stat_generic(segment + ".tar", "segments")["size"]
92     for s in sorted(store.list_snapshots()):
93         d = cumulus.parse_full(store.load_snapshot(s))
94         check_version(d['Format'])
95
96         segments = set(d['Segments'].split())
97         (added, removed, addcount, remcount) = (0, 0, 0, 0)
98         for seg in segments.difference(previous):
99             added += get_size(seg)
100             addcount += 1
101         for seg in previous.difference(segments):
102             removed += get_size(seg)
103             remcount += 1
104         size += added - removed
105         previous = segments
106         print "%s: %.3f +%.3f -%.3f (+%d/-%d segments)" % (s, size / 1024.0**2, added / 1024.0**2, removed / 1024.0**2, addcount, remcount)
107
108 def cmd_garbage_collect(args):
109     """ Search for any files which are not needed by any current
110         snapshots and offer to delete them.
111         Syntax: $0 --store=DATADIR gc
112     """
113     store = cumulus.CumulusStore(options.store)
114     backend = store.backend
115     referenced = set()
116     for s in store.list_snapshots():
117         d = cumulus.parse_full(store.load_snapshot(s))
118         check_version(d['Format'])
119         referenced.add(s)
120         referenced.update(d['Segments'].split())
121
122     print referenced
123
124     to_delete = []
125     to_preserve = []
126     for filetype in cumulus.SEARCH_PATHS:
127         for (name, path) in store.backend.list_generic(filetype):
128             if name in referenced:
129                 to_preserve.append(path)
130             else:
131                 to_delete.append(path)
132
133     print to_preserve
134     print to_delete
135
136     raw_backend = backend.raw_backend
137     for f in to_delete:
138         print "Delete:", f
139         if not options.dry_run:
140             raw_backend.delete(f)
141 cmd_gc = cmd_garbage_collect
142
143 def cmd_read_snapshots(snapshots):
144     """ Read a snapshot file
145     """
146     get_passphrase()
147     store = cumulus.CumulusStore(options.store)
148     for s in snapshots:
149         d = cumulus.parse_full(store.load_snapshot(s))
150         check_version(d['Format'])
151         print d
152         print d['Segments'].split()
153     store.cleanup()
154
155 def cmd_read_metadata(args):
156     """ Produce a flattened metadata dump from a snapshot
157     """
158     snapshot = args [0]
159     get_passphrase()
160     store = cumulus.CumulusStore(options.store)
161     d = cumulus.parse_full(store.load_snapshot(snapshot))
162     check_version(d['Format'])
163     metadata = cumulus.read_metadata(store, d['Root'])
164     blank = True
165     for l in metadata:
166         if l == '\n':
167             if blank: continue
168             blank = True
169         else:
170             blank = False
171         sys.stdout.write(l)
172     store.cleanup()
173
174 def cmd_verify_snapshots(snapshots):
175     """ Verify snapshot integrity
176     """
177     get_passphrase()
178     store = cumulus.CumulusStore(options.store)
179     for s in snapshots:
180         cumulus.accessed_segments.clear()
181         print "#### Snapshot", s
182         d = cumulus.parse_full(store.load_snapshot(s))
183         check_version(d['Format'])
184         print "## Root:", d['Root']
185         metadata = cumulus.iterate_metadata(store, d['Root'])
186         for m in metadata:
187             if m.fields['type'] not in ('-', 'f'): continue
188             print "%s [%d bytes]" % (m.fields['name'], int(m.fields['size']))
189             verifier = cumulus.ChecksumVerifier(m.fields['checksum'])
190             size = 0
191             for block in m.data():
192                 data = store.get(block)
193                 verifier.update(data)
194                 size += len(data)
195             if int(m.fields['size']) != size:
196                 raise ValueError("File size does not match!")
197             if not verifier.valid():
198                 raise ValueError("Bad checksum found")
199
200         # Verify that the list of segments included with the snapshot was
201         # actually accurate: covered all segments that were really read, and
202         # doesn't contain duplicates.
203         listed_segments = set(d['Segments'].split())
204         if cumulus.accessed_segments - listed_segments:
205             print "Error: Some segments not listed in descriptor!"
206             print sorted(list(cumulus.accessed_segments - listed_segments))
207         if listed_segments - cumulus.accessed_segments :
208             print "Warning: Extra unused segments listed in descriptor!"
209             print sorted(list(listed_segments - cumulus.accessed_segments))
210     store.cleanup()
211
212 def cmd_restore_snapshot(args):
213     """ Restore a snapshot, or some subset of files from it
214     """
215     get_passphrase()
216     store = cumulus.CumulusStore(options.store)
217     snapshot = cumulus.parse_full(store.load_snapshot(args[0]))
218     check_version(snapshot['Format'])
219     destdir = args[1]
220     paths = args[2:]
221
222     def matchpath(path):
223         "Return true if the specified path should be included in the restore."
224
225         # No specification of what to restore => restore everything
226         if len(paths) == 0: return True
227
228         for p in paths:
229             if path == p: return True
230             if path.startswith(p + "/"): return True
231         return False
232
233     def warn(m, msg):
234         print "Warning: %s: %s" % (m.items.name, msg)
235
236     # Phase 1: Read the complete metadata log and create directory structure.
237     metadata_items = []
238     metadata_paths = {}
239     metadata_segments = {}
240     for m in cumulus.iterate_metadata(store, snapshot['Root']):
241         pathname = os.path.normpath(m.items.name)
242         while os.path.isabs(pathname):
243             pathname = pathname[1:]
244         if not matchpath(pathname): continue
245
246         destpath = os.path.join(destdir, pathname)
247         if m.items.type == 'd':
248             path = destpath
249         else:
250             (path, filename) = os.path.split(destpath)
251
252         metadata_items.append((pathname, m))
253         if m.items.type in ('-', 'f'):
254             metadata_paths[pathname] = m
255             for block in m.data():
256                 (segment, object, checksum, slice) \
257                     = cumulus.CumulusStore.parse_ref(block)
258                 if segment not in metadata_segments:
259                     metadata_segments[segment] = set()
260                 metadata_segments[segment].add(pathname)
261
262         try:
263             if not os.path.isdir(path):
264                 print "mkdir:", path
265                 os.makedirs(path)
266         except Exception, e:
267             warn(m, "Error creating directory structure: %s" % (e,))
268             continue
269
270     # Phase 2: Restore files, ordered by how data is stored in segments.
271     def restore_file(pathname, m):
272         assert m.items.type in ('-', 'f')
273         print "extract:", pathname
274         destpath = os.path.join(destdir, pathname)
275
276         file = open(destpath, 'wb')
277         verifier = cumulus.ChecksumVerifier(m.items.checksum)
278         size = 0
279         for block in m.data():
280             data = store.get(block)
281             verifier.update(data)
282             size += len(data)
283             file.write(data)
284         file.close()
285         if int(m.fields['size']) != size:
286             raise ValueError("File size does not match!")
287         if not verifier.valid():
288             raise ValueError("Bad checksum found")
289
290     while metadata_segments:
291         (segment, items) = metadata_segments.popitem()
292         print "+ Segment", segment
293         for pathname in sorted(items):
294             if pathname in metadata_paths:
295                 restore_file(pathname, metadata_paths[pathname])
296                 del metadata_paths[pathname]
297
298     print "+ Remaining files"
299     while metadata_paths:
300         (pathname, m) = metadata_paths.popitem()
301         restore_file(pathname, m)
302
303     # Phase 3: Restore special files (symlinks, devices).
304     # Phase 4: Restore directory permissions and modification times.
305     for (pathname, m) in reversed(metadata_items):
306         print "permissions:", pathname
307         destpath = os.path.join(destdir, pathname)
308         (path, filename) = os.path.split(destpath)
309
310         # TODO: Check for ../../../paths that might attempt to write outside
311         # the destination directory.  Maybe also check attempts to follow
312         # symlinks pointing outside?
313
314         try:
315             if m.items.type in ('-', 'f', 'd'):
316                 pass
317             elif m.items.type == 'l':
318                 try:
319                     target = m.items.target
320                 except:
321                     # Old (v0.2 format) name for 'target'
322                     target = m.items.contents
323                 os.symlink(target, destpath)
324             elif m.items.type == 'p':
325                 os.mkfifo(destpath)
326             elif m.items.type in ('c', 'b'):
327                 if m.items.type == 'c':
328                     mode = 0600 | stat.S_IFCHR
329                 else:
330                     mode = 0600 | stat.S_IFBLK
331                 os.mknod(destpath, mode, os.makedev(*m.items.device))
332             elif m.items.type == 's':
333                 pass        # TODO: Implement
334             else:
335                 warn(m, "Unknown type code: " + m.items.type)
336                 continue
337
338         except Exception, e:
339             warn(m, "Error restoring: %s" % (e,))
340             continue
341
342         try:
343             uid = m.items.user[0]
344             gid = m.items.group[0]
345             os.lchown(destpath, uid, gid)
346         except Exception, e:
347             warn(m, "Error restoring file ownership: %s" % (e,))
348
349         if m.items.type == 'l':
350             continue
351
352         try:
353             os.chmod(destpath, m.items.mode)
354         except Exception, e:
355             warn(m, "Error restoring file permissions: %s" % (e,))
356
357         try:
358             os.utime(destpath, (time.time(), m.items.mtime))
359         except Exception, e:
360             warn(m, "Error restoring file timestamps: %s" % (e,))
361
362     store.cleanup()
363
364 def main(argv):
365     usage = ["%prog [option]... command [arg]...", "", "Commands:"]
366     cmd = method = None
367     for cmd, method in globals().iteritems():
368         if cmd.startswith ('cmd_'):
369             usage.append(cmd[4:].replace('_', '-') + ':' + method.__doc__)
370     parser = OptionParser(usage="\n".join(usage))
371     parser.add_option("-v", action="store_true", dest="verbose", default=False,
372                       help="increase verbosity")
373     parser.add_option("-n", action="store_true", dest="dry_run", default=False,
374                       help="dry run")
375     parser.add_option("--store", dest="store",
376                       help="specify path to backup data store")
377     parser.add_option("--localdb", dest="localdb",
378                       help="specify path to local database")
379     parser.add_option("--intent", dest="intent", default=1.0,
380                       help="give expected next snapshot type when cleaning")
381     global options
382     (options, args) = parser.parse_args(argv[1:])
383
384     if len(args) == 0:
385         parser.print_usage()
386         sys.exit(1)
387     cmd = args[0]
388     args = args[1:]
389     method = globals().get('cmd_' + cmd.replace('-', '_'))
390     if method:
391         method (args)
392     else:
393         print "Unknown command:", cmd
394         parser.print_usage()
395         sys.exit(1)