OLD | NEW |
---|---|
1 # Copyright 2013 The LUCI Authors. All rights reserved. | 1 # Copyright 2013 The LUCI Authors. All rights reserved. |
2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
4 | 4 |
5 """Set of functions to work with GAE SDK tools.""" | 5 """Set of functions to work with GAE SDK tools.""" |
6 | 6 |
7 import collections | 7 import collections |
8 import glob | 8 import glob |
9 import json | 9 import json |
10 import logging | 10 import logging |
(...skipping 15 matching lines...) Expand all Loading... | |
26 | 26 |
27 # Name of a directory with Go GAE SDK. | 27 # Name of a directory with Go GAE SDK. |
28 GO_GAE_SDK = 'go_appengine' | 28 GO_GAE_SDK = 'go_appengine' |
29 | 29 |
30 # Value of 'runtime: ...' in app.yaml -> SDK to use. | 30 # Value of 'runtime: ...' in app.yaml -> SDK to use. |
31 RUNTIME_TO_SDK = { | 31 RUNTIME_TO_SDK = { |
32 'go': GO_GAE_SDK, | 32 'go': GO_GAE_SDK, |
33 'python27': PYTHON_GAE_SDK, | 33 'python27': PYTHON_GAE_SDK, |
34 } | 34 } |
35 | 35 |
36 # Exe name => instructions how to install it. | |
37 KNOWN_TOOLS = { | |
38 'gcloud': | |
39 'Download and install the Google Cloud SDK from ' | |
40 'https://cloud.google.com/sdk/', | |
41 'aedeploy': | |
42 'Install with: go install ' | |
43 'google.golang.org/appengine/cmd/aedeploy', | |
44 } | |
45 | |
46 | |
47 # Path to a current SDK, set in setup_gae_sdk, accessible by gae_sdk_path. | 36 # Path to a current SDK, set in setup_gae_sdk, accessible by gae_sdk_path. |
48 _GAE_SDK_PATH = None | 37 _GAE_SDK_PATH = None |
49 | 38 |
50 | 39 |
51 class BadEnvironmentConfig(Exception): | 40 class Error(Exception): |
41 """Base class for a fatal error.""" | |
42 | |
43 | |
44 class BadEnvironmentError(Error): | |
52 """Raised when required tools or environment are missing.""" | 45 """Raised when required tools or environment are missing.""" |
53 | 46 |
54 | 47 |
48 class UnsupportedModuleError(Error): | |
49 """Raised when trying to deploy MVM or Flex module.""" | |
50 | |
51 | |
52 class LoginRequiredError(Error): | |
53 """Raised by Application methods if use has to go through login flow.""" | |
54 | |
55 | |
55 def find_gae_sdk(sdk_name=PYTHON_GAE_SDK, search_dir=TOOLS_DIR): | 56 def find_gae_sdk(sdk_name=PYTHON_GAE_SDK, search_dir=TOOLS_DIR): |
56 """Returns the path to GAE SDK if found, else None.""" | 57 """Returns the path to GAE SDK if found, else None.""" |
57 # First search up the directories up to root. | 58 # First search up the directories up to root. |
58 while True: | 59 while True: |
59 attempt = os.path.join(search_dir, sdk_name) | 60 attempt = os.path.join(search_dir, sdk_name) |
60 if os.path.isfile(os.path.join(attempt, 'dev_appserver.py')): | 61 if os.path.isfile(os.path.join(attempt, 'dev_appserver.py')): |
61 return attempt | 62 return attempt |
62 prev_dir = search_dir | 63 prev_dir = search_dir |
63 search_dir = os.path.dirname(search_dir) | 64 search_dir = os.path.dirname(search_dir) |
64 if search_dir == prev_dir: | 65 if search_dir == prev_dir: |
(...skipping 118 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
183 yaml = yaml_module | 184 yaml = yaml_module |
184 | 185 |
185 | 186 |
186 def gae_sdk_path(): | 187 def gae_sdk_path(): |
187 """Checks that 'setup_gae_sdk' was called and returns a path to GAE SDK.""" | 188 """Checks that 'setup_gae_sdk' was called and returns a path to GAE SDK.""" |
188 if not _GAE_SDK_PATH: | 189 if not _GAE_SDK_PATH: |
189 raise ValueError('setup_gae_sdk wasn\'t called') | 190 raise ValueError('setup_gae_sdk wasn\'t called') |
190 return _GAE_SDK_PATH | 191 return _GAE_SDK_PATH |
191 | 192 |
192 | 193 |
193 class LoginRequiredError(Exception): | |
194 """Raised by Application methods if use has to go through login flow.""" | |
195 | |
196 | |
197 ModuleFile = collections.namedtuple('ModuleFile', ['path', 'data']) | 194 ModuleFile = collections.namedtuple('ModuleFile', ['path', 'data']) |
198 | 195 |
199 | 196 |
200 class Application(object): | 197 class Application(object): |
201 """Configurable GAE application. | 198 """Configurable GAE application. |
202 | 199 |
203 Can be used to query and change GAE application configuration (default | 200 Can be used to query and change GAE application configuration (default |
204 serving version, uploaded versions, etc.). Built on top of appcfg.py calls. | 201 serving version, uploaded versions, etc.). Built on top of appcfg.py calls. |
205 """ | 202 """ |
206 | 203 |
(...skipping 69 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
276 cmd = [ | 273 cmd = [ |
277 sys.executable, | 274 sys.executable, |
278 os.path.join(gae_sdk_path(), 'appcfg.py'), | 275 os.path.join(gae_sdk_path(), 'appcfg.py'), |
279 '--application', self.app_id, | 276 '--application', self.app_id, |
280 '--noauth_local_webserver', | 277 '--noauth_local_webserver', |
281 'list_versions', | 278 'list_versions', |
282 ] | 279 ] |
283 return subprocess.call(cmd, cwd=self._app_dir) == 0 | 280 return subprocess.call(cmd, cwd=self._app_dir) == 0 |
284 | 281 |
285 def run_cmd(self, cmd, cwd=None): | 282 def run_cmd(self, cmd, cwd=None): |
286 """Runs subprocess, capturing the output.""" | 283 """Runs subprocess, capturing the output. |
284 | |
285 Doesn't close stdin, since gcloud may me asking for user input. If this is | |
Vadim Sh.
2017/05/23 19:36:11
this is actually needed for http://shortn/_h5Bh8n3
iannucci
2017/05/23 19:38:08
s/may me/may be
Vadim Sh.
2017/05/23 19:46:36
Done.
| |
286 undesirable (e.g when gae.py is used from scripts), close 'stdin' of gae.py | |
287 process itself. | |
288 """ | |
287 logging.debug('Running %s', cmd) | 289 logging.debug('Running %s', cmd) |
288 proc = subprocess.Popen( | 290 proc = subprocess.Popen( |
289 cmd, | 291 cmd, |
290 cwd=cwd or self._app_dir, | 292 cwd=cwd or self._app_dir, |
291 stdout=subprocess.PIPE, | 293 stdout=subprocess.PIPE) |
292 stdin=subprocess.PIPE) | 294 output, _ = proc.communicate() |
293 output, _ = proc.communicate(None) | |
294 if proc.returncode: | 295 if proc.returncode: |
295 sys.stderr.write('\n' + output + '\n') | 296 sys.stderr.write('\n' + output + '\n') |
296 raise subprocess.CalledProcessError(proc.returncode, cmd, output) | 297 raise subprocess.CalledProcessError(proc.returncode, cmd, output) |
297 return output | 298 return output |
298 | 299 |
299 def run_appcfg(self, args): | 300 def run_appcfg(self, args): |
300 """Runs appcfg.py <args>, deserializes its output and returns it.""" | 301 """Runs appcfg.py <args>, deserializes its output and returns it.""" |
301 if not is_oauth_token_cached(): | 302 if not is_oauth_token_cached(): |
302 raise LoginRequiredError('Login first using \'login\' subcommand.') | 303 raise LoginRequiredError('Login first using \'login\' subcommand.') |
303 cmd = [ | 304 cmd = [ |
304 sys.executable, | 305 sys.executable, |
305 os.path.join(gae_sdk_path(), 'appcfg.py'), | 306 os.path.join(gae_sdk_path(), 'appcfg.py'), |
306 '--application', self.app_id, | 307 '--application', self.app_id, |
307 ] | 308 ] |
308 if self._verbose: | 309 if self._verbose: |
309 cmd.append('--verbose') | 310 cmd.append('--verbose') |
310 cmd.extend(args) | 311 cmd.extend(args) |
311 return yaml.safe_load(self.run_cmd(cmd)) | 312 return yaml.safe_load(self.run_cmd(cmd)) |
312 | 313 |
313 def run_gcloud(self, args): | 314 def run_gcloud(self, args): |
314 """Runs gcloud <args>.""" | 315 """Runs gcloud <args>.""" |
316 gcloud = find_gcloud() | |
315 if not is_gcloud_oauth2_token_cached(): | 317 if not is_gcloud_oauth2_token_cached(): |
316 raise LoginRequiredError('Login first using \'gcloud auth login\'') | 318 raise LoginRequiredError('Login first using \'gcloud auth login\'') |
317 check_tool_in_path('gcloud') | 319 return self.run_cmd([gcloud] + args) |
318 return self.run_cmd(['gcloud'] + args) | |
319 | 320 |
320 def list_versions(self): | 321 def list_versions(self): |
321 """List all uploaded versions. | 322 """List all uploaded versions. |
322 | 323 |
323 Returns: | 324 Returns: |
324 Dict {module name -> [list of uploaded versions]}. | 325 Dict {module name -> [list of uploaded versions]}. |
325 """ | 326 """ |
326 return self.run_appcfg(['list_versions']) | 327 return self.run_appcfg(['list_versions']) |
327 | 328 |
328 def set_default_version(self, version, modules=None): | 329 def set_default_version(self, version, modules=None): |
(...skipping 13 matching lines...) Expand all Loading... | |
342 'delete_version', | 343 'delete_version', |
343 '--module', module, | 344 '--module', module, |
344 '--version', version, | 345 '--version', version, |
345 ]) | 346 ]) |
346 | 347 |
347 def update_modules(self, version, modules=None): | 348 def update_modules(self, version, modules=None): |
348 """Deploys new version of the given module names. | 349 """Deploys new version of the given module names. |
349 | 350 |
350 Supports deploying modules both with Managed VMs and AppEngine v1 runtime. | 351 Supports deploying modules both with Managed VMs and AppEngine v1 runtime. |
351 """ | 352 """ |
352 reg_modules = [] | 353 mods = [] |
353 mvm_modules = [] | |
354 try: | 354 try: |
355 for m in sorted(modules or self.modules): | 355 for m in sorted(modules or self.modules): |
356 mod = self._modules[m] | 356 mod = self._modules[m] |
357 if mod.data.get('vm'): | 357 if mod.data.get('vm'): |
358 mvm_modules.append(mod) | 358 raise UnsupportedModuleError('MVM is not supported: %s' % m) |
359 else: | 359 if mod.data.get('env') == 'flex': |
360 reg_modules.append(mod) | 360 raise UnsupportedModuleError('Flex is not supported yet: %s' % m) |
361 if mod.data.get('runtime') == 'go' and not os.environ.get('GOROOT'): | 361 if mod.data.get('runtime') == 'go' and not os.environ.get('GOROOT'): |
362 raise BadEnvironmentConfig('GOROOT must be set when deploying Go app') | 362 raise BadEnvironmentError('GOROOT must be set when deploying Go app') |
363 mods.append(mod) | |
363 except KeyError as e: | 364 except KeyError as e: |
364 raise ValueError('Unknown module: %s' % e) | 365 raise ValueError('Unknown module: %s' % e) |
365 if reg_modules: | 366 # Always make 'default' the first module to be uploaded. |
366 # Always make 'default' the first module to be uploaded. | 367 mods.sort(key=lambda x: '' if x == 'default' else x) |
367 reg_modules.sort(key=lambda x: '' if x == 'default' else x) | 368 self.run_appcfg( |
368 self.run_appcfg( | 369 ['update'] + [m.path for m in mods] + ['--version', version]) |
369 ['update'] + [m.path for m in reg_modules] + ['--version', version]) | |
370 # Go modules have to be deployed one at a time, based on docs. | |
371 for m in mvm_modules: | |
372 self.deploy_mvm_module(m, version) | |
373 | 370 |
374 def update_indexes(self): | 371 def update_indexes(self): |
375 """Deploys new index.yaml.""" | 372 """Deploys new index.yaml.""" |
376 if os.path.isfile(os.path.join(self.default_module_dir, 'index.yaml')): | 373 if os.path.isfile(os.path.join(self.default_module_dir, 'index.yaml')): |
377 self.run_appcfg(['update_indexes', self.default_module_dir]) | 374 self.run_appcfg(['update_indexes', self.default_module_dir]) |
378 | 375 |
379 def update_queues(self): | 376 def update_queues(self): |
380 """Deploys new queue.yaml.""" | 377 """Deploys new queue.yaml.""" |
381 if os.path.isfile(os.path.join(self.default_module_dir, 'queue.yaml')): | 378 if os.path.isfile(os.path.join(self.default_module_dir, 'queue.yaml')): |
382 self.run_appcfg(['update_queues', self.default_module_dir]) | 379 self.run_appcfg(['update_queues', self.default_module_dir]) |
(...skipping 68 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
451 | 448 |
452 # Sort by version number (best effort, nonconforming version names will | 449 # Sort by version number (best effort, nonconforming version names will |
453 # appear first in the list). | 450 # appear first in the list). |
454 def extract_version_num(version): | 451 def extract_version_num(version): |
455 try: | 452 try: |
456 return int(version.split('-', 1)[0]) | 453 return int(version.split('-', 1)[0]) |
457 except ValueError: | 454 except ValueError: |
458 return -1 | 455 return -1 |
459 return sorted(actual_versions, key=extract_version_num) | 456 return sorted(actual_versions, key=extract_version_num) |
460 | 457 |
461 def deploy_mvm_module(self, mod, version): | |
462 """Uses gcloud to upload MVM module using remote docker build. | |
463 | |
464 Assumes 'gcloud' and 'aedeploy' are in PATH. | |
465 """ | |
466 # 'aedeploy' requires cwd to be set to module path. | |
467 check_tool_in_path('gcloud') | |
468 cmd = [ | |
469 'gcloud', 'app', 'deploy', os.path.basename(mod.path), | |
470 '--project', self.app_id, | |
471 '--version', version, | |
472 '--docker-build', 'remote', | |
473 '--no-promote', '--force', | |
474 ] | |
475 if self._verbose: | |
476 cmd.extend(['--verbosity', 'debug']) | |
477 if mod.data.get('runtime') == 'go': | |
478 check_tool_in_path('aedeploy') | |
479 cmd = ['aedeploy'] + cmd | |
480 self.run_cmd(cmd, cwd=os.path.dirname(mod.path)) | |
481 | |
482 def get_actives(self, modules=None): | 458 def get_actives(self, modules=None): |
483 """Returns active version(s).""" | 459 """Returns active version(s).""" |
484 args = [ | 460 args = [ |
485 'app', 'versions', 'list', | 461 'app', 'versions', 'list', |
486 '--project', self.app_id, | 462 '--project', self.app_id, |
487 '--format', 'json', | 463 '--format', 'json', |
488 '--hide-no-traffic', | 464 '--hide-no-traffic', |
489 ] | 465 ] |
490 raw = self.run_gcloud(args) | 466 raw = self.run_gcloud(args) |
491 try: | 467 try: |
492 data = json.loads(raw) | 468 data = json.loads(raw) |
493 except ValueError: | 469 except ValueError: |
494 sys.stderr.write('Failed to decode %r as JSON\n' % raw) | 470 sys.stderr.write('Failed to decode %r as JSON\n' % raw) |
495 raise | 471 raise |
496 # TODO(maruel): Handle when traffic_split != 1.0. | 472 # TODO(maruel): Handle when traffic_split != 1.0. |
497 # TODO(maruel): There's a lot more data, decide what is generally useful in | 473 # TODO(maruel): There's a lot more data, decide what is generally useful in |
498 # there. | 474 # there. |
499 return [ | 475 return [ |
500 { | 476 { |
501 'creationTime': service['version']['createTime'], | 477 'creationTime': service['version']['createTime'], |
502 'deployer': service['version']['createdBy'], | 478 'deployer': service['version']['createdBy'], |
503 'id': service['id'], | 479 'id': service['id'], |
504 'service': service['service'], | 480 'service': service['service'], |
505 } for service in data if not modules or service['service'] in modules | 481 } for service in data if not modules or service['service'] in modules |
506 ] | 482 ] |
507 | 483 |
508 | 484 |
509 def check_tool_in_path(tool): | 485 def find_gcloud(): |
510 """Raises BadEnvironmentConfig error if no such executable in PATH.""" | 486 """Searches for 'gcloud' binary in PATH and returns absolute path to it. |
487 | |
488 Raises BadEnvironmentError error if it's not there. | |
489 """ | |
511 for path in os.environ['PATH'].split(os.pathsep): | 490 for path in os.environ['PATH'].split(os.pathsep): |
512 exe_file = os.path.join(path, tool) | 491 exe_file = os.path.join(path, 'gcloud') |
513 if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): | 492 if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): |
514 return | 493 return exe_file |
515 msg = 'Can\'t find "%s" in PATH.' % tool | 494 raise BadEnvironmentError( |
516 if tool in KNOWN_TOOLS: | 495 'Can\'t find "gcloud" in PATH. Install the Google Cloud SDK from ' |
517 msg += ' ' + KNOWN_TOOLS[tool] | 496 'https://cloud.google.com/sdk/') |
518 raise BadEnvironmentConfig(msg) | |
519 | 497 |
520 | 498 |
521 def setup_env(app_dir, app_id, version, module_id, remote_api=False): | 499 def setup_env(app_dir, app_id, version, module_id, remote_api=False): |
522 """Setups os.environ so GAE code works.""" | 500 """Setups os.environ so GAE code works.""" |
523 # GCS library behaves differently when running under remote_api. It uses | 501 # GCS library behaves differently when running under remote_api. It uses |
524 # SERVER_SOFTWARE to figure this out. See cloudstorage/common.py, local_run(). | 502 # SERVER_SOFTWARE to figure this out. See cloudstorage/common.py, local_run(). |
525 if remote_api: | 503 if remote_api: |
526 os.environ['SERVER_SOFTWARE'] = 'remote_api' | 504 os.environ['SERVER_SOFTWARE'] = 'remote_api' |
527 else: | 505 else: |
528 os.environ['SERVER_SOFTWARE'] = 'Development yo dawg/1.0' | 506 os.environ['SERVER_SOFTWARE'] = 'Development yo dawg/1.0' |
(...skipping 42 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
571 New instance of Application configured based on passed options. | 549 New instance of Application configured based on passed options. |
572 """ | 550 """ |
573 logging.basicConfig(level=logging.DEBUG if options.verbose else logging.ERROR) | 551 logging.basicConfig(level=logging.DEBUG if options.verbose else logging.ERROR) |
574 | 552 |
575 if not app_dir and not options.app_dir: | 553 if not app_dir and not options.app_dir: |
576 parser.error('--app-dir option is required') | 554 parser.error('--app-dir option is required') |
577 app_dir = os.path.abspath(app_dir or options.app_dir) | 555 app_dir = os.path.abspath(app_dir or options.app_dir) |
578 | 556 |
579 try: | 557 try: |
580 runtime = get_app_runtime(find_app_yamls(app_dir)) | 558 runtime = get_app_runtime(find_app_yamls(app_dir)) |
581 except (BadEnvironmentConfig, ValueError) as exc: | 559 except (Error, ValueError) as exc: |
582 parser.error(str(exc)) | 560 parser.error(str(exc)) |
583 | 561 |
584 sdk_path = options.sdk_path or find_gae_sdk(RUNTIME_TO_SDK[runtime], app_dir) | 562 sdk_path = options.sdk_path or find_gae_sdk(RUNTIME_TO_SDK[runtime], app_dir) |
585 if not sdk_path: | 563 if not sdk_path: |
586 parser.error('Failed to find the AppEngine SDK. Pass --sdk-path argument.') | 564 parser.error('Failed to find the AppEngine SDK. Pass --sdk-path argument.') |
587 | 565 |
588 setup_gae_sdk(sdk_path) | 566 setup_gae_sdk(sdk_path) |
589 | 567 |
590 try: | 568 try: |
591 return Application(app_dir, options.app_id, options.verbose) | 569 return Application(app_dir, options.app_id, options.verbose) |
592 except (BadEnvironmentConfig, ValueError) as e: | 570 except (Error, ValueError) as e: |
593 parser.error(str(e)) | 571 parser.error(str(e)) |
594 | 572 |
595 | 573 |
596 def confirm(text, app, version, modules=None, default_yes=False): | 574 def confirm(text, app, version, modules=None, default_yes=False): |
597 """Asks a user to confirm the action related to GAE app. | 575 """Asks a user to confirm the action related to GAE app. |
598 | 576 |
599 Args: | 577 Args: |
600 text: actual text of the prompt. | 578 text: actual text of the prompt. |
601 app: instance of Application. | 579 app: instance of Application. |
602 version: version or a list of versions to operate upon. | 580 version: version or a list of versions to operate upon. |
(...skipping 26 matching lines...) Expand all Loading... | |
629 with open(p) as f: | 607 with open(p) as f: |
630 return len(json.load(f)['data']) != 0 | 608 return len(json.load(f)['data']) != 0 |
631 except (KeyError, IOError, OSError, ValueError): | 609 except (KeyError, IOError, OSError, ValueError): |
632 return False | 610 return False |
633 | 611 |
634 | 612 |
635 def setup_gae_env(): | 613 def setup_gae_env(): |
636 """Sets up App Engine Python test environment.""" | 614 """Sets up App Engine Python test environment.""" |
637 sdk_path = find_gae_sdk(PYTHON_GAE_SDK) | 615 sdk_path = find_gae_sdk(PYTHON_GAE_SDK) |
638 if not sdk_path: | 616 if not sdk_path: |
639 raise BadEnvironmentConfig('Couldn\'t find GAE SDK.') | 617 raise BadEnvironmentError('Couldn\'t find GAE SDK.') |
640 setup_gae_sdk(sdk_path) | 618 setup_gae_sdk(sdk_path) |
OLD | NEW |