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