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-buildbots' 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 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 '-verification-timeout', '30m0s', 127 ]) 128 129 def download(self, name, version, target_dir): 130 """Download a CIPD package.""" 131 pkg_name = CIPD_PACKAGE_NAME_TMPL % name 132 version_tag = TAG_VERSION_TMPL % version 133 target_dir = os.path.abspath(target_dir) 134 with utils.tmp_dir(): 135 infile = os.path.join(os.getcwd(), 'input') 136 with open(infile, 'w') as f: 137 f.write('%s %s' % (pkg_name, version_tag)) 138 self._run([ 139 'ensure', 140 '--root', target_dir, 141 '--list', infile, 142 ]) 143 144 def delete_contents(self, name): 145 """Delete data for the given asset.""" 146 self._run(['pkg-delete', CIPD_PACKAGE_NAME_TMPL % name]) 147 148 149class GSStore(object): 150 """Wrapper object for interacting with Google Storage.""" 151 def __init__(self, gsutil=None, bucket=DEFAULT_GS_BUCKET): 152 if gsutil: 153 gsutil = os.path.abspath(gsutil) 154 else: 155 gsutil = subprocess.check_output([WHICH, 'gsutil']).rstrip() 156 self._gsutil = [gsutil] 157 if gsutil.endswith('.py'): 158 self._gsutil = ['python', gsutil] 159 self._gs_bucket = bucket 160 161 def copy(self, src, dst): 162 """Copy src to dst.""" 163 subprocess.check_call(self._gsutil + ['cp', src, dst]) 164 165 def list(self, path): 166 """List objects in the given path.""" 167 try: 168 return subprocess.check_output(self._gsutil + ['ls', path]).splitlines() 169 except subprocess.CalledProcessError: 170 # If the prefix does not exist, we'll get an error, which is okay. 171 return [] 172 173 def get_available_versions(self, name): 174 """Return the existing version numbers for the asset.""" 175 files = self.list(GS_SUBDIR_TMPL % (self._gs_bucket, name)) 176 bnames = [os.path.basename(f) for f in files] 177 suffix = '.zip' 178 versions = [int(f[:-len(suffix)]) for f in bnames if f.endswith(suffix)] 179 versions.sort() 180 return versions 181 182 def upload(self, name, version, target_dir): 183 """Upload to GS.""" 184 target_dir = os.path.abspath(target_dir) 185 with utils.tmp_dir(): 186 zip_file = os.path.join(os.getcwd(), '%d.zip' % version) 187 zip_utils.zip(target_dir, zip_file, blacklist=ZIP_BLACKLIST) 188 gs_path = GS_PATH_TMPL % (GS_SUBDIR_TMPL % (self._gs_bucket, name), 189 str(version)) 190 self.copy(zip_file, gs_path) 191 192 def download(self, name, version, target_dir): 193 """Download from GS.""" 194 gs_path = GS_PATH_TMPL % (GS_SUBDIR_TMPL % (self._gs_bucket, name), 195 str(version)) 196 target_dir = os.path.abspath(target_dir) 197 with utils.tmp_dir(): 198 zip_file = os.path.join(os.getcwd(), '%d.zip' % version) 199 self.copy(gs_path, zip_file) 200 zip_utils.unzip(zip_file, target_dir) 201 202 def delete_contents(self, name): 203 """Delete data for the given asset.""" 204 gs_path = GS_SUBDIR_TMPL % (self._gs_bucket, name) 205 attempt_delete = True 206 try: 207 subprocess.check_call(self._gsutil + ['ls', gs_path]) 208 except subprocess.CalledProcessError: 209 attempt_delete = False 210 if attempt_delete: 211 subprocess.check_call(self._gsutil + ['rm', '-rf', gs_path]) 212 213 214class MultiStore(object): 215 """Wrapper object which uses CIPD as the primary store and GS for backup.""" 216 def __init__(self, cipd_url=DEFAULT_CIPD_SERVICE_URL, 217 gsutil=None, gs_bucket=DEFAULT_GS_BUCKET): 218 self._cipd = CIPDStore(cipd_url=cipd_url) 219 self._gs = GSStore(gsutil=gsutil, bucket=gs_bucket) 220 221 def get_available_versions(self, name): 222 return self._cipd.get_available_versions(name) 223 224 def upload(self, name, version, target_dir): 225 self._cipd.upload(name, version, target_dir) 226 self._gs.upload(name, version, target_dir) 227 228 def download(self, name, version, target_dir): 229 self._gs.download(name, version, target_dir) 230 231 def delete_contents(self, name): 232 self._cipd.delete_contents(name) 233 self._gs.delete_contents(name) 234 235 236def _prompt(prompt): 237 """Prompt for input, return result.""" 238 return raw_input(prompt) 239 240 241class Asset(object): 242 def __init__(self, name, store): 243 self._store = store 244 self._name = name 245 self._dir = os.path.join(ASSETS_DIR, self._name) 246 247 @property 248 def version_file(self): 249 """Return the path to the version file for this asset.""" 250 return os.path.join(self._dir, VERSION_FILENAME) 251 252 def get_current_version(self): 253 """Obtain the current version of the asset.""" 254 if not os.path.isfile(self.version_file): 255 return -1 256 with open(self.version_file) as f: 257 return int(f.read()) 258 259 def get_available_versions(self): 260 """Return the existing version numbers for this asset.""" 261 return self._store.get_available_versions(self._name) 262 263 def get_next_version(self): 264 """Find the next available version number for the asset.""" 265 versions = self.get_available_versions() 266 if len(versions) == 0: 267 return 0 268 return versions[-1] + 1 269 270 def download_version(self, version, target_dir): 271 """Download the specified version of the asset.""" 272 self._store.download(self._name, version, target_dir) 273 274 def download_current_version(self, target_dir): 275 """Download the version of the asset specified in its version file.""" 276 v = self.get_current_version() 277 self.download_version(v, target_dir) 278 279 def upload_new_version(self, target_dir, commit=False): 280 """Upload a new version and update the version file for the asset.""" 281 version = self.get_next_version() 282 self._store.upload(self._name, version, target_dir) 283 284 def _write_version(): 285 with open(self.version_file, 'w') as f: 286 f.write(str(version)) 287 subprocess.check_call([utils.GIT, 'add', self.version_file]) 288 289 with utils.chdir(SKIA_DIR): 290 if commit: 291 with utils.git_branch(): 292 _write_version() 293 subprocess.check_call([ 294 utils.GIT, 'commit', '-m', 'Update %s version' % self._name]) 295 subprocess.check_call([utils.GIT, 'cl', 'upload', '--bypass-hooks']) 296 else: 297 _write_version() 298 299 @classmethod 300 def add(cls, name, store): 301 """Add an asset.""" 302 asset = cls(name, store) 303 if os.path.isdir(asset._dir): 304 raise Exception('Asset %s already exists!' % asset._name) 305 306 print 'Creating asset in %s' % asset._dir 307 os.mkdir(asset._dir) 308 def copy_script(script): 309 src = os.path.join(ASSETS_DIR, 'scripts', script) 310 dst = os.path.join(asset._dir, script) 311 print 'Creating %s' % dst 312 shutil.copy(src, dst) 313 subprocess.check_call([utils.GIT, 'add', dst]) 314 315 for script in ('download.py', 'upload.py', 'common.py'): 316 copy_script(script) 317 resp = _prompt('Add script to automate creation of this asset? (y/n) ') 318 if resp == 'y': 319 copy_script('create.py') 320 copy_script('create_and_upload.py') 321 print 'You will need to add implementation to the creation script.' 322 print 'Successfully created asset %s.' % asset._name 323 return asset 324 325 def remove(self, remove_in_store=False): 326 """Remove this asset.""" 327 # Ensure that the asset exists. 328 if not os.path.isdir(self._dir): 329 raise Exception('Asset %s does not exist!' % self._name) 330 331 # Cleanup the store. 332 if remove_in_store: 333 self._store.delete_contents(self._name) 334 335 # Remove the asset. 336 subprocess.check_call([utils.GIT, 'rm', '-rf', self._dir]) 337 if os.path.isdir(self._dir): 338 shutil.rmtree(self._dir) 339