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