Chromium Code Reviews| 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 |