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