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 |