]> git.rkrishnan.org Git - tahoe-lafs/tahoe-lafs.git/blob - src/allmydata/scripts/tahoe_backup.py
f68cc7fbc9fa372eadd63e30166d0ef43447c914
[tahoe-lafs/tahoe-lafs.git] / src / allmydata / scripts / tahoe_backup.py
1
2 import os.path
3 import time
4 import urllib
5 import simplejson
6 from allmydata.scripts.common import get_alias, escape_path, DEFAULT_ALIAS
7 from allmydata.scripts.common_http import do_http
8 from allmydata import uri
9 from allmydata.util import time_format
10
11 def raiseHTTPError(msg, resp):
12     msg = msg + ": %s %s %s" % (resp.status, resp.reason, resp.read())
13     raise RuntimeError(msg)
14
15 def readonly(writedircap):
16     return uri.from_string_dirnode(writedircap).get_readonly().to_string()
17
18 def parse_old_timestamp(s, options):
19     try:
20         if not s.endswith("Z"):
21             raise ValueError
22         # the "local" in this "localseconds" is superfluous and
23         # misleading. This returns seconds-since-epoch for an
24         # ISO-8601-ish-formatted UTC time string. This might raise
25         # ValueError if the string is not in the right format.
26         when = time_format.iso_utc_time_to_localseconds(s[:-1])
27         return when
28     except ValueError:
29         pass
30     try:
31         # "2008-11-16 10.34 PM" (localtime)
32         if s[-3:] in (" AM", " PM"):
33             # this might raise ValueError
34             when = time.strptime(s[:-3], "%Y-%m-%d %H.%M")
35             if s[-3:] == "PM":
36                 when += 12*60*60
37             return when
38     except ValueError:
39         pass
40     print >>options.stderr, "unable to parse old timestamp '%s', ignoring" % s
41
42 def readdir(dircap, options):
43     # returns a dict of (childname: (type, readcap, metadata)), or None if the
44     # dircap didn't point to a directory
45     url = options['node-url'] + "uri/%s?t=json" % urllib.quote(dircap)
46     resp = do_http("GET", url)
47     if resp.status != 200:
48         raiseHTTPError("Error during directory GET", resp)
49     jd = simplejson.load(resp)
50     ntype, ndata = jd
51     if ntype != "dirnode":
52         return None
53     contents = {}
54     for (childname, (childtype, childdata)) in ndata["children"].items():
55         contents[childname] = (childtype,
56                                str(childdata["ro_uri"]),
57                                childdata["metadata"])
58     return contents
59
60 def get_local_metadata(path):
61     metadata = {}
62
63     # posix stat(2) metadata, depends on the platform
64     os.stat_float_times(True)
65     s = os.stat(path)
66     metadata["ctime"] = s.st_ctime
67     metadata["mtime"] = s.st_mtime
68
69     misc_fields = ("st_mode", "st_ino", "st_dev", "st_uid", "st_gid")
70     macos_misc_fields = ("st_rsize", "st_creator", "st_type")
71     for field in misc_fields + macos_misc_fields:
72         if hasattr(s, field):
73             metadata[field] = getattr(s, field)
74
75     # TODO: extended attributes, like on OS-X's HFS+
76     return metadata
77
78 def mkdir(contents, options):
79     url = options['node-url'] + "uri?t=mkdir"
80     resp = do_http("POST", url)
81     if resp.status < 200 or resp.status >= 300:
82         raiseHTTPError("error during mkdir", resp)
83     dircap = str(resp.read().strip())
84     url = options['node-url'] + "uri/%s?t=set_children" % urllib.quote(dircap)
85     body = dict([ (childname, (contents[childname][0],
86                                {"ro_uri": contents[childname][1],
87                                 "metadata": contents[childname][2],
88                                 }))
89                   for childname in contents
90                   ])
91     resp = do_http("POST", url, simplejson.dumps(body))
92     if resp.status != 200:
93         raiseHTTPError("error during set_children", resp)
94     return dircap
95
96 def put_child(dirurl, childname, childcap):
97     assert dirurl[-1] == "/"
98     url = dirurl + urllib.quote(childname) + "?t=uri"
99     resp = do_http("PUT", url, childcap)
100     if resp.status not in (200, 201):
101         raiseHTTPError("error during put_child", resp)
102
103 def directory_is_changed(a, b):
104     # each is a mapping from childname to (type, cap, metadata)
105     significant_metadata = ("ctime", "mtime")
106     # other metadata keys are preserved, but changes to them won't trigger a
107     # new backup
108
109     if set(a.keys()) != set(b.keys()):
110         return True
111     for childname in a:
112         a_type, a_cap, a_metadata = a[childname]
113         b_type, b_cap, b_metadata = b[childname]
114         if a_type != b_type:
115             return True
116         if a_cap != b_cap:
117             return True
118         for k in significant_metadata:
119             if a_metadata.get(k) != b_metadata.get(k):
120                 return True
121     return False
122
123 def backup(options):
124     nodeurl = options['node-url']
125     from_dir = options.from_dir
126     to_dir = options.to_dir
127     if options['quiet']:
128         verbosity = 0
129     else:
130         verbosity = 2
131     stdin = options.stdin
132     stdout = options.stdout
133     stderr = options.stderr
134
135     rootcap, path = get_alias(options.aliases, options.to_dir, DEFAULT_ALIAS)
136     to_url = nodeurl + "uri/%s/" % urllib.quote(rootcap)
137     if path:
138         to_url += escape_path(path)
139     if not to_url.endswith("/"):
140         to_url += "/"
141
142     archives_url = to_url + "Archives/"
143     latest_url = to_url + "Latest"
144
145     # first step: make sure the target directory exists, as well as the
146     # Archives/ subdirectory.
147     resp = do_http("GET", archives_url + "?t=json")
148     if resp.status == 404:
149         resp = do_http("POST", archives_url + "?t=mkdir")
150         if resp.status != 200:
151             print >>stderr, "Unable to create target directory: %s %s %s" % \
152                   (resp.status, resp.reason, resp.read())
153             return 1
154         archives_dir = {}
155     else:
156         jdata = simplejson.load(resp)
157         (otype, attrs) = jdata
158         archives_dir = attrs["children"]
159
160     # second step: locate the most recent backup in TODIR/Archives/*
161     latest_backup_time = 0
162     latest_backup_name = None
163     latest_backup_dircap = None
164
165     # we have various time formats. The allmydata.com windows backup tool
166     # appears to create things like "2008-11-16 10.34 PM". This script
167     # creates things like "2009-11-16--17.34Z".
168     for archive_name in archives_dir.keys():
169         if archives_dir[archive_name][0] != "dirnode":
170             continue
171         when = parse_old_timestamp(archive_name, options)
172         if when is not None:
173             if when > latest_backup_time:
174                 latest_backup_time = when
175                 latest_backup_name = archive_name
176                 latest_backup_dircap = str(archives_dir[archive_name][1]["ro_uri"])
177
178     # third step: process the tree
179     new_backup_dircap = Node().process(options.from_dir,
180                                        latest_backup_dircap,
181                                        options)
182     print >>stdout, "new backup done"
183
184     # fourth: attach the new backup to the list
185     new_readonly_backup_dircap = readonly(new_backup_dircap)
186     now = time_format.iso_utc(int(time.time()), sep="_") + "Z"
187
188     put_child(archives_url, now, new_readonly_backup_dircap)
189     put_child(to_url, "Latest", new_readonly_backup_dircap)
190
191     print >>stdout, "backup done"
192     # done!
193     return 0
194
195
196 class Node:
197     def verboseprint(self, msg):
198         if self.options["verbose"]:
199             print >>self.options.stdout, msg
200
201     def process(self, localpath, olddircap, options):
202         # returns newdircap
203         self.options = options
204
205         self.verboseprint("processing %s, olddircap %s" % (localpath, olddircap))
206         olddircontents = {}
207         if olddircap:
208             olddircontents = readdir(olddircap, options)
209
210         newdircontents = {} # childname -> (type, rocap, metadata)
211         for child in os.listdir(localpath):
212             childpath = os.path.join(localpath, child)
213             if os.path.isdir(childpath):
214                 metadata = get_local_metadata(childpath)
215                 oldchildcap = None
216                 if olddircontents is not None and child in olddircontents:
217                     oldchildcap = olddircontents[child][1]
218                 newchilddircap = self.recurse(childpath, oldchildcap)
219                 newdircontents[child] = ("dirnode", newchilddircap, metadata)
220             elif os.path.isfile(childpath):
221                 newfilecap, metadata = self.upload(childpath)
222                 newdircontents[child] = ("filenode", newfilecap, metadata)
223             else:
224                 raise RuntimeError("how do I back this up?")
225
226         if (olddircap
227             and olddircontents is not None
228             and not directory_is_changed(newdircontents, olddircontents)
229             ):
230             self.verboseprint(" %s not changed, re-using old directory" % localpath)
231             # yay! they're identical!
232             return olddircap
233         else:
234             self.verboseprint(" %s changed, making new directory" % localpath)
235             # something changed, or there was no previous directory, so we
236             # must make a new directory
237             newdircap = mkdir(newdircontents, options)
238             return readonly(newdircap)
239
240     def recurse(self, localpath, olddircap):
241         n = self.__class__()
242         return n.process(localpath, olddircap, self.options)
243
244     def upload(self, childpath):
245         self.verboseprint("uploading %s.." % childpath)
246         # we can use the backupdb here
247         #s = os.stat(childpath)
248         # ...
249         # if we go with the old file, we're obligated to use the old
250         # metadata, to make sure it matches the metadata for this child in
251         # the old parent directory
252         #  return oldcap, old_metadata
253
254         metadata = get_local_metadata(childpath)
255         infileobj = open(os.path.expanduser(childpath), "rb")
256         url = self.options['node-url'] + "uri"
257         resp = do_http("PUT", url, infileobj)
258         if resp.status not in (200, 201):
259             raiseHTTPError("Error during file PUT", resp)
260         filecap = resp.read().strip()
261         self.verboseprint(" %s -> %s" % (childpath, filecap))
262         self.verboseprint(" metadata: %s" % (metadata,))
263         return filecap, metadata
264