]> arthur.barton.de Git - bup.git/blob - lib/bup/cmd/save.py
HLinkDB.__del__: replace with 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, indexfile, hlink_db, 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     r = index.Reader(indexfile)
229     try:
230         msr = index.MetaStoreReader(indexfile + b'.meta')
231     except IOError as ex:
232         if ex.errno != EACCES:
233             raise
234         log('error: cannot access %r; have you run bup index?'
235             % path_msg(indexfile))
236         sys.exit(1)
237
238     def already_saved(ent):
239         return ent.is_valid() and w.exists(ent.sha) and ent.sha
240
241     def wantrecurse_pre(ent):
242         return not already_saved(ent)
243
244     def wantrecurse_during(ent):
245         return not already_saved(ent) or ent.sha_missing()
246
247     def find_hardlink_target(hlink_db, ent):
248         if hlink_db and not stat.S_ISDIR(ent.mode) and ent.nlink > 1:
249             link_paths = hlink_db.node_paths(ent.dev, ent.ino)
250             if link_paths:
251                 return link_paths[0]
252         return None
253
254     total = ftotal = 0
255     if opt.progress:
256         for transname, ent in r.filter(opt.sources,
257                                        wantrecurse=wantrecurse_pre):
258             if not (ftotal % 10024):
259                 qprogress('Reading index: %d\r' % ftotal)
260             exists = ent.exists()
261             hashvalid = already_saved(ent)
262             ent.set_sha_missing(not hashvalid)
263             if not opt.smaller or ent.size < opt.smaller:
264                 if exists and not hashvalid:
265                     total += ent.size
266             ftotal += 1
267         progress('Reading index: %d, done.\n' % ftotal)
268         hashsplit.progress_callback = progress_report
269
270     # Root collisions occur when strip or graft options map more than one
271     # path to the same directory (paths which originally had separate
272     # parents).  When that situation is detected, use empty metadata for
273     # the parent.  Otherwise, use the metadata for the common parent.
274     # Collision example: "bup save ... --strip /foo /foo/bar /bar".
275
276     # FIXME: Add collision tests, or handle collisions some other way.
277
278     # FIXME: Detect/handle strip/graft name collisions (other than root),
279     # i.e. if '/foo/bar' and '/bar' both map to '/'.
280
281     first_root = None
282     root_collision = None
283     tstart = time.time()
284     fcount = 0
285     lastskip_name = None
286     lastdir = b''
287     for transname, ent in r.filter(opt.sources, wantrecurse=wantrecurse_during):
288         (dir, file) = os.path.split(ent.name)
289         exists = (ent.flags & index.IX_EXISTS)
290         hashvalid = already_saved(ent)
291         wasmissing = ent.sha_missing()
292         oldsize = ent.size
293         if opt.verbose:
294             if not exists:
295                 status = 'D'
296             elif not hashvalid:
297                 if ent.sha == index.EMPTY_SHA:
298                     status = 'A'
299                 else:
300                     status = 'M'
301             else:
302                 status = ' '
303             if opt.verbose >= 2:
304                 log('%s %-70s\n' % (status, path_msg(ent.name)))
305             elif not stat.S_ISDIR(ent.mode) and lastdir != dir:
306                 if not lastdir.startswith(dir):
307                     log('%s %-70s\n' % (status, path_msg(os.path.join(dir, b''))))
308                 lastdir = dir
309
310         if opt.progress:
311             progress_report(0)
312         fcount += 1
313
314         if not exists:
315             continue
316         if opt.smaller and ent.size >= opt.smaller:
317             if exists and not hashvalid:
318                 if opt.verbose:
319                     log('skipping large file "%s"\n' % path_msg(ent.name))
320                 lastskip_name = ent.name
321             continue
322
323         assert(dir.startswith(b'/'))
324         if opt.strip:
325             dirp = stripped_path_components(dir, opt.sources)
326         elif opt.strip_path:
327             dirp = stripped_path_components(dir, [opt.strip_path])
328         elif opt.grafts:
329             dirp = grafted_path_components(opt.grafts, dir)
330         else:
331             dirp = path_components(dir)
332
333         # At this point, dirp contains a representation of the archive
334         # path that looks like [(archive_dir_name, real_fs_path), ...].
335         # So given "bup save ... --strip /foo/bar /foo/bar/baz", dirp
336         # might look like this at some point:
337         #   [('', '/foo/bar'), ('baz', '/foo/bar/baz'), ...].
338
339         # This dual representation supports stripping/grafting, where the
340         # archive path may not have a direct correspondence with the
341         # filesystem.  The root directory is represented by an initial
342         # component named '', and any component that doesn't have a
343         # corresponding filesystem directory (due to grafting, for
344         # example) will have a real_fs_path of None, i.e. [('', None),
345         # ...].
346
347         if first_root == None:
348             first_root = dirp[0]
349         elif first_root != dirp[0]:
350             root_collision = True
351
352         # If switching to a new sub-tree, finish the current sub-tree.
353         while [x.name for x in stack] > [x[0] for x in dirp]:
354             _pop()
355
356         # If switching to a new sub-tree, start a new sub-tree.
357         for path_component in dirp[len(stack):]:
358             dir_name, fs_path = path_component
359             # Not indexed, so just grab the FS metadata or use empty metadata.
360             try:
361                 meta = metadata.from_path(fs_path, normalized=True) \
362                     if fs_path else metadata.Metadata()
363             except (OSError, IOError) as e:
364                 add_error(e)
365                 lastskip_name = dir_name
366                 meta = metadata.Metadata()
367             _push(dir_name, meta)
368
369         if not file:
370             if len(stack) == 1:
371                 continue # We're at the top level -- keep the current root dir
372             # Since there's no filename, this is a subdir -- finish it.
373             oldtree = already_saved(ent) # may be None
374             newtree = _pop(force_tree = oldtree)
375             if not oldtree:
376                 if lastskip_name and lastskip_name.startswith(ent.name):
377                     ent.invalidate()
378                 else:
379                     ent.validate(GIT_MODE_TREE, newtree)
380                 ent.repack()
381             if exists and wasmissing:
382                 _nonlocal['count'] += oldsize
383             continue
384
385         # it's not a directory
386         if hashvalid:
387             meta = msr.metadata_at(ent.meta_ofs)
388             meta.hardlink_target = find_hardlink_target(hlink_db, ent)
389             # Restore the times that were cleared to 0 in the metastore.
390             (meta.atime, meta.mtime, meta.ctime) = (ent.atime, ent.mtime, ent.ctime)
391             stack[-1].append(file, ent.mode, ent.gitmode, ent.sha, meta)
392         else:
393             id = None
394             hlink = find_hardlink_target(hlink_db, ent)
395             try:
396                 meta = metadata.from_path(ent.name, hardlink_target=hlink,
397                                           normalized=True,
398                                           after_stat=after_nondir_metadata_stat)
399             except (OSError, IOError) as e:
400                 add_error(e)
401                 lastskip_name = ent.name
402                 continue
403             if stat.S_IFMT(ent.mode) != stat.S_IFMT(meta.mode):
404                 # The mode changed since we indexed the file, this is bad.
405                 # This can cause two issues:
406                 # 1) We e.g. think the file is a regular file, but now it's
407                 #    something else (a device, socket, FIFO or symlink, etc.)
408                 #    and _read_ from it when we shouldn't.
409                 # 2) We then record it as valid, but don't update the index
410                 #    metadata, and on a subsequent save it has 'hashvalid'
411                 #    but is recorded as the file type from the index, when
412                 #    the content is something else ...
413                 # Avoid all of these consistency issues by just skipping such
414                 # things - it really ought to not happen anyway.
415                 add_error("%s: mode changed since indexing, skipping." % path_msg(ent.name))
416                 lastskip_name = ent.name
417                 continue
418             if stat.S_ISREG(ent.mode):
419                 try:
420                     # If the file changes while we're reading it, then our reading
421                     # may stop at some point, but the stat() above may have gotten
422                     # a different size already. Recalculate the meta size so that
423                     # the repository records the accurate size in the metadata, even
424                     # if the other stat() data might be slightly older than the file
425                     # content (which we can't fix, this is inherently racy, but we
426                     # can prevent the size mismatch.)
427                     meta.size = 0
428                     def new_blob(data):
429                         meta.size += len(data)
430                         return w.new_blob(data)
431                     before_saving_regular_file(ent.name)
432                     with hashsplit.open_noatime(ent.name) as f:
433                         (mode, id) = hashsplit.split_to_blob_or_tree(
434                                                 new_blob, w.new_tree, [f],
435                                                 keep_boundaries=False)
436                 except (IOError, OSError) as e:
437                     add_error('%s: %s' % (ent.name, e))
438                     lastskip_name = ent.name
439             elif stat.S_ISDIR(ent.mode):
440                 assert(0)  # handled above
441             elif stat.S_ISLNK(ent.mode):
442                 mode, id = (GIT_MODE_SYMLINK, w.new_blob(meta.symlink_target))
443             else:
444                 # Everything else should be fully described by its
445                 # metadata, so just record an empty blob, so the paths
446                 # in the tree and .bupm will match up.
447                 (mode, id) = (GIT_MODE_FILE, w.new_blob(b''))
448
449             if id:
450                 ent.validate(mode, id)
451                 ent.repack()
452                 stack[-1].append(file, ent.mode, ent.gitmode, id, meta)
453
454         if exists and wasmissing:
455             _nonlocal['count'] += oldsize
456             _nonlocal['subcount'] = 0
457
458
459     if opt.progress:
460         pct = total and _nonlocal['count']*100.0/total or 100
461         progress('Saving: %.2f%% (%d/%dk, %d/%d files), done.    \n'
462                  % (pct, _nonlocal['count']/1024, total/1024, fcount, ftotal))
463
464     while len(stack) > 1: # _pop() all the parts above the root
465         _pop()
466
467     # Finish the root directory.
468     # When there's a collision, use empty metadata for the root.
469     tree = _pop(dir_metadata = metadata.Metadata() if root_collision else None)
470
471     msr.close()
472     return tree
473
474
475 def commit_tree(tree, parent, date, argv, writer):
476     if compat.py_maj > 2:
477         # Strip b prefix from python 3 bytes reprs to preserve previous format
478          msgcmd = b'[%s]' % b', '.join([repr(argv_bytes(x))[1:].encode('ascii')
479                                        for x in argv])
480     else:
481         msgcmd = repr(argv)
482     msg = b'bup save\n\nGenerated by command:\n%s\n' % msgcmd
483     userline = (b'%s <%s@%s>' % (userfullname(), username(), hostname()))
484     return writer.new_commit(tree, parent, userline, date, None,
485                              userline, date, None, msg)
486
487
488 def main(argv):
489     handle_ctrl_c()
490     opt = opts_from_cmdline(argv)
491     client.bwlimit = opt.bwlimit
492     git.check_repo_or_die()
493
494     remote_dest = opt.remote or opt.is_reverse
495     if not remote_dest:
496         repo = git
497         cli = nullcontext()
498     else:
499         try:
500             cli = repo = client.Client(opt.remote)
501         except client.ClientError as e:
502             log('error: %s' % e)
503             sys.exit(1)
504
505     # cli creation must be last nontrivial command in each if clause above
506     with cli:
507         if not remote_dest:
508             w = git.PackWriter(compression_level=opt.compress)
509         else:
510             w = cli.new_packwriter(compression_level=opt.compress)
511
512         with w:
513             sys.stdout.flush()
514             out = byte_stream(sys.stdout)
515
516             if opt.name:
517                 refname = b'refs/heads/%s' % opt.name
518                 parent = repo.read_ref(refname)
519             else:
520                 refname = parent = None
521
522             indexfile = opt.indexfile or git.repo(b'bupindex')
523             with hlinkdb.HLinkDB(indexfile + b'.hlink') as hlink_db:
524                 tree = save_tree(opt, indexfile, hlink_db, w)
525             if opt.tree:
526                 out.write(hexlify(tree))
527                 out.write(b'\n')
528             if opt.commit or opt.name:
529                 commit = commit_tree(tree, parent, opt.date, argv, w)
530                 if opt.commit:
531                     out.write(hexlify(commit))
532                     out.write(b'\n')
533
534         # packwriter must be closed before we can update the ref
535         if opt.name:
536             repo.update_ref(refname, commit, parent)
537
538     if saved_errors:
539         log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))
540         sys.exit(1)