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