]> arthur.barton.de Git - bup.git/blob - cmd/restore-cmd.py
Add support for "restore --sparse"
[bup.git] / cmd / restore-cmd.py
1 #!/usr/bin/env python
2 import copy, errno, sys, stat, re
3 from bup import options, git, metadata, vfs
4 from bup.helpers import *
5 from bup._helpers import write_sparsely
6
7 optspec = """
8 bup restore [-C outdir] </branch/revision/path/to/dir ...>
9 --
10 C,outdir=   change to given outdir before extracting files
11 numeric-ids restore numeric IDs (user, group, etc.) rather than names
12 exclude-rx= skip paths matching the unanchored regex (may be repeated)
13 exclude-rx-from= skip --exclude-rx patterns in file (may be repeated)
14 sparse      create sparse files
15 v,verbose   increase log output (can be used more than once)
16 map-user=   given OLD=NEW, restore OLD user as NEW user
17 map-group=  given OLD=NEW, restore OLD group as NEW group
18 map-uid=    given OLD=NEW, restore OLD uid as NEW uid
19 map-gid=    given OLD=NEW, restore OLD gid as NEW gid
20 q,quiet     don't show progress meter
21 """
22
23 total_restored = 0
24
25 # stdout should be flushed after each line, even when not connected to a tty
26 sys.stdout.flush()
27 sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
28
29 def verbose1(s):
30     if opt.verbose >= 1:
31         print s
32
33
34 def verbose2(s):
35     if opt.verbose >= 2:
36         print s
37
38
39 def plog(s):
40     if opt.quiet:
41         return
42     qprogress(s)
43
44
45 def valid_restore_path(path):
46     path = os.path.normpath(path)
47     if path.startswith('/'):
48         path = path[1:]
49     if '/' in path:
50         return True
51
52
53 def print_info(n, fullname):
54     if stat.S_ISDIR(n.mode):
55         verbose1('%s/' % fullname)
56     elif stat.S_ISLNK(n.mode):
57         verbose2('%s@ -> %s' % (fullname, n.readlink()))
58     else:
59         verbose2(fullname)
60
61
62 def create_path(n, fullname, meta):
63     if meta:
64         meta.create_path(fullname)
65     else:
66         # These fallbacks are important -- meta could be null if, for
67         # example, save created a "fake" item, i.e. a new strip/graft
68         # path element, etc.  You can find cases like that by
69         # searching for "Metadata()".
70         unlink(fullname)
71         if stat.S_ISDIR(n.mode):
72             mkdirp(fullname)
73         elif stat.S_ISLNK(n.mode):
74             os.symlink(n.readlink(), fullname)
75
76
77 def parse_owner_mappings(type, options, fatal):
78     """Traverse the options and parse all --map-TYPEs, or call Option.fatal()."""
79     opt_name = '--map-' + type
80     value_rx = r'^([^=]+)=([^=]*)$'
81     if type in ('uid', 'gid'):
82         value_rx = r'^(-?[0-9]+)=(-?[0-9]+)$'
83     owner_map = {}
84     for flag in options:
85         (option, parameter) = flag
86         if option != opt_name:
87             continue
88         match = re.match(value_rx, parameter)
89         if not match:
90             raise fatal("couldn't parse %s as %s mapping" % (parameter, type))
91         old_id, new_id = match.groups()
92         if type in ('uid', 'gid'):
93             old_id = int(old_id)
94             new_id = int(new_id)
95         owner_map[old_id] = new_id
96     return owner_map
97
98
99 def apply_metadata(meta, name, restore_numeric_ids, owner_map):
100     m = copy.deepcopy(meta)
101     m.user = owner_map['user'].get(m.user, m.user)
102     m.group = owner_map['group'].get(m.group, m.group)
103     m.uid = owner_map['uid'].get(m.uid, m.uid)
104     m.gid = owner_map['gid'].get(m.gid, m.gid)
105     m.apply_to_path(name, restore_numeric_ids = restore_numeric_ids)
106
107
108 # Track a list of (restore_path, vfs_path, meta) triples for each path
109 # we've written for a given hardlink_target.  This allows us to handle
110 # the case where we restore a set of hardlinks out of order (with
111 # respect to the original save call(s)) -- i.e. when we don't restore
112 # the hardlink_target path first.  This data also allows us to attempt
113 # to handle other situations like hardlink sets that change on disk
114 # during a save, or between index and save.
115 targets_written = {}
116
117 def hardlink_compatible(target_path, target_vfs_path, target_meta,
118                         src_node, src_meta):
119     global top
120     if not os.path.exists(target_path):
121         return False
122     target_node = top.lresolve(target_vfs_path)
123     if src_node.mode != target_node.mode \
124             or src_node.mtime != target_node.mtime \
125             or src_node.ctime != target_node.ctime \
126             or src_node.hash != target_node.hash:
127         return False
128     if not src_meta.same_file(target_meta):
129         return False
130     return True
131
132
133 def hardlink_if_possible(fullname, node, meta):
134     """Find a suitable hardlink target, link to it, and return true,
135     otherwise return false."""
136     # Expect the caller to handle restoring the metadata if
137     # hardlinking isn't possible.
138     global targets_written
139     target = meta.hardlink_target
140     target_versions = targets_written.get(target)
141     if target_versions:
142         # Check every path in the set that we've written so far for a match.
143         for (target_path, target_vfs_path, target_meta) in target_versions:
144             if hardlink_compatible(target_path, target_vfs_path, target_meta,
145                                    node, meta):
146                 try:
147                     os.link(target_path, fullname)
148                     return True
149                 except OSError, e:
150                     if e.errno != errno.EXDEV:
151                         raise
152     else:
153         target_versions = []
154         targets_written[target] = target_versions
155     full_vfs_path = node.fullname()
156     target_versions.append((fullname, full_vfs_path, meta))
157     return False
158
159
160 def write_file_content(fullname, n):
161     outf = open(fullname, 'wb')
162     try:
163         for b in chunkyreader(n.open()):
164             outf.write(b)
165     finally:
166         outf.close()
167
168
169 def write_file_content_sparsely(fullname, n):
170     outfd = os.open(fullname, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0600)
171     try:
172         trailing_zeros = 0;
173         for b in chunkyreader(n.open()):
174             trailing_zeros = write_sparsely(outfd, b, 512, trailing_zeros)
175         pos = os.lseek(outfd, trailing_zeros, os.SEEK_END)
176         os.ftruncate(outfd, pos)
177     finally:
178         os.close(outfd)
179
180
181 def find_dir_item_metadata_by_name(dir, name):
182     """Find metadata in dir (a node) for an item with the given name,
183     or for the directory itself if the name is ''."""
184     meta_stream = None
185     try:
186         mfile = dir.metadata_file() # VFS file -- cannot close().
187         if mfile:
188             meta_stream = mfile.open()
189             # First entry is for the dir itself.
190             meta = metadata.Metadata.read(meta_stream)
191             if name == '':
192                 return meta
193             for sub in dir:
194                 if stat.S_ISDIR(sub.mode):
195                     meta = find_dir_item_metadata_by_name(sub, '')
196                 else:
197                     meta = metadata.Metadata.read(meta_stream)
198                 if sub.name == name:
199                     return meta
200     finally:
201         if meta_stream:
202             meta_stream.close()
203
204
205 def do_root(n, sparse, owner_map, restore_root_meta = True):
206     # Very similar to do_node(), except that this function doesn't
207     # create a path for n's destination directory (and so ignores
208     # n.fullname).  It assumes the destination is '.', and restores
209     # n's metadata and content there.
210     global total_restored, opt
211     meta_stream = None
212     try:
213         # Directory metadata is the first entry in any .bupm file in
214         # the directory.  Get it.
215         mfile = n.metadata_file() # VFS file -- cannot close().
216         root_meta = None
217         if mfile:
218             meta_stream = mfile.open()
219             root_meta = metadata.Metadata.read(meta_stream)
220         print_info(n, '.')
221         total_restored += 1
222         plog('Restoring: %d\r' % total_restored)
223         for sub in n:
224             m = None
225             # Don't get metadata if this is a dir -- handled in sub do_node().
226             if meta_stream and not stat.S_ISDIR(sub.mode):
227                 m = metadata.Metadata.read(meta_stream)
228             do_node(n, sub, sparse, owner_map, meta = m)
229         if root_meta and restore_root_meta:
230             apply_metadata(root_meta, '.', opt.numeric_ids, owner_map)
231     finally:
232         if meta_stream:
233             meta_stream.close()
234
235 def do_node(top, n, sparse, owner_map, meta = None):
236     # Create n.fullname(), relative to the current directory, and
237     # restore all of its metadata, when available.  The meta argument
238     # will be None for dirs, or when there is no .bupm (i.e. no
239     # metadata).
240     global total_restored, opt
241     meta_stream = None
242     write_content = sparse and write_file_content_sparsely or write_file_content
243     try:
244         fullname = n.fullname(stop_at=top)
245         # Match behavior of index --exclude-rx with respect to paths.
246         exclude_candidate = '/' + fullname
247         if(stat.S_ISDIR(n.mode)):
248             exclude_candidate += '/'
249         if should_rx_exclude_path(exclude_candidate, exclude_rxs):
250             return
251         # If this is a directory, its metadata is the first entry in
252         # any .bupm file inside the directory.  Get it.
253         if(stat.S_ISDIR(n.mode)):
254             mfile = n.metadata_file() # VFS file -- cannot close().
255             if mfile:
256                 meta_stream = mfile.open()
257                 meta = metadata.Metadata.read(meta_stream)
258         print_info(n, fullname)
259
260         created_hardlink = False
261         if meta and meta.hardlink_target:
262             created_hardlink = hardlink_if_possible(fullname, n, meta)
263
264         if not created_hardlink:
265             create_path(n, fullname, meta)
266             if meta:
267                 if stat.S_ISREG(meta.mode):
268                     write_content(fullname, n)
269             elif stat.S_ISREG(n.mode):
270                 write_content(fullname, n)
271
272         total_restored += 1
273         plog('Restoring: %d\r' % total_restored)
274         for sub in n:
275             m = None
276             # Don't get metadata if this is a dir -- handled in sub do_node().
277             if meta_stream and not stat.S_ISDIR(sub.mode):
278                 m = metadata.Metadata.read(meta_stream)
279             do_node(top, sub, sparse, owner_map, meta = m)
280         if meta and not created_hardlink:
281             apply_metadata(meta, fullname, opt.numeric_ids, owner_map)
282     finally:
283         if meta_stream:
284             meta_stream.close()
285         n.release()
286
287
288 handle_ctrl_c()
289
290 o = options.Options(optspec)
291 (opt, flags, extra) = o.parse(sys.argv[1:])
292
293 git.check_repo_or_die()
294 top = vfs.RefList(None)
295
296 if not extra:
297     o.fatal('must specify at least one filename to restore')
298     
299 exclude_rxs = parse_rx_excludes(flags, o.fatal)
300
301 owner_map = {}
302 for map_type in ('user', 'group', 'uid', 'gid'):
303     owner_map[map_type] = parse_owner_mappings(map_type, flags, o.fatal)
304
305 if opt.outdir:
306     mkdirp(opt.outdir)
307     os.chdir(opt.outdir)
308
309 ret = 0
310 for d in extra:
311     if not valid_restore_path(d):
312         add_error("ERROR: path %r doesn't include a branch and revision" % d)
313         continue
314     path,name = os.path.split(d)
315     try:
316         n = top.lresolve(d)
317     except vfs.NodeError, e:
318         add_error(e)
319         continue
320     isdir = stat.S_ISDIR(n.mode)
321     if not name or name == '.':
322         # Source is /foo/what/ever/ or /foo/what/ever/. -- extract
323         # what/ever/* to the current directory, and if name == '.'
324         # (i.e. /foo/what/ever/.), then also restore what/ever's
325         # metadata to the current directory.
326         if not isdir:
327             add_error('%r: not a directory' % d)
328         else:
329             do_root(n, opt.sparse, owner_map, restore_root_meta = (name == '.'))
330     else:
331         # Source is /foo/what/ever -- extract ./ever to cwd.
332         if isinstance(n, vfs.FakeSymlink):
333             # Source is actually /foo/what, i.e. a top-level commit
334             # like /foo/latest, which is a symlink to ../.commit/SHA.
335             # So dereference it, and restore ../.commit/SHA/. to
336             # "./what/.".
337             target = n.dereference()
338             mkdirp(n.name)
339             os.chdir(n.name)
340             do_root(target, opt.sparse, owner_map)
341         else: # Not a directory or fake symlink.
342             meta = find_dir_item_metadata_by_name(n.parent, n.name)
343             do_node(n.parent, n, opt.sparse, owner_map, meta = meta)
344
345 if not opt.quiet:
346     progress('Restoring: %d, done.\n' % total_restored)
347
348 if saved_errors:
349     log('WARNING: %d errors encountered while restoring.\n' % len(saved_errors))
350     sys.exit(1)