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