Improve tracking of segments and segment utilization.
[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.LowlevelDataStore(options.store)
80     for s in sorted(store.list_snapshots()):
81         print s
82
83 def cmd_list_snapshot_sizes(args):
84     """ List size of data needed for each snapshot.
85         Syntax: $0 --data=DATADIR list-snapshot-sizes
86     """
87     lowlevel = cumulus.LowlevelDataStore(options.store)
88     lowlevel.scan()
89     store = cumulus.ObjectStore(lowlevel)
90     previous = set()
91     exts = {}
92     for seg in lowlevel.store.list('segments'):
93         exts.update ([seg.split ('.', 1)])
94     for s in sorted(lowlevel.list_snapshots()):
95         d = cumulus.parse_full(store.load_snapshot(s))
96         check_version(d['Format'])
97
98         try:
99             intent = float(d['Backup-Intent'])
100         except:
101             intent = 1.0
102
103         segments = d['Segments'].split()
104         (size, added, removed, addcount, remcount) = (0, 0, 0, 0, 0)
105         lo_stat = lowlevel.lowlevel_stat
106         for seg in segments:
107             segsize = lo_stat('.'.join ((seg, exts[seg])))['size']
108             size += segsize
109             if seg not in previous:
110                 added += segsize
111                 addcount += 1
112         for seg in previous:
113             if seg not in segments:
114                 removed += lo_stat('.'.join((seg, exts[seg])))['size']
115                 remcount += 1
116         previous = set(segments)
117         print "%s [%s]: %.3f +%.3f -%.3f (+%d/-%d segments)" % (s, intent, size / 1024.0**2, added / 1024.0**2, removed / 1024.0**2, addcount, remcount)
118
119 def cmd_garbage_collect(args):
120     """ Search for any files which are not needed by any current
121         snapshots and offer to delete them.
122         Syntax: $0 --store=DATADIR gc
123     """
124     lowlevel = cumulus.LowlevelDataStore(options.store)
125     lowlevel.scan()
126     store = cumulus.ObjectStore(lowlevel)
127     snapshots = set(lowlevel.list_snapshots())
128     segments = set()
129     for s in snapshots:
130         d = cumulus.parse_full(store.load_snapshot(s))
131         check_version(d['Format'])
132         segments.update(d['Segments'].split())
133
134     referenced = snapshots.union(segments)
135     reclaimed = 0
136     for (t, r) in cumulus.store.type_patterns.items():
137         for f in lowlevel.store.list(t):
138             m = r.match(f)
139             if m is None or m.group(1) not in referenced:
140                 print "Garbage:", (t, f)
141                 reclaimed += lowlevel.store.stat(t, f)['size']
142                 if not options.dry_run:
143                     lowlevel.store.delete(t, f)
144     print "Reclaimed space:", reclaimed
145
146 cmd_gc = cmd_garbage_collect
147
148 def cmd_object_checksums(segments):
149     """ Build checksum list for objects in the given segments, or all
150         segments if none are specified.
151     """
152     get_passphrase()
153     lowlevel = cumulus.LowlevelDataStore(options.store)
154     store = cumulus.ObjectStore(lowlevel)
155     if len(segments) == 0:
156         segments = sorted(lowlevel.list_segments())
157     for s in segments:
158         for (o, data) in store.load_segment(s):
159             csum = cumulus.ChecksumCreator().update(data).compute()
160             print "%s/%s:%d:%s" % (s, o, len(data), csum)
161     store.cleanup()
162 object_sums = cmd_object_checksums
163
164 def cmd_read_snapshots(snapshots):
165     """ Read a snapshot file
166     """
167     get_passphrase()
168     lowlevel = cumulus.LowlevelDataStore(options.store)
169     store = cumulus.ObjectStore(lowlevel)
170     for s in snapshots:
171         d = cumulus.parse_full(store.load_snapshot(s))
172         check_version(d['Format'])
173         print d
174         print d['Segments'].split()
175     store.cleanup()
176
177 def cmd_read_metadata(args):
178     """ Produce a flattened metadata dump from a snapshot
179     """
180     snapshot = args [0]
181     get_passphrase()
182     lowlevel = cumulus.LowlevelDataStore(options.store)
183     store = cumulus.ObjectStore(lowlevel)
184     d = cumulus.parse_full(store.load_snapshot(snapshot))
185     check_version(d['Format'])
186     metadata = cumulus.read_metadata(store, d['Root'])
187     blank = True
188     for l in metadata:
189         if l == '\n':
190             if blank: continue
191             blank = True
192         else:
193             blank = False
194         sys.stdout.write(l)
195     store.cleanup()
196
197 def cmd_verify_snapshots(snapshots):
198     """ Verify snapshot integrity
199     """
200     get_passphrase()
201     lowlevel = cumulus.LowlevelDataStore(options.store)
202     store = cumulus.ObjectStore(lowlevel)
203     for s in snapshots:
204         cumulus.accessed_segments.clear()
205         print "#### Snapshot", s
206         d = cumulus.parse_full(store.load_snapshot(s))
207         check_version(d['Format'])
208         print "## Root:", d['Root']
209         metadata = cumulus.iterate_metadata(store, d['Root'])
210         for m in metadata:
211             if m.fields['type'] not in ('-', 'f'): continue
212             print "%s [%d bytes]" % (m.fields['name'], int(m.fields['size']))
213             verifier = cumulus.ChecksumVerifier(m.fields['checksum'])
214             size = 0
215             for block in m.data():
216                 data = store.get(block)
217                 verifier.update(data)
218                 size += len(data)
219             if int(m.fields['size']) != size:
220                 raise ValueError("File size does not match!")
221             if not verifier.valid():
222                 raise ValueError("Bad checksum found")
223
224         # Verify that the list of segments included with the snapshot was
225         # actually accurate: covered all segments that were really read, and
226         # doesn't contain duplicates.
227         listed_segments = set(d['Segments'].split())
228         if cumulus.accessed_segments - listed_segments:
229             print "Error: Some segments not listed in descriptor!"
230             print sorted(list(cumulus.accessed_segments - listed_segments))
231         if listed_segments - cumulus.accessed_segments :
232             print "Warning: Extra unused segments listed in descriptor!"
233             print sorted(list(listed_segments - cumulus.accessed_segments))
234     store.cleanup()
235
236 def cmd_restore_snapshot(args):
237     """ Restore a snapshot, or some subset of files from it
238     """
239     get_passphrase()
240     lowlevel = cumulus.LowlevelDataStore(options.store)
241     store = cumulus.ObjectStore(lowlevel)
242     snapshot = cumulus.parse_full(store.load_snapshot(args[0]))
243     check_version(snapshot['Format'])
244     destdir = args[1]
245     paths = args[2:]
246
247     def matchpath(path):
248         "Return true if the specified path should be included in the restore."
249
250         # No specification of what to restore => restore everything
251         if len(paths) == 0: return True
252
253         for p in paths:
254             if path == p: return True
255             if path.startswith(p + "/"): return True
256         return False
257
258     def warn(m, msg):
259         print "Warning: %s: %s" % (m.items.name, msg)
260
261     # Phase 1: Read the complete metadata log and create directory structure.
262     metadata_items = []
263     metadata_paths = {}
264     metadata_segments = {}
265     for m in cumulus.iterate_metadata(store, snapshot['Root']):
266         pathname = os.path.normpath(m.items.name)
267         while os.path.isabs(pathname):
268             pathname = pathname[1:]
269         if not matchpath(pathname): continue
270
271         destpath = os.path.join(destdir, pathname)
272         if m.items.type == 'd':
273             path = destpath
274         else:
275             (path, filename) = os.path.split(destpath)
276
277         metadata_items.append((pathname, m))
278         if m.items.type in ('-', 'f'):
279             metadata_paths[pathname] = m
280             for block in m.data():
281                 (segment, object, checksum, slice) \
282                     = cumulus.ObjectStore.parse_ref(block)
283                 if segment not in metadata_segments:
284                     metadata_segments[segment] = set()
285                 metadata_segments[segment].add(pathname)
286
287         try:
288             if not os.path.isdir(path):
289                 print "mkdir:", path
290                 os.makedirs(path)
291         except Exception, e:
292             warn(m, "Error creating directory structure: %s" % (e,))
293             continue
294
295     # Phase 2: Restore files, ordered by how data is stored in segments.
296     def restore_file(pathname, m):
297         assert m.items.type in ('-', 'f')
298         print "extract:", pathname
299         destpath = os.path.join(destdir, pathname)
300
301         file = open(destpath, 'wb')
302         verifier = cumulus.ChecksumVerifier(m.items.checksum)
303         size = 0
304         for block in m.data():
305             data = store.get(block)
306             verifier.update(data)
307             size += len(data)
308             file.write(data)
309         file.close()
310         if int(m.fields['size']) != size:
311             raise ValueError("File size does not match!")
312         if not verifier.valid():
313             raise ValueError("Bad checksum found")
314
315     while metadata_segments:
316         (segment, items) = metadata_segments.popitem()
317         print "+ Segment", segment
318         for pathname in sorted(items):
319             if pathname in metadata_paths:
320                 restore_file(pathname, metadata_paths[pathname])
321                 del metadata_paths[pathname]
322
323     print "+ Remaining files"
324     while metadata_paths:
325         (pathname, m) = metadata_paths.popitem()
326         restore_file(pathname, m)
327
328     # Phase 3: Restore special files (symlinks, devices).
329     # Phase 4: Restore directory permissions and modification times.
330     for (pathname, m) in reversed(metadata_items):
331         print "permissions:", pathname
332         destpath = os.path.join(destdir, pathname)
333         (path, filename) = os.path.split(destpath)
334
335         # TODO: Check for ../../../paths that might attempt to write outside
336         # the destination directory.  Maybe also check attempts to follow
337         # symlinks pointing outside?
338
339         try:
340             if m.items.type in ('-', 'f', 'd'):
341                 pass
342             elif m.items.type == 'l':
343                 try:
344                     target = m.items.target
345                 except:
346                     # Old (v0.2 format) name for 'target'
347                     target = m.items.contents
348                 os.symlink(target, destpath)
349             elif m.items.type == 'p':
350                 os.mkfifo(destpath)
351             elif m.items.type in ('c', 'b'):
352                 if m.items.type == 'c':
353                     mode = 0600 | stat.S_IFCHR
354                 else:
355                     mode = 0600 | stat.S_IFBLK
356                 os.mknod(destpath, mode, os.makedev(*m.items.device))
357             elif m.items.type == 's':
358                 pass        # TODO: Implement
359             else:
360                 warn(m, "Unknown type code: " + m.items.type)
361                 continue
362
363         except Exception, e:
364             warn(m, "Error restoring: %s" % (e,))
365             continue
366
367         try:
368             uid = m.items.user[0]
369             gid = m.items.group[0]
370             os.lchown(destpath, uid, gid)
371         except Exception, e:
372             warn(m, "Error restoring file ownership: %s" % (e,))
373
374         if m.items.type == 'l':
375             continue
376
377         try:
378             os.chmod(destpath, m.items.mode)
379         except Exception, e:
380             warn(m, "Error restoring file permissions: %s" % (e,))
381
382         try:
383             os.utime(destpath, (time.time(), m.items.mtime))
384         except Exception, e:
385             warn(m, "Error restoring file timestamps: %s" % (e,))
386
387     store.cleanup()
388
389 def main(argv):
390     usage = ["%prog [option]... command [arg]...", "", "Commands:"]
391     cmd = method = None
392     for cmd, method in globals().iteritems():
393         if cmd.startswith ('cmd_'):
394             usage.append(cmd[4:].replace('_', '-') + ':' + method.__doc__)
395     parser = OptionParser(usage="\n".join(usage))
396     parser.add_option("-v", action="store_true", dest="verbose", default=False,
397                       help="increase verbosity")
398     parser.add_option("-n", action="store_true", dest="dry_run", default=False,
399                       help="dry run")
400     parser.add_option("--store", dest="store",
401                       help="specify path to backup data store")
402     parser.add_option("--localdb", dest="localdb",
403                       help="specify path to local database")
404     parser.add_option("--intent", dest="intent", default=1.0,
405                       help="give expected next snapshot type when cleaning")
406     global options
407     (options, args) = parser.parse_args(argv[1:])
408
409     if len(args) == 0:
410         parser.print_usage()
411         sys.exit(1)
412     cmd = args[0]
413     args = args[1:]
414     method = globals().get('cmd_' + cmd.replace('-', '_'))
415     if method:
416         method (args)
417     else:
418         print "Unknown command:", cmd
419         parser.print_usage()
420         sys.exit(1)