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