]> arthur.barton.de Git - bup.git/blob - lib/bup/cmd/save.py
save: refactor the code using helper classes
[bup.git] / lib / bup / cmd / save.py
1
2 from __future__ import absolute_import, print_function
3 from binascii import hexlify
4 from errno import EACCES
5 from io import BytesIO
6 import math, os, stat, sys, time
7
8 from bup import compat, hashsplit, git, options, index, client, metadata
9 from bup import hlinkdb
10 from bup.compat import argv_bytes, environ
11 from bup.hashsplit import GIT_MODE_TREE, GIT_MODE_FILE, GIT_MODE_SYMLINK
12 from bup.helpers import (add_error, grafted_path_components, handle_ctrl_c,
13                          hostname, istty2, log, parse_date_or_fatal, parse_num,
14                          path_components, progress, qprogress, resolve_parent,
15                          saved_errors, stripped_path_components,
16                          valid_save_name)
17 from bup.io import byte_stream, path_msg
18 from bup.pwdgrp import userfullname, username
19 from bup.tree import StackDir
20
21
22 optspec = """
23 bup save [-tc] [-n name] <filenames...>
24 --
25 r,remote=  hostname:/path/to/repo of remote repository
26 t,tree     output a tree id
27 c,commit   output a commit id
28 n,name=    name of backup set to update (if any)
29 d,date=    date for the commit (seconds since the epoch)
30 v,verbose  increase log output (can be used more than once)
31 q,quiet    don't show progress meter
32 smaller=   only back up files smaller than n bytes
33 bwlimit=   maximum bytes/sec to transmit to server
34 f,indexfile=  the name of the index file (normally BUP_DIR/bupindex)
35 strip      strips the path to every filename given
36 strip-path= path-prefix to be stripped when saving
37 graft=     a graft point *old_path*=*new_path* (can be used more than once)
38 #,compress=  set compression level to # (0-9, 9 is highest) [1]
39 """
40
41
42 ### Test hooks
43
44 after_nondir_metadata_stat = None
45
46 def before_saving_regular_file(name):
47     return
48
49
50 def main(argv):
51
52     # Hack around lack of nonlocal vars in python 2
53     _nonlocal = {}
54
55     o = options.Options(optspec)
56     opt, flags, extra = o.parse_bytes(argv[1:])
57
58     if opt.indexfile:
59         opt.indexfile = argv_bytes(opt.indexfile)
60     if opt.name:
61         opt.name = argv_bytes(opt.name)
62     if opt.remote:
63         opt.remote = argv_bytes(opt.remote)
64     if opt.strip_path:
65         opt.strip_path = argv_bytes(opt.strip_path)
66
67     git.check_repo_or_die()
68     if not (opt.tree or opt.commit or opt.name):
69         o.fatal("use one or more of -t, -c, -n")
70     if not extra:
71         o.fatal("no filenames given")
72
73     extra = [argv_bytes(x) for x in extra]
74
75     opt.progress = (istty2 and not opt.quiet)
76     opt.smaller = parse_num(opt.smaller or 0)
77     if opt.bwlimit:
78         client.bwlimit = parse_num(opt.bwlimit)
79
80     if opt.date:
81         date = parse_date_or_fatal(opt.date, o.fatal)
82     else:
83         date = time.time()
84
85     if opt.strip and opt.strip_path:
86         o.fatal("--strip is incompatible with --strip-path")
87
88     graft_points = []
89     if opt.graft:
90         if opt.strip:
91             o.fatal("--strip is incompatible with --graft")
92
93         if opt.strip_path:
94             o.fatal("--strip-path is incompatible with --graft")
95
96         for (option, parameter) in flags:
97             if option == "--graft":
98                 parameter = argv_bytes(parameter)
99                 splitted_parameter = parameter.split(b'=')
100                 if len(splitted_parameter) != 2:
101                     o.fatal("a graft point must be of the form old_path=new_path")
102                 old_path, new_path = splitted_parameter
103                 if not (old_path and new_path):
104                     o.fatal("a graft point cannot be empty")
105                 graft_points.append((resolve_parent(old_path),
106                                      resolve_parent(new_path)))
107
108     is_reverse = environ.get(b'BUP_SERVER_REVERSE')
109     if is_reverse and opt.remote:
110         o.fatal("don't use -r in reverse mode; it's automatic")
111
112     name = opt.name
113     if name and not valid_save_name(name):
114         o.fatal("'%s' is not a valid branch name" % path_msg(name))
115     refname = name and b'refs/heads/%s' % name or None
116     if opt.remote or is_reverse:
117         try:
118             cli = client.Client(opt.remote)
119         except client.ClientError as e:
120             log('error: %s' % e)
121             sys.exit(1)
122         oldref = refname and cli.read_ref(refname) or None
123         w = cli.new_packwriter(compression_level=opt.compress)
124     else:
125         cli = None
126         oldref = refname and git.read_ref(refname) or None
127         w = git.PackWriter(compression_level=opt.compress)
128
129     handle_ctrl_c()
130
131
132     # Metadata is stored in a file named .bupm in each directory.  The
133     # first metadata entry will be the metadata for the current directory.
134     # The remaining entries will be for each of the other directory
135     # elements, in the order they're listed in the index.
136     #
137     # Since the git tree elements are sorted according to
138     # git.shalist_item_sort_key, the metalist items are accumulated as
139     # (sort_key, metadata) tuples, and then sorted when the .bupm file is
140     # created.  The sort_key should have been computed using the element's
141     # mangled name and git mode (after hashsplitting), but the code isn't
142     # actually doing that but rather uses the element's real name and mode.
143     # This makes things a bit more difficult when reading it back, see
144     # vfs.ordered_tree_entries().
145
146     # Maintain a stack of information representing the current location in
147     # the archive being constructed.  The current path is recorded in
148     # parts, which will be something like
149     #      [StackDir(name=''), StackDir(name='home'), StackDir(name='someuser')],
150     # and the accumulated content and metadata for files in the dirs is stored
151     # in the .items member of the StackDir.
152
153     stack = []
154
155
156     def _push(part, metadata):
157         # Enter a new archive directory -- make it the current directory.
158         item = StackDir(part, metadata)
159         stack.append(item)
160
161
162     def _pop(force_tree=None, dir_metadata=None):
163         # Leave the current archive directory and add its tree to its parent.
164         item = stack.pop()
165         # FIXME: only test if collision is possible (i.e. given --strip, etc.)?
166         if force_tree:
167             tree = force_tree
168         else:
169             names_seen = set()
170             clean_list = []
171             for x in item.items:
172                 name = x.name
173                 if name in names_seen:
174                     parent_path = b'/'.join(x.name for x in stack) + b'/'
175                     add_error('error: ignoring duplicate path %s in %s'
176                               % (path_msg(name), path_msg(parent_path)))
177                 else:
178                     names_seen.add(name)
179                     clean_list.append(x)
180
181             # if set, overrides the original metadata pushed for this dir.
182             if dir_metadata is None:
183                 dir_metadata = item.meta
184             metalist = [(b'', dir_metadata)]
185             metalist += [(git.shalist_item_sort_key((entry.mode, entry.name, None)),
186                           entry.meta)
187                          for entry in clean_list if entry.mode != GIT_MODE_TREE]
188             metalist.sort(key = lambda x: x[0])
189             metadata = BytesIO(b''.join(m[1].encode() for m in metalist))
190             mode, id = hashsplit.split_to_blob_or_tree(w.new_blob, w.new_tree,
191                                                        [metadata],
192                                                        keep_boundaries=False)
193             shalist = [(mode, b'.bupm', id)]
194             shalist += [(entry.gitmode,
195                          git.mangle_name(entry.name, entry.mode, entry.gitmode),
196                          entry.oid)
197                         for entry in clean_list]
198
199             tree = w.new_tree(shalist)
200         if stack:
201             stack[-1].append(item.name, GIT_MODE_TREE, GIT_MODE_TREE, tree, None)
202         return tree
203
204
205     _nonlocal['count'] = 0
206     _nonlocal['subcount'] = 0
207     _nonlocal['lastremain'] = None
208
209     def progress_report(n):
210         _nonlocal['subcount'] += n
211         cc = _nonlocal['count'] + _nonlocal['subcount']
212         pct = total and (cc*100.0/total) or 0
213         now = time.time()
214         elapsed = now - tstart
215         kps = elapsed and int(cc/1024./elapsed)
216         kps_frac = 10 ** int(math.log(kps+1, 10) - 1)
217         kps = int(kps/kps_frac)*kps_frac
218         if cc:
219             remain = elapsed*1.0/cc * (total-cc)
220         else:
221             remain = 0.0
222         if (_nonlocal['lastremain'] and (remain > _nonlocal['lastremain'])
223               and ((remain - _nonlocal['lastremain'])/_nonlocal['lastremain'] < 0.05)):
224             remain = _nonlocal['lastremain']
225         else:
226             _nonlocal['lastremain'] = remain
227         hours = int(remain/60/60)
228         mins = int(remain/60 - hours*60)
229         secs = int(remain - hours*60*60 - mins*60)
230         if elapsed < 30:
231             remainstr = ''
232             kpsstr = ''
233         else:
234             kpsstr = '%dk/s' % kps
235             if hours:
236                 remainstr = '%dh%dm' % (hours, mins)
237             elif mins:
238                 remainstr = '%dm%d' % (mins, secs)
239             else:
240                 remainstr = '%ds' % secs
241         qprogress('Saving: %.2f%% (%d/%dk, %d/%d files) %s %s\r'
242                   % (pct, cc/1024, total/1024, fcount, ftotal,
243                      remainstr, kpsstr))
244
245
246     indexfile = opt.indexfile or git.repo(b'bupindex')
247     r = index.Reader(indexfile)
248     try:
249         msr = index.MetaStoreReader(indexfile + b'.meta')
250     except IOError as ex:
251         if ex.errno != EACCES:
252             raise
253         log('error: cannot access %r; have you run bup index?'
254             % path_msg(indexfile))
255         sys.exit(1)
256     hlink_db = hlinkdb.HLinkDB(indexfile + b'.hlink')
257
258     def already_saved(ent):
259         return ent.is_valid() and w.exists(ent.sha) and ent.sha
260
261     def wantrecurse_pre(ent):
262         return not already_saved(ent)
263
264     def wantrecurse_during(ent):
265         return not already_saved(ent) or ent.sha_missing()
266
267     def find_hardlink_target(hlink_db, ent):
268         if hlink_db and not stat.S_ISDIR(ent.mode) and ent.nlink > 1:
269             link_paths = hlink_db.node_paths(ent.dev, ent.ino)
270             if link_paths:
271                 return link_paths[0]
272
273     total = ftotal = 0
274     if opt.progress:
275         for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_pre):
276             if not (ftotal % 10024):
277                 qprogress('Reading index: %d\r' % ftotal)
278             exists = ent.exists()
279             hashvalid = already_saved(ent)
280             ent.set_sha_missing(not hashvalid)
281             if not opt.smaller or ent.size < opt.smaller:
282                 if exists and not hashvalid:
283                     total += ent.size
284             ftotal += 1
285         progress('Reading index: %d, done.\n' % ftotal)
286         hashsplit.progress_callback = progress_report
287
288     # Root collisions occur when strip or graft options map more than one
289     # path to the same directory (paths which originally had separate
290     # parents).  When that situation is detected, use empty metadata for
291     # the parent.  Otherwise, use the metadata for the common parent.
292     # Collision example: "bup save ... --strip /foo /foo/bar /bar".
293
294     # FIXME: Add collision tests, or handle collisions some other way.
295
296     # FIXME: Detect/handle strip/graft name collisions (other than root),
297     # i.e. if '/foo/bar' and '/bar' both map to '/'.
298
299     first_root = None
300     root_collision = None
301     tstart = time.time()
302     fcount = 0
303     lastskip_name = None
304     lastdir = b''
305     for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_during):
306         (dir, file) = os.path.split(ent.name)
307         exists = (ent.flags & index.IX_EXISTS)
308         hashvalid = already_saved(ent)
309         wasmissing = ent.sha_missing()
310         oldsize = ent.size
311         if opt.verbose:
312             if not exists:
313                 status = 'D'
314             elif not hashvalid:
315                 if ent.sha == index.EMPTY_SHA:
316                     status = 'A'
317                 else:
318                     status = 'M'
319             else:
320                 status = ' '
321             if opt.verbose >= 2:
322                 log('%s %-70s\n' % (status, path_msg(ent.name)))
323             elif not stat.S_ISDIR(ent.mode) and lastdir != dir:
324                 if not lastdir.startswith(dir):
325                     log('%s %-70s\n' % (status, path_msg(os.path.join(dir, b''))))
326                 lastdir = dir
327
328         if opt.progress:
329             progress_report(0)
330         fcount += 1
331
332         if not exists:
333             continue
334         if opt.smaller and ent.size >= opt.smaller:
335             if exists and not hashvalid:
336                 if opt.verbose:
337                     log('skipping large file "%s"\n' % path_msg(ent.name))
338                 lastskip_name = ent.name
339             continue
340
341         assert(dir.startswith(b'/'))
342         if opt.strip:
343             dirp = stripped_path_components(dir, extra)
344         elif opt.strip_path:
345             dirp = stripped_path_components(dir, [opt.strip_path])
346         elif graft_points:
347             dirp = grafted_path_components(graft_points, dir)
348         else:
349             dirp = path_components(dir)
350
351         # At this point, dirp contains a representation of the archive
352         # path that looks like [(archive_dir_name, real_fs_path), ...].
353         # So given "bup save ... --strip /foo/bar /foo/bar/baz", dirp
354         # might look like this at some point:
355         #   [('', '/foo/bar'), ('baz', '/foo/bar/baz'), ...].
356
357         # This dual representation supports stripping/grafting, where the
358         # archive path may not have a direct correspondence with the
359         # filesystem.  The root directory is represented by an initial
360         # component named '', and any component that doesn't have a
361         # corresponding filesystem directory (due to grafting, for
362         # example) will have a real_fs_path of None, i.e. [('', None),
363         # ...].
364
365         if first_root == None:
366             first_root = dirp[0]
367         elif first_root != dirp[0]:
368             root_collision = True
369
370         # If switching to a new sub-tree, finish the current sub-tree.
371         while [x.name for x in stack] > [x[0] for x in dirp]:
372             _pop()
373
374         # If switching to a new sub-tree, start a new sub-tree.
375         for path_component in dirp[len(stack):]:
376             dir_name, fs_path = path_component
377             # Not indexed, so just grab the FS metadata or use empty metadata.
378             try:
379                 meta = metadata.from_path(fs_path, normalized=True) \
380                     if fs_path else metadata.Metadata()
381             except (OSError, IOError) as e:
382                 add_error(e)
383                 lastskip_name = dir_name
384                 meta = metadata.Metadata()
385             _push(dir_name, meta)
386
387         if not file:
388             if len(stack) == 1:
389                 continue # We're at the top level -- keep the current root dir
390             # Since there's no filename, this is a subdir -- finish it.
391             oldtree = already_saved(ent) # may be None
392             newtree = _pop(force_tree = oldtree)
393             if not oldtree:
394                 if lastskip_name and lastskip_name.startswith(ent.name):
395                     ent.invalidate()
396                 else:
397                     ent.validate(GIT_MODE_TREE, newtree)
398                 ent.repack()
399             if exists and wasmissing:
400                 _nonlocal['count'] += oldsize
401             continue
402
403         # it's not a directory
404         if hashvalid:
405             meta = msr.metadata_at(ent.meta_ofs)
406             meta.hardlink_target = find_hardlink_target(hlink_db, ent)
407             # Restore the times that were cleared to 0 in the metastore.
408             (meta.atime, meta.mtime, meta.ctime) = (ent.atime, ent.mtime, ent.ctime)
409             stack[-1].append(file, ent.mode, ent.gitmode, ent.sha, meta)
410         else:
411             id = None
412             hlink = find_hardlink_target(hlink_db, ent)
413             try:
414                 meta = metadata.from_path(ent.name, hardlink_target=hlink,
415                                           normalized=True,
416                                           after_stat=after_nondir_metadata_stat)
417             except (OSError, IOError) as e:
418                 add_error(e)
419                 lastskip_name = ent.name
420                 continue
421             if stat.S_IFMT(ent.mode) != stat.S_IFMT(meta.mode):
422                 # The mode changed since we indexed the file, this is bad.
423                 # This can cause two issues:
424                 # 1) We e.g. think the file is a regular file, but now it's
425                 #    something else (a device, socket, FIFO or symlink, etc.)
426                 #    and _read_ from it when we shouldn't.
427                 # 2) We then record it as valid, but don't update the index
428                 #    metadata, and on a subsequent save it has 'hashvalid'
429                 #    but is recorded as the file type from the index, when
430                 #    the content is something else ...
431                 # Avoid all of these consistency issues by just skipping such
432                 # things - it really ought to not happen anyway.
433                 add_error("%s: mode changed since indexing, skipping." % path_msg(ent.name))
434                 lastskip_name = ent.name
435                 continue
436             if stat.S_ISREG(ent.mode):
437                 try:
438                     # If the file changes while we're reading it, then our reading
439                     # may stop at some point, but the stat() above may have gotten
440                     # a different size already. Recalculate the meta size so that
441                     # the repository records the accurate size in the metadata, even
442                     # if the other stat() data might be slightly older than the file
443                     # content (which we can't fix, this is inherently racy, but we
444                     # can prevent the size mismatch.)
445                     meta.size = 0
446                     def new_blob(data):
447                         meta.size += len(data)
448                         return w.new_blob(data)
449                     before_saving_regular_file(ent.name)
450                     with hashsplit.open_noatime(ent.name) as f:
451                         (mode, id) = hashsplit.split_to_blob_or_tree(
452                                                 new_blob, w.new_tree, [f],
453                                                 keep_boundaries=False)
454                 except (IOError, OSError) as e:
455                     add_error('%s: %s' % (ent.name, e))
456                     lastskip_name = ent.name
457             elif stat.S_ISDIR(ent.mode):
458                 assert(0)  # handled above
459             elif stat.S_ISLNK(ent.mode):
460                 mode, id = (GIT_MODE_SYMLINK, w.new_blob(meta.symlink_target))
461             else:
462                 # Everything else should be fully described by its
463                 # metadata, so just record an empty blob, so the paths
464                 # in the tree and .bupm will match up.
465                 (mode, id) = (GIT_MODE_FILE, w.new_blob(b''))
466
467             if id:
468                 ent.validate(mode, id)
469                 ent.repack()
470                 stack[-1].append(file, ent.mode, ent.gitmode, id, meta)
471
472         if exists and wasmissing:
473             _nonlocal['count'] += oldsize
474             _nonlocal['subcount'] = 0
475
476
477     if opt.progress:
478         pct = total and _nonlocal['count']*100.0/total or 100
479         progress('Saving: %.2f%% (%d/%dk, %d/%d files), done.    \n'
480                  % (pct, _nonlocal['count']/1024, total/1024, fcount, ftotal))
481
482     while len(stack) > 1: # _pop() all the parts above the root
483         _pop()
484
485     # Finish the root directory.
486     # When there's a collision, use empty metadata for the root.
487     tree = _pop(dir_metadata = metadata.Metadata() if root_collision else None)
488
489     sys.stdout.flush()
490     out = byte_stream(sys.stdout)
491
492     if opt.tree:
493         out.write(hexlify(tree))
494         out.write(b'\n')
495     if opt.commit or name:
496         if compat.py_maj > 2:
497             # Strip b prefix from python 3 bytes reprs to preserve previous format
498              msgcmd = b'[%s]' % b', '.join([repr(argv_bytes(x))[1:].encode('ascii')
499                                            for x in argv])
500         else:
501             msgcmd = repr(argv)
502         msg = b'bup save\n\nGenerated by command:\n%s\n' % msgcmd
503         userline = (b'%s <%s@%s>' % (userfullname(), username(), hostname()))
504         commit = w.new_commit(tree, oldref, userline, date, None,
505                               userline, date, None, msg)
506         if opt.commit:
507             out.write(hexlify(commit))
508             out.write(b'\n')
509
510     msr.close()
511     w.close()  # must close before we can update the ref
512
513     if opt.name:
514         if cli:
515             cli.update_ref(refname, commit, oldref)
516         else:
517             git.update_ref(refname, commit, oldref)
518
519     if cli:
520         cli.close()
521
522     if saved_errors:
523         log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))
524         sys.exit(1)