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