""" Ported to Python 3. """ import os.path import time from urllib.parse import quote as url_quote import datetime from allmydata.scripts.common import get_alias, escape_path, DEFAULT_ALIAS, \ UnknownAliasError from allmydata.scripts.common_http import do_http, HTTPError, format_http_error from allmydata.util import time_format, jsonbytes as json from allmydata.scripts import backupdb from allmydata.util.encodingutil import listdir_unicode, quote_output, \ quote_local_unicode_path, to_bytes, FilenameEncodingError, unicode_to_url from allmydata.util.assertutil import precondition from allmydata.util.fileutil import abspath_expanduser_unicode, precondition_abspath def get_local_metadata(path): metadata = {} # posix stat(2) metadata, depends on the platform s = os.stat(path) metadata["ctime"] = s.st_ctime metadata["mtime"] = s.st_mtime misc_fields = ("st_mode", "st_ino", "st_dev", "st_uid", "st_gid") macos_misc_fields = ("st_rsize", "st_creator", "st_type") for field in misc_fields + macos_misc_fields: if hasattr(s, field): metadata[field] = getattr(s, field) # TODO: extended attributes, like on OS-X's HFS+ return metadata def mkdir(contents, options): kids = dict([ (childname, (contents[childname][0], {"ro_uri": contents[childname][1], "metadata": contents[childname][2], })) for childname in contents ]) body = json.dumps(kids).encode("utf-8") url = options['node-url'] + "uri?t=mkdir-immutable" resp = do_http("POST", url, body) if resp.status < 200 or resp.status >= 300: raise HTTPError("Error during mkdir", resp) dircap = to_bytes(resp.read().strip()) return dircap def put_child(dirurl, childname, childcap): assert dirurl[-1] != "/" url = dirurl + "/" + url_quote(unicode_to_url(childname)) + "?t=uri" resp = do_http("PUT", url, childcap) if resp.status not in (200, 201): raise HTTPError("Error during put_child", resp) class BackerUpper(object): """ :ivar int _files_checked: The number of files which the backup process has so-far inspected on the grid to determine if they need to be re-uploaded. :ivar int _directories_checked: The number of directories which the backup process has so-far inspected on the grid to determine if they need to be re-uploaded. """ def __init__(self, options): self.options = options self._files_checked = 0 self._directories_checked = 0 def run(self): options = self.options nodeurl = options['node-url'] self.verbosity = 1 if options['quiet']: self.verbosity = 0 if options['verbose']: self.verbosity = 2 stdout = options.stdout stderr = options.stderr start_timestamp = datetime.datetime.now() bdbfile = os.path.join(options["node-directory"], "private", "backupdb.sqlite") bdbfile = abspath_expanduser_unicode(bdbfile) self.backupdb = backupdb.get_backupdb(bdbfile, stderr) if not self.backupdb: print("ERROR: Unable to load backup db.", file=stderr) return 1 try: rootcap, path = get_alias(options.aliases, options.to_dir, DEFAULT_ALIAS) except UnknownAliasError as e: e.display(stderr) return 1 to_url = nodeurl + "uri/%s/" % url_quote(rootcap) if path: to_url += escape_path(path) if not to_url.endswith("/"): to_url += "/" archives_url = to_url + "Archives/" archives_url = archives_url.rstrip("/") to_url = to_url.rstrip("/") # first step: make sure the target directory exists, as well as the # Archives/ subdirectory. resp = do_http("GET", archives_url + "?t=json") if resp.status == 404: resp = do_http("POST", archives_url + "?t=mkdir") if resp.status != 200: print(format_http_error("Unable to create target directory", resp), file=stderr) return 1 # second step: process the tree targets = list(collect_backup_targets( options.from_dir, listdir_unicode, self.options.filter_listdir, )) completed = run_backup( warn=self.warn, upload_file=self.upload, upload_directory=self.upload_directory, targets=targets, start_timestamp=start_timestamp, stdout=stdout, ) new_backup_dircap = completed.dircap # third: attach the new backup to the list now = time_format.iso_utc(int(time.time()), sep="_") + "Z" put_child(archives_url, now, new_backup_dircap) put_child(to_url, "Latest", new_backup_dircap) print(completed.report( self.verbosity, self._files_checked, self._directories_checked, ), file=stdout) # The command exits with code 2 if files or directories were skipped if completed.any_skips(): return 2 # done! return 0 def verboseprint(self, msg): precondition(isinstance(msg, str), msg) if self.verbosity >= 2: print(msg, file=self.options.stdout) def warn(self, msg): precondition(isinstance(msg, str), msg) print(msg, file=self.options.stderr) def upload_directory(self, path, compare_contents, create_contents): must_create, r = self.check_backupdb_directory(compare_contents) if must_create: self.verboseprint(" creating directory for %s" % quote_local_unicode_path(path)) newdircap = mkdir(create_contents, self.options) assert isinstance(newdircap, bytes) if r: r.did_create(newdircap) return True, newdircap else: self.verboseprint(" re-using old directory for %s" % quote_local_unicode_path(path)) return False, r.was_created() def check_backupdb_file(self, childpath): if not self.backupdb: return True, None use_timestamps = not self.options["ignore-timestamps"] r = self.backupdb.check_file(childpath, use_timestamps) if not r.was_uploaded(): return True, r if not r.should_check(): # the file was uploaded or checked recently, so we can just use # it return False, r # we must check the file before using the results filecap = r.was_uploaded() self.verboseprint("checking %s" % quote_output(filecap)) nodeurl = self.options['node-url'] checkurl = nodeurl + "uri/%s?t=check&output=JSON" % url_quote(filecap) self._files_checked += 1 resp = do_http("POST", checkurl) if resp.status != 200: # can't check, so we must assume it's bad return True, r cr = json.loads(resp.read()) healthy = cr["results"]["healthy"] if not healthy: # must upload return True, r # file is healthy, no need to upload r.did_check_healthy(cr) return False, r def check_backupdb_directory(self, compare_contents): if not self.backupdb: return True, None r = self.backupdb.check_directory(compare_contents) if not r.was_created(): return True, r if not r.should_check(): # the file was uploaded or checked recently, so we can just use # it return False, r # we must check the directory before re-using it dircap = r.was_created() self.verboseprint("checking %s" % quote_output(dircap)) nodeurl = self.options['node-url'] checkurl = nodeurl + "uri/%s?t=check&output=JSON" % url_quote(dircap) self._directories_checked += 1 resp = do_http("POST", checkurl) if resp.status != 200: # can't check, so we must assume it's bad return True, r cr = json.loads(resp.read()) healthy = cr["results"]["healthy"] if not healthy: # must create return True, r # directory is healthy, no need to upload r.did_check_healthy(cr) return False, r # This function will raise an IOError exception when called on an unreadable file def upload(self, childpath): precondition_abspath(childpath) #self.verboseprint("uploading %s.." % quote_local_unicode_path(childpath)) metadata = get_local_metadata(childpath) # we can use the backupdb here must_upload, bdb_results = self.check_backupdb_file(childpath) if must_upload: self.verboseprint("uploading %s.." % quote_local_unicode_path(childpath)) infileobj = open(childpath, "rb") url = self.options['node-url'] + "uri" resp = do_http("PUT", url, infileobj) if resp.status not in (200, 201): raise HTTPError("Error during file PUT", resp) filecap = resp.read().strip() self.verboseprint(" %s -> %s" % (quote_local_unicode_path(childpath, quotemarks=False), quote_output(filecap, quotemarks=False))) #self.verboseprint(" metadata: %s" % (quote_output(metadata, quotemarks=False),)) if bdb_results: bdb_results.did_upload(filecap) return True, filecap, metadata else: self.verboseprint("skipping %s.." % quote_local_unicode_path(childpath)) return False, bdb_results.was_uploaded(), metadata def backup(options): bu = BackerUpper(options) return bu.run() def collect_backup_targets(root, listdir, filter_children): """ Yield BackupTargets in a suitable order for processing (deepest targets before their parents). """ try: children = listdir(root) except EnvironmentError: yield PermissionDeniedTarget(root, isdir=True) except FilenameEncodingError: yield FilenameUndecodableTarget(root, isdir=True) else: for child in filter_children(children): assert isinstance(child, str), child childpath = os.path.join(root, child) if os.path.islink(childpath): yield LinkTarget(childpath, isdir=False) elif os.path.isdir(childpath): child_targets = collect_backup_targets( childpath, listdir, filter_children, ) for child_target in child_targets: yield child_target elif os.path.isfile(childpath): yield FileTarget(childpath) else: yield SpecialTarget(childpath) yield DirectoryTarget(root) def run_backup( warn, upload_file, upload_directory, targets, start_timestamp, stdout, ): progress = BackupProgress(warn, start_timestamp, len(targets)) for target in targets: # Pass in the progress and get back a progress. It would be great if # progress objects were immutable. Then the target's backup would # make a new progress with the desired changes and return it to us. # Currently, BackupProgress is mutable, though, and everything just # mutates it. progress = target.backup(progress, upload_file, upload_directory) print(progress.report(datetime.datetime.now()), file=stdout) return progress.backup_finished() class FileTarget(object): def __init__(self, path): self._path = path def __repr__(self): return "".format(self._path) def backup(self, progress, upload_file, upload_directory): try: created, childcap, metadata = upload_file(self._path) except EnvironmentError: target = PermissionDeniedTarget(self._path, isdir=False) return target.backup(progress, upload_file, upload_directory) else: assert isinstance(childcap, bytes) if created: return progress.created_file(self._path, childcap, metadata) return progress.reused_file(self._path, childcap, metadata) class DirectoryTarget(object): def __init__(self, path): self._path = path def __repr__(self): return "".format(self._path) def backup(self, progress, upload_file, upload_directory): metadata = get_local_metadata(self._path) progress, create, compare = progress.consume_directory(self._path) did_create, dircap = upload_directory(self._path, compare, create) if did_create: return progress.created_directory(self._path, dircap, metadata) return progress.reused_directory(self._path, dircap, metadata) class _ErrorTarget(object): def __init__(self, path, isdir=False): self._path = path self._quoted_path = quote_local_unicode_path(path) self._isdir = isdir class PermissionDeniedTarget(_ErrorTarget): def backup(self, progress, upload_file, upload_directory): return progress.permission_denied(self._isdir, self._quoted_path) class FilenameUndecodableTarget(_ErrorTarget): def backup(self, progress, upload_file, upload_directory): return progress.decoding_failed(self._isdir, self._quoted_path) class LinkTarget(_ErrorTarget): def backup(self, progress, upload_file, upload_directory): return progress.unsupported_filetype( self._isdir, self._quoted_path, "symlink", ) class SpecialTarget(_ErrorTarget): def backup(self, progress, upload_file, upload_directory): return progress.unsupported_filetype( self._isdir, self._quoted_path, "special", ) class BackupComplete(object): def __init__(self, start_timestamp, end_timestamp, files_created, files_reused, files_skipped, directories_created, directories_reused, directories_skipped, dircap, ): self._start_timestamp = start_timestamp self._end_timestamp = end_timestamp self._files_created = files_created self._files_reused = files_reused self._files_skipped = files_skipped self._directories_created = directories_created self._directories_reused = directories_reused self._directories_skipped = directories_skipped self.dircap = dircap def any_skips(self): return self._files_skipped or self._directories_skipped def report(self, verbosity, files_checked, directories_checked): result = [] if verbosity >= 1: result.append( " %d files uploaded (%d reused)," " %d files skipped," " %d directories created (%d reused)," " %d directories skipped" % ( self._files_created, self._files_reused, self._files_skipped, self._directories_created, self._directories_reused, self._directories_skipped, ), ) if verbosity >= 2: result.append( " %d files checked, %d directories checked" % ( files_checked, directories_checked, ), ) # calc elapsed time, omitting microseconds elapsed_time = str( self._end_timestamp - self._start_timestamp ).split('.')[0] result.append(" backup done, elapsed time: %s" % (elapsed_time,)) return "\n".join(result) class BackupProgress(object): # Would be nice if this data structure were immutable and its methods were # transformations that created a new slightly different object. Not there # yet, though. def __init__(self, warn, start_timestamp, target_count): self._warn = warn self._start_timestamp = start_timestamp self._target_count = target_count self._files_created = 0 self._files_reused = 0 self._files_skipped = 0 self._directories_created = 0 self._directories_reused = 0 self._directories_skipped = 0 self.last_dircap = None self._create_contents = {} self._compare_contents = {} def report(self, now): report_format = ( "Backing up {target_progress}/{target_total}... {elapsed} elapsed..." ) return report_format.format( target_progress=( self._files_created + self._files_reused + self._files_skipped + self._directories_created + self._directories_reused + self._directories_skipped ), target_total=self._target_count, elapsed=self._format_elapsed(now - self._start_timestamp), ) def _format_elapsed(self, elapsed): seconds = int(elapsed.total_seconds()) hours = seconds // 3600 minutes = (seconds // 60) % 60 seconds = seconds % 60 return "{}h {}m {}s".format( hours, minutes, seconds, ) def backup_finished(self): end_timestamp = datetime.datetime.now() return BackupComplete( self._start_timestamp, end_timestamp, self._files_created, self._files_reused, self._files_skipped, self._directories_created, self._directories_reused, self._directories_skipped, self.last_dircap, ) def consume_directory(self, dirpath): return self, { os.path.basename(create_path): create_value for (create_path, create_value) in list(self._create_contents.items()) if os.path.dirname(create_path) == dirpath }, { os.path.basename(compare_path): compare_value for (compare_path, compare_value) in list(self._compare_contents.items()) if os.path.dirname(compare_path) == dirpath } def created_directory(self, path, dircap, metadata): self._create_contents[path] = ("dirnode", dircap, metadata) self._compare_contents[path] = dircap self._directories_created += 1 self.last_dircap = dircap return self def reused_directory(self, path, dircap, metadata): self._create_contents[path] = ("dirnode", dircap, metadata) self._compare_contents[path] = dircap self._directories_reused += 1 self.last_dircap = dircap return self def created_file(self, path, cap, metadata): self._create_contents[path] = ("filenode", cap, metadata) self._compare_contents[path] = cap self._files_created += 1 return self def reused_file(self, path, cap, metadata): self._create_contents[path] = ("filenode", cap, metadata) self._compare_contents[path] = cap self._files_reused += 1 return self def permission_denied(self, isdir, quoted_path): return self._skip( "WARNING: permission denied on {kind} {path}", isdir, path=quoted_path, ) def decoding_failed(self, isdir, quoted_path): return self._skip( "WARNING: could not list {kind} {path} due to a filename encoding error", isdir, path=quoted_path, ) def unsupported_filetype(self, isdir, quoted_path, filetype): return self._skip( "WARNING: cannot backup {filetype} {path}", isdir, path=quoted_path, filetype=filetype, ) def _skip(self, message, isdir, **kw): if isdir: self._directories_skipped += 1 kind = "directory" else: self._files_skipped += 1 kind = "file" self._warn(message.format(kind=kind, **kw)) # Pretend we're a persistent data structure being transformed. return self