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