Home | History | Annotate | Download | only in assets
      1 #!/usr/bin/env python
      2 #
      3 # Copyright 2016 Google Inc.
      4 #
      5 # Use of this source code is governed by a BSD-style license that can be
      6 # found in the LICENSE file.
      7 
      8 
      9 """Utilities for managing assets."""
     10 
     11 
     12 import argparse
     13 import json
     14 import os
     15 import shlex
     16 import shutil
     17 import subprocess
     18 import sys
     19 
     20 INFRA_BOTS_DIR = os.path.abspath(os.path.realpath(os.path.join(
     21     os.path.dirname(os.path.abspath(__file__)), os.pardir)))
     22 sys.path.insert(0, INFRA_BOTS_DIR)
     23 import utils
     24 import zip_utils
     25 
     26 
     27 ASSETS_DIR = os.path.join(INFRA_BOTS_DIR, 'assets')
     28 SKIA_DIR = os.path.abspath(os.path.join(INFRA_BOTS_DIR, os.pardir, os.pardir))
     29 
     30 CIPD_PACKAGE_NAME_TMPL = 'skia/bots/%s'
     31 DEFAULT_CIPD_SERVICE_URL = 'https://chrome-infra-packages.appspot.com'
     32 
     33 DEFAULT_GS_BUCKET = 'skia-buildbots'
     34 GS_SUBDIR_TMPL = 'gs://%s/assets/%s'
     35 GS_PATH_TMPL = '%s/%s.zip'
     36 
     37 TAG_PROJECT_SKIA = 'project:skia'
     38 TAG_VERSION_PREFIX = 'version:'
     39 TAG_VERSION_TMPL = '%s%%s' % TAG_VERSION_PREFIX
     40 
     41 WHICH = 'where' if sys.platform.startswith('win') else 'which'
     42 
     43 VERSION_FILENAME = 'VERSION'
     44 ZIP_BLACKLIST = ['.git', '.svn', '*.pyc', '.DS_STORE']
     45 
     46 
     47 class CIPDStore(object):
     48   """Wrapper object for CIPD."""
     49   def __init__(self, cipd_url=DEFAULT_CIPD_SERVICE_URL):
     50     self._cipd = 'cipd'
     51     if sys.platform == 'win32':
     52       self._cipd = 'cipd.bat'
     53     self._cipd_url = cipd_url
     54     self._check_setup()
     55 
     56   def _check_setup(self):
     57     """Verify that we have the CIPD binary and that we're authenticated."""
     58     try:
     59       self._run(['auth-info'], specify_service_url=False)
     60     except OSError:
     61       raise Exception('CIPD binary not found on your path (typically in '
     62                       'depot_tools). You may need to update depot_tools.')
     63     except subprocess.CalledProcessError:
     64       raise Exception('CIPD not authenticated. You may need to run:\n\n'
     65                       '$ %s auth-login' % self._cipd)
     66 
     67   def _run(self, cmd, specify_service_url=True):
     68     """Run the given command."""
     69     cipd_args = []
     70     if specify_service_url:
     71       cipd_args.extend(['--service-url', self._cipd_url])
     72     if os.getenv('USE_CIPD_GCE_AUTH'):
     73       # Enable automatic GCE authentication. For context see
     74       # https://bugs.chromium.org/p/skia/issues/detail?id=6385#c3
     75       cipd_args.extend(['-service-account-json', ':gce'])
     76     subprocess.check_call(
     77         [self._cipd]
     78         + cmd
     79         + cipd_args
     80     )
     81 
     82   def _json_output(self, cmd):
     83     """Run the given command, return the JSON output."""
     84     with utils.tmp_dir():
     85       json_output = os.path.join(os.getcwd(), 'output.json')
     86       self._run(cmd + ['--json-output', json_output])
     87       with open(json_output) as f:
     88         parsed = json.load(f)
     89     return parsed.get('result', [])
     90 
     91   def _search(self, pkg_name):
     92     res = self._json_output(['search', pkg_name, '--tag', TAG_PROJECT_SKIA])
     93     return [r['instance_id'] for r in res]
     94 
     95   def _describe(self, pkg_name, instance_id):
     96     """Obtain details about the given package and instance ID."""
     97     return self._json_output(['describe', pkg_name, '--version', instance_id])
     98 
     99   def get_available_versions(self, name):
    100     """List available versions of the asset."""
    101     pkg_name = CIPD_PACKAGE_NAME_TMPL % name
    102     versions = []
    103     for instance_id in self._search(pkg_name):
    104       details = self._describe(pkg_name, instance_id)
    105       for tag in details.get('tags'):
    106         tag_name = tag.get('tag', '')
    107         if tag_name.startswith(TAG_VERSION_PREFIX):
    108           trimmed = tag_name[len(TAG_VERSION_PREFIX):]
    109           try:
    110             versions.append(int(trimmed))
    111           except ValueError:
    112             raise ValueError('Found package instance with invalid version '
    113                              'tag: %s' % tag_name)
    114     versions.sort()
    115     return versions
    116 
    117   def upload(self, name, version, target_dir):
    118     """Create a CIPD package."""
    119     self._run([
    120         'create',
    121         '--name', CIPD_PACKAGE_NAME_TMPL % name,
    122         '--in', target_dir,
    123         '--tag', TAG_PROJECT_SKIA,
    124         '--tag', TAG_VERSION_TMPL % version,
    125         '--compression-level', '1',
    126     ])
    127 
    128   def download(self, name, version, target_dir):
    129     """Download a CIPD package."""
    130     pkg_name = CIPD_PACKAGE_NAME_TMPL % name
    131     version_tag = TAG_VERSION_TMPL % version
    132     target_dir = os.path.abspath(target_dir)
    133     with utils.tmp_dir():
    134       infile = os.path.join(os.getcwd(), 'input')
    135       with open(infile, 'w') as f:
    136         f.write('%s %s' % (pkg_name, version_tag))
    137       self._run([
    138           'ensure',
    139           '--root', target_dir,
    140           '--list', infile,
    141       ])
    142 
    143   def delete_contents(self, name):
    144     """Delete data for the given asset."""
    145     self._run(['pkg-delete', CIPD_PACKAGE_NAME_TMPL % name])
    146 
    147 
    148 class GSStore(object):
    149   """Wrapper object for interacting with Google Storage."""
    150   def __init__(self, gsutil=None, bucket=DEFAULT_GS_BUCKET):
    151     if gsutil:
    152       gsutil = os.path.abspath(gsutil)
    153     else:
    154       gsutil = subprocess.check_output([WHICH, 'gsutil']).rstrip()
    155     self._gsutil = [gsutil]
    156     if gsutil.endswith('.py'):
    157       self._gsutil = ['python', gsutil]
    158     self._gs_bucket = bucket
    159 
    160   def copy(self, src, dst):
    161     """Copy src to dst."""
    162     subprocess.check_call(self._gsutil + ['cp', src, dst])
    163 
    164   def list(self, path):
    165     """List objects in the given path."""
    166     try:
    167       return subprocess.check_output(self._gsutil + ['ls', path]).splitlines()
    168     except subprocess.CalledProcessError:
    169       # If the prefix does not exist, we'll get an error, which is okay.
    170       return []
    171 
    172   def get_available_versions(self, name):
    173     """Return the existing version numbers for the asset."""
    174     files = self.list(GS_SUBDIR_TMPL % (self._gs_bucket, name))
    175     bnames = [os.path.basename(f) for f in files]
    176     suffix = '.zip'
    177     versions = [int(f[:-len(suffix)]) for f in bnames if f.endswith(suffix)]
    178     versions.sort()
    179     return versions
    180 
    181   def upload(self, name, version, target_dir):
    182     """Upload to GS."""
    183     target_dir = os.path.abspath(target_dir)
    184     with utils.tmp_dir():
    185       zip_file = os.path.join(os.getcwd(), '%d.zip' % version)
    186       zip_utils.zip(target_dir, zip_file, blacklist=ZIP_BLACKLIST)
    187       gs_path = GS_PATH_TMPL % (GS_SUBDIR_TMPL % (self._gs_bucket, name),
    188                                 str(version))
    189       self.copy(zip_file, gs_path)
    190 
    191   def download(self, name, version, target_dir):
    192     """Download from GS."""
    193     gs_path = GS_PATH_TMPL % (GS_SUBDIR_TMPL % (self._gs_bucket, name),
    194                               str(version))
    195     target_dir = os.path.abspath(target_dir)
    196     with utils.tmp_dir():
    197       zip_file = os.path.join(os.getcwd(), '%d.zip' % version)
    198       self.copy(gs_path, zip_file)
    199       zip_utils.unzip(zip_file, target_dir)
    200 
    201   def delete_contents(self, name):
    202     """Delete data for the given asset."""
    203     gs_path = GS_SUBDIR_TMPL % (self._gs_bucket, name)
    204     attempt_delete = True
    205     try:
    206       subprocess.check_call(self._gsutil + ['ls', gs_path])
    207     except subprocess.CalledProcessError:
    208       attempt_delete = False
    209     if attempt_delete:
    210       subprocess.check_call(self._gsutil + ['rm', '-rf', gs_path])
    211 
    212 
    213 class MultiStore(object):
    214   """Wrapper object which uses CIPD as the primary store and GS for backup."""
    215   def __init__(self, cipd_url=DEFAULT_CIPD_SERVICE_URL,
    216                gsutil=None, gs_bucket=DEFAULT_GS_BUCKET):
    217     self._cipd = CIPDStore(cipd_url=cipd_url)
    218     self._gs = GSStore(gsutil=gsutil, bucket=gs_bucket)
    219 
    220   def get_available_versions(self, name):
    221     return self._cipd.get_available_versions(name)
    222 
    223   def upload(self, name, version, target_dir):
    224     self._cipd.upload(name, version, target_dir)
    225     self._gs.upload(name, version, target_dir)
    226 
    227   def download(self, name, version, target_dir):
    228     self._gs.download(name, version, target_dir)
    229 
    230   def delete_contents(self, name):
    231     self._cipd.delete_contents(name)
    232     self._gs.delete_contents(name)
    233 
    234 
    235 def _prompt(prompt):
    236   """Prompt for input, return result."""
    237   return raw_input(prompt)
    238 
    239 
    240 class Asset(object):
    241   def __init__(self, name, store):
    242     self._store = store
    243     self._name = name
    244     self._dir = os.path.join(ASSETS_DIR, self._name)
    245 
    246   @property
    247   def version_file(self):
    248     """Return the path to the version file for this asset."""
    249     return os.path.join(self._dir, VERSION_FILENAME)
    250 
    251   def get_current_version(self):
    252     """Obtain the current version of the asset."""
    253     if not os.path.isfile(self.version_file):
    254       return -1
    255     with open(self.version_file) as f:
    256       return int(f.read())
    257 
    258   def get_available_versions(self):
    259     """Return the existing version numbers for this asset."""
    260     return self._store.get_available_versions(self._name)
    261 
    262   def get_next_version(self):
    263     """Find the next available version number for the asset."""
    264     versions = self.get_available_versions()
    265     if len(versions) == 0:
    266       return 0
    267     return versions[-1] + 1
    268 
    269   def download_version(self, version, target_dir):
    270     """Download the specified version of the asset."""
    271     self._store.download(self._name, version, target_dir)
    272 
    273   def download_current_version(self, target_dir):
    274     """Download the version of the asset specified in its version file."""
    275     v = self.get_current_version()
    276     self.download_version(v, target_dir)
    277 
    278   def upload_new_version(self, target_dir, commit=False):
    279     """Upload a new version and update the version file for the asset."""
    280     version = self.get_next_version()
    281     self._store.upload(self._name, version, target_dir)
    282 
    283     def _write_version():
    284       with open(self.version_file, 'w') as f:
    285         f.write(str(version))
    286       subprocess.check_call([utils.GIT, 'add', self.version_file])
    287 
    288     with utils.chdir(SKIA_DIR):
    289       if commit:
    290         with utils.git_branch():
    291           _write_version()
    292           subprocess.check_call([
    293               utils.GIT, 'commit', '-m', 'Update %s version' % self._name])
    294           subprocess.check_call([utils.GIT, 'cl', 'upload', '--bypass-hooks'])
    295       else:
    296         _write_version()
    297 
    298   @classmethod
    299   def add(cls, name, store):
    300     """Add an asset."""
    301     asset = cls(name, store)
    302     if os.path.isdir(asset._dir):
    303       raise Exception('Asset %s already exists!' % asset._name)
    304 
    305     print 'Creating asset in %s' % asset._dir
    306     os.mkdir(asset._dir)
    307     def copy_script(script):
    308       src = os.path.join(ASSETS_DIR, 'scripts', script)
    309       dst = os.path.join(asset._dir, script)
    310       print 'Creating %s' % dst
    311       shutil.copy(src, dst)
    312       subprocess.check_call([utils.GIT, 'add', dst])
    313 
    314     for script in ('download.py', 'upload.py', 'common.py'):
    315       copy_script(script)
    316     resp = _prompt('Add script to automate creation of this asset? (y/n) ')
    317     if resp == 'y':
    318       copy_script('create.py')
    319       copy_script('create_and_upload.py')
    320       print 'You will need to add implementation to the creation script.'
    321     print 'Successfully created asset %s.' % asset._name
    322     return asset
    323 
    324   def remove(self, remove_in_store=False):
    325     """Remove this asset."""
    326     # Ensure that the asset exists.
    327     if not os.path.isdir(self._dir):
    328       raise Exception('Asset %s does not exist!' % self._name)
    329 
    330     # Cleanup the store.
    331     if remove_in_store:
    332       self._store.delete_contents(self._name)
    333 
    334     # Remove the asset.
    335     subprocess.check_call([utils.GIT, 'rm', '-rf', self._dir])
    336     if os.path.isdir(self._dir):
    337       shutil.rmtree(self._dir)
    338