| #!/usr/bin/python |
| # |
| # This Source Code Form is subject to the terms of the Mozilla Public |
| # License, v. 2.0. If a copy of the MPL was not distributed with this |
| # file, You can obtain one at http://mozilla.org/MPL/2.0/. |
| # |
| # When run directly, this script expects the following environment variables |
| # to be set: |
| # UPLOAD_HOST : host to upload files to |
| # UPLOAD_USER : username on that host |
| # and one of the following: |
| # UPLOAD_PATH : path on that host to put the files in |
| # UPLOAD_TO_TEMP : upload files to a new temporary directory |
| # |
| # If UPLOAD_HOST and UPLOAD_USER are not set, this script will simply write out |
| # the properties file. |
| # |
| # If UPLOAD_HOST is "localhost", then files are simply copied to UPLOAD_PATH. |
| # In this case, UPLOAD_TO_TEMP and POST_UPLOAD_CMD are not supported, and no |
| # properties are written out. |
| # |
| # And will use the following optional environment variables if set: |
| # UPLOAD_SSH_KEY : path to a ssh private key to use |
| # UPLOAD_PORT : port to use for ssh |
| # POST_UPLOAD_CMD: a commandline to run on the remote host after uploading. |
| # UPLOAD_PATH and the full paths of all files uploaded will |
| # be appended to the commandline. |
| # |
| # All files to be uploaded should be passed as commandline arguments to this |
| # script. The script takes one other parameter, --base-path, which you can use |
| # to indicate that files should be uploaded including their paths relative |
| # to the base path. |
| |
| import sys, os |
| import re |
| import json |
| import errno |
| import hashlib |
| import shutil |
| from optparse import OptionParser |
| from subprocess import check_call, check_output, STDOUT |
| import redo |
| |
| def OptionalEnvironmentVariable(v): |
| """Return the value of the environment variable named v, or None |
| if it's unset (or empty).""" |
| if v in os.environ and os.environ[v] != "": |
| return os.environ[v] |
| return None |
| |
| def FixupMsysPath(path): |
| """MSYS helpfully translates absolute pathnames in environment variables |
| and commandline arguments into Windows native paths. This sucks if you're |
| trying to pass an absolute path on a remote server. This function attempts |
| to un-mangle such paths.""" |
| if 'OSTYPE' in os.environ and os.environ['OSTYPE'] == 'msys': |
| # sort of awful, find out where our shell is (should be in msys/bin) |
| # and strip the first part of that path out of the other path |
| if 'SHELL' in os.environ: |
| sh = os.environ['SHELL'] |
| msys = sh[:sh.find('/bin')] |
| if path.startswith(msys): |
| path = path[len(msys):] |
| return path |
| |
| def WindowsPathToMsysPath(path): |
| """Translate a Windows pathname to an MSYS pathname. |
| Necessary because we call out to ssh/scp, which are MSYS binaries |
| and expect MSYS paths.""" |
| # If we're not on Windows, or if we already have an MSYS path (starting |
| # with '/' instead of 'c:' or something), then just return. |
| if sys.platform != 'win32' or path.startswith('/'): |
| return path |
| (drive, path) = os.path.splitdrive(os.path.abspath(path)) |
| return "/" + drive[0] + path.replace('\\','/') |
| |
| def AppendOptionalArgsToSSHCommandline(cmdline, port, ssh_key): |
| """Given optional port and ssh key values, append valid OpenSSH |
| commandline arguments to the list cmdline if the values are not None.""" |
| if port is not None: |
| cmdline.append("-P%d" % port) |
| if ssh_key is not None: |
| # Don't interpret ~ paths - ssh can handle that on its own |
| if not ssh_key.startswith('~'): |
| ssh_key = WindowsPathToMsysPath(ssh_key) |
| cmdline.extend(["-o", "IdentityFile=%s" % ssh_key]) |
| |
| def DoSSHCommand(command, user, host, port=None, ssh_key=None): |
| """Execute command on user@host using ssh. Optionally use |
| port and ssh_key, if provided.""" |
| cmdline = ["ssh"] |
| AppendOptionalArgsToSSHCommandline(cmdline, port, ssh_key) |
| cmdline.extend(["%s@%s" % (user, host), command]) |
| |
| with redo.retrying(check_output, sleeptime=10) as f: |
| output = f(cmdline, stderr=STDOUT).strip() |
| return output |
| |
| raise Exception("Command %s returned non-zero exit code" % cmdline) |
| |
| def DoSCPFile(file, remote_path, user, host, port=None, ssh_key=None): |
| """Upload file to user@host:remote_path using scp. Optionally use |
| port and ssh_key, if provided.""" |
| cmdline = ["scp"] |
| AppendOptionalArgsToSSHCommandline(cmdline, port, ssh_key) |
| cmdline.extend([WindowsPathToMsysPath(file), |
| "%s@%s:%s" % (user, host, remote_path)]) |
| with redo.retrying(check_call, sleeptime=10) as f: |
| f(cmdline) |
| return |
| |
| raise Exception("Command %s returned non-zero exit code" % cmdline) |
| |
| def GetBaseRelativePath(path, local_file, base_path): |
| """Given a remote path to upload to, a full path to a local file, and an |
| optional full path that is a base path of the local file, construct the |
| full remote path to place the file in. If base_path is not None, include |
| the relative path from base_path to file.""" |
| if base_path is None or not local_file.startswith(base_path): |
| # Hack to work around OSX uploading the i386 SDK from i386/dist. Both |
| # the i386 SDK and x86-64 SDK end up in the same directory this way. |
| if base_path.endswith('/x86_64/dist'): |
| return GetBaseRelativePath(path, local_file, base_path.replace('/x86_64/', '/i386/')) |
| return path |
| dir = os.path.dirname(local_file) |
| # strip base_path + extra slash and make it unixy |
| dir = dir[len(base_path)+1:].replace('\\','/') |
| return path + dir |
| |
| def GetFileHashAndSize(filename): |
| sha512Hash = 'UNKNOWN' |
| size = 'UNKNOWN' |
| |
| try: |
| # open in binary mode to make sure we get consistent results |
| # across all platforms |
| with open(filename, "rb") as f: |
| shaObj = hashlib.sha512(f.read()) |
| sha512Hash = shaObj.hexdigest() |
| |
| size = os.path.getsize(filename) |
| except: |
| raise Exception("Unable to get filesize/hash from file: %s" % filename) |
| |
| return (sha512Hash, size) |
| |
| def GetMarProperties(filename): |
| if not os.path.exists(filename): |
| return {} |
| (mar_hash, mar_size) = GetFileHashAndSize(filename) |
| return { |
| 'completeMarFilename': os.path.basename(filename), |
| 'completeMarSize': mar_size, |
| 'completeMarHash': mar_hash, |
| } |
| |
| def GetUrlProperties(output, package): |
| # let's create a switch case using name-spaces/dict |
| # rather than a long if/else with duplicate code |
| property_conditions = [ |
| # key: property name, value: condition |
| ('symbolsUrl', lambda m: m.endswith('crashreporter-symbols.zip') or |
| m.endswith('crashreporter-symbols-full.zip')), |
| ('testsUrl', lambda m: m.endswith(('tests.tar.bz2', 'tests.zip'))), |
| ('unsignedApkUrl', lambda m: m.endswith('apk') and |
| 'unsigned-unaligned' in m), |
| ('robocopApkUrl', lambda m: m.endswith('apk') and 'robocop' in m), |
| ('jsshellUrl', lambda m: 'jsshell-' in m and m.endswith('.zip')), |
| ('completeMarUrl', lambda m: m.endswith('.complete.mar')), |
| ('partialMarUrl', lambda m: m.endswith('.mar') and '.partial.' in m), |
| ('codeCoverageURL', lambda m: m.endswith('code-coverage-gcno.zip')), |
| ('sdkUrl', lambda m: m.endswith(('sdk.tar.bz2', 'sdk.zip'))), |
| ('testPackagesUrl', lambda m: m.endswith('test_packages.json')), |
| ('packageUrl', lambda m: m.endswith(package)), |
| ] |
| url_re = re.compile(r'''^(https?://.*?\.(?:tar\.bz2|dmg|zip|apk|rpm|mar|tar\.gz|json))$''') |
| properties = {} |
| |
| try: |
| for line in output.splitlines(): |
| m = url_re.match(line.strip()) |
| if m: |
| m = m.group(1) |
| for prop, condition in property_conditions: |
| if condition(m): |
| properties.update({prop: m}) |
| break |
| except IOError as e: |
| if e.errno != errno.ENOENT: |
| raise |
| properties = {prop: 'UNKNOWN' for prop, condition in property_conditions} |
| return properties |
| |
| def UploadFiles(user, host, path, files, verbose=False, port=None, ssh_key=None, base_path=None, upload_to_temp_dir=False, post_upload_command=None, package=None): |
| """Upload each file in the list files to user@host:path. Optionally pass |
| port and ssh_key to the ssh commands. If base_path is not None, upload |
| files including their path relative to base_path. If upload_to_temp_dir is |
| True files will be uploaded to a temporary directory on the remote server. |
| Generally, you should have a post upload command specified in these cases |
| that can move them around to their correct location(s). |
| If post_upload_command is not None, execute that command on the remote host |
| after uploading all files, passing it the upload path, and the full paths to |
| all files uploaded. |
| If verbose is True, print status updates while working.""" |
| if not host or not user: |
| return {} |
| if (not path and not upload_to_temp_dir) or (path and upload_to_temp_dir): |
| print "One (and only one of UPLOAD_PATH or UPLOAD_TO_TEMP must be " + \ |
| "defined." |
| sys.exit(1) |
| |
| if upload_to_temp_dir: |
| path = DoSSHCommand("mktemp -d", user, host, port=port, ssh_key=ssh_key) |
| if not path.endswith("/"): |
| path += "/" |
| if base_path is not None: |
| base_path = os.path.abspath(base_path) |
| remote_files = [] |
| properties = {} |
| try: |
| for file in files: |
| file = os.path.abspath(file) |
| if not os.path.isfile(file): |
| raise IOError("File not found: %s" % file) |
| # first ensure that path exists remotely |
| remote_path = GetBaseRelativePath(path, file, base_path) |
| DoSSHCommand("mkdir -p " + remote_path, user, host, port=port, ssh_key=ssh_key) |
| if verbose: |
| print "Uploading " + file |
| DoSCPFile(file, remote_path, user, host, port=port, ssh_key=ssh_key) |
| remote_files.append(remote_path + '/' + os.path.basename(file)) |
| if post_upload_command is not None: |
| if verbose: |
| print "Running post-upload command: " + post_upload_command |
| file_list = '"' + '" "'.join(remote_files) + '"' |
| output = DoSSHCommand('%s "%s" %s' % (post_upload_command, path, file_list), user, host, port=port, ssh_key=ssh_key) |
| # We print since mozharness may parse URLs from the output stream. |
| print output |
| properties = GetUrlProperties(output, package) |
| finally: |
| if upload_to_temp_dir: |
| DoSSHCommand("rm -rf %s" % path, user, host, port=port, |
| ssh_key=ssh_key) |
| if verbose: |
| print "Upload complete" |
| return properties |
| |
| def CopyFilesLocally(path, files, verbose=False, base_path=None, package=None): |
| """Copy each file in the list of files to `path`. The `base_path` argument is treated |
| as it is by UploadFiles.""" |
| if not path.endswith("/"): |
| path += "/" |
| if base_path is not None: |
| base_path = os.path.abspath(base_path) |
| for file in files: |
| file = os.path.abspath(file) |
| if not os.path.isfile(file): |
| raise IOError("File not found: %s" % file) |
| # first ensure that path exists remotely |
| target_path = GetBaseRelativePath(path, file, base_path) |
| if not os.path.exists(target_path): |
| os.makedirs(target_path) |
| if verbose: |
| print "Copying " + file + " to " + target_path |
| shutil.copy(file, target_path) |
| |
| def WriteProperties(files, properties_file, url_properties, package): |
| properties = url_properties |
| for file in files: |
| if file.endswith('.complete.mar'): |
| properties.update(GetMarProperties(file)) |
| with open(properties_file, 'w') as outfile: |
| properties['packageFilename'] = package |
| properties['uploadFiles'] = [os.path.abspath(f) for f in files] |
| json.dump(properties, outfile, indent=4) |
| |
| if __name__ == '__main__': |
| host = OptionalEnvironmentVariable('UPLOAD_HOST') |
| user = OptionalEnvironmentVariable('UPLOAD_USER') |
| path = OptionalEnvironmentVariable('UPLOAD_PATH') |
| upload_to_temp_dir = OptionalEnvironmentVariable('UPLOAD_TO_TEMP') |
| port = OptionalEnvironmentVariable('UPLOAD_PORT') |
| if port is not None: |
| port = int(port) |
| key = OptionalEnvironmentVariable('UPLOAD_SSH_KEY') |
| post_upload_command = OptionalEnvironmentVariable('POST_UPLOAD_CMD') |
| |
| if sys.platform == 'win32': |
| if path is not None: |
| path = FixupMsysPath(path) |
| if post_upload_command is not None: |
| post_upload_command = FixupMsysPath(post_upload_command) |
| |
| parser = OptionParser(usage="usage: %prog [options] <files>") |
| parser.add_option("-b", "--base-path", |
| action="store", |
| help="Preserve file paths relative to this path when uploading. If unset, all files will be uploaded directly to UPLOAD_PATH.") |
| parser.add_option("--properties-file", |
| action="store", |
| help="Path to the properties file to store the upload properties.") |
| parser.add_option("--package", |
| action="store", |
| help="Name of the main package.") |
| (options, args) = parser.parse_args() |
| if len(args) < 1: |
| print "You must specify at least one file to upload" |
| sys.exit(1) |
| if not options.properties_file: |
| print "You must specify a --properties-file" |
| sys.exit(1) |
| |
| if host == "localhost": |
| if upload_to_temp_dir: |
| print "Cannot use UPLOAD_TO_TEMP with UPLOAD_HOST=localhost" |
| sys.exit(1) |
| if post_upload_command: |
| # POST_UPLOAD_COMMAND is difficult to extract from the mozharness |
| # scripts, so just ignore it until it's no longer used anywhere |
| print "Ignoring POST_UPLOAD_COMMAND with UPLOAD_HOST=localhost" |
| |
| try: |
| if host == "localhost": |
| CopyFilesLocally(path, args, base_path=options.base_path, |
| package=options.package, |
| verbose=True) |
| else: |
| |
| url_properties = UploadFiles(user, host, path, args, |
| base_path=options.base_path, port=port, ssh_key=key, |
| upload_to_temp_dir=upload_to_temp_dir, |
| post_upload_command=post_upload_command, |
| package=options.package, verbose=True) |
| |
| WriteProperties(args, options.properties_file, url_properties, options.package) |
| except IOError, (strerror): |
| print strerror |
| sys.exit(1) |