| 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 |
| 56 def find_gcloud(): |
| 57 """Searches for 'gcloud' binary in PATH and returns absolute path to it. |
| 58 |
| 59 Raises BadEnvironmentError error if it's not there. |
| 60 """ |
| 61 for path in os.environ['PATH'].split(os.pathsep): |
| 62 exe_file = os.path.join(path, 'gcloud') |
| 63 if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): |
| 64 return exe_file |
| 65 raise BadEnvironmentError( |
| 66 'Can\'t find "gcloud" in PATH. Install the Google Cloud SDK from ' |
| 67 'https://cloud.google.com/sdk/') |
| 68 |
| 69 |
| 55 def find_gae_sdk(sdk_name=PYTHON_GAE_SDK, search_dir=TOOLS_DIR): | 70 def find_gae_sdk(sdk_name=PYTHON_GAE_SDK, search_dir=TOOLS_DIR): |
| 56 """Returns the path to GAE SDK if found, else None.""" | 71 """Returns the path to GAE SDK if found, else None.""" |
| 57 # First search up the directories up to root. | 72 # First search up the directories up to root. |
| 58 while True: | 73 while True: |
| 59 attempt = os.path.join(search_dir, sdk_name) | 74 attempt = os.path.join(search_dir, sdk_name) |
| 60 if os.path.isfile(os.path.join(attempt, 'dev_appserver.py')): | 75 if os.path.isfile(os.path.join(attempt, 'dev_appserver.py')): |
| 61 return attempt | 76 return attempt |
| 62 prev_dir = search_dir | 77 prev_dir = search_dir |
| 63 search_dir = os.path.dirname(search_dir) | 78 search_dir = os.path.dirname(search_dir) |
| 64 if search_dir == prev_dir: | 79 if search_dir == prev_dir: |
| (...skipping 118 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 183 yaml = yaml_module | 198 yaml = yaml_module |
| 184 | 199 |
| 185 | 200 |
| 186 def gae_sdk_path(): | 201 def gae_sdk_path(): |
| 187 """Checks that 'setup_gae_sdk' was called and returns a path to GAE SDK.""" | 202 """Checks that 'setup_gae_sdk' was called and returns a path to GAE SDK.""" |
| 188 if not _GAE_SDK_PATH: | 203 if not _GAE_SDK_PATH: |
| 189 raise ValueError('setup_gae_sdk wasn\'t called') | 204 raise ValueError('setup_gae_sdk wasn\'t called') |
| 190 return _GAE_SDK_PATH | 205 return _GAE_SDK_PATH |
| 191 | 206 |
| 192 | 207 |
| 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']) | 208 ModuleFile = collections.namedtuple('ModuleFile', ['path', 'data']) |
| 198 | 209 |
| 199 | 210 |
| 200 class Application(object): | 211 class Application(object): |
| 201 """Configurable GAE application. | 212 """Configurable GAE application. |
| 202 | 213 |
| 203 Can be used to query and change GAE application configuration (default | 214 Can be used to query and change GAE application configuration (default |
| 204 serving version, uploaded versions, etc.). Built on top of appcfg.py calls. | 215 serving version, uploaded versions, etc.). Built on top of appcfg.py calls. |
| 205 """ | 216 """ |
| 206 | 217 |
| (...skipping 69 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 276 cmd = [ | 287 cmd = [ |
| 277 sys.executable, | 288 sys.executable, |
| 278 os.path.join(gae_sdk_path(), 'appcfg.py'), | 289 os.path.join(gae_sdk_path(), 'appcfg.py'), |
| 279 '--application', self.app_id, | 290 '--application', self.app_id, |
| 280 '--noauth_local_webserver', | 291 '--noauth_local_webserver', |
| 281 'list_versions', | 292 'list_versions', |
| 282 ] | 293 ] |
| 283 return subprocess.call(cmd, cwd=self._app_dir) == 0 | 294 return subprocess.call(cmd, cwd=self._app_dir) == 0 |
| 284 | 295 |
| 285 def run_cmd(self, cmd, cwd=None): | 296 def run_cmd(self, cmd, cwd=None): |
| 286 """Runs subprocess, capturing the output.""" | 297 """Runs subprocess, capturing the output. |
| 298 |
| 299 Doesn't close stdin, since gcloud may be asking for user input. If this is |
| 300 undesirable (e.g when gae.py is used from scripts), close 'stdin' of gae.py |
| 301 process itself. |
| 302 """ |
| 287 logging.debug('Running %s', cmd) | 303 logging.debug('Running %s', cmd) |
| 288 proc = subprocess.Popen( | 304 proc = subprocess.Popen( |
| 289 cmd, | 305 cmd, |
| 290 cwd=cwd or self._app_dir, | 306 cwd=cwd or self._app_dir, |
| 291 stdout=subprocess.PIPE, | 307 stdout=subprocess.PIPE) |
| 292 stdin=subprocess.PIPE) | 308 output, _ = proc.communicate() |
| 293 output, _ = proc.communicate(None) | |
| 294 if proc.returncode: | 309 if proc.returncode: |
| 295 sys.stderr.write('\n' + output + '\n') | 310 sys.stderr.write('\n' + output + '\n') |
| 296 raise subprocess.CalledProcessError(proc.returncode, cmd, output) | 311 raise subprocess.CalledProcessError(proc.returncode, cmd, output) |
| 297 return output | 312 return output |
| 298 | 313 |
| 299 def run_appcfg(self, args): | 314 def run_appcfg(self, args): |
| 300 """Runs appcfg.py <args>, deserializes its output and returns it.""" | 315 """Runs appcfg.py <args>, deserializes its output and returns it.""" |
| 301 if not is_oauth_token_cached(): | 316 if not is_oauth_token_cached(): |
| 302 raise LoginRequiredError('Login first using \'login\' subcommand.') | 317 raise LoginRequiredError('Login first using \'login\' subcommand.') |
| 303 cmd = [ | 318 cmd = [ |
| 304 sys.executable, | 319 sys.executable, |
| 305 os.path.join(gae_sdk_path(), 'appcfg.py'), | 320 os.path.join(gae_sdk_path(), 'appcfg.py'), |
| 306 '--application', self.app_id, | 321 '--application', self.app_id, |
| 307 ] | 322 ] |
| 308 if self._verbose: | 323 if self._verbose: |
| 309 cmd.append('--verbose') | 324 cmd.append('--verbose') |
| 310 cmd.extend(args) | 325 cmd.extend(args) |
| 311 return yaml.safe_load(self.run_cmd(cmd)) | 326 return yaml.safe_load(self.run_cmd(cmd)) |
| 312 | 327 |
| 313 def run_gcloud(self, args): | 328 def run_gcloud(self, args): |
| 314 """Runs gcloud <args>.""" | 329 """Runs gcloud <args>.""" |
| 330 gcloud = find_gcloud() |
| 315 if not is_gcloud_oauth2_token_cached(): | 331 if not is_gcloud_oauth2_token_cached(): |
| 316 raise LoginRequiredError('Login first using \'gcloud auth login\'') | 332 raise LoginRequiredError('Login first using \'gcloud auth login\'') |
| 317 check_tool_in_path('gcloud') | 333 return self.run_cmd([gcloud] + args) |
| 318 return self.run_cmd(['gcloud'] + args) | |
| 319 | 334 |
| 320 def list_versions(self): | 335 def list_versions(self): |
| 321 """List all uploaded versions. | 336 """List all uploaded versions. |
| 322 | 337 |
| 323 Returns: | 338 Returns: |
| 324 Dict {module name -> [list of uploaded versions]}. | 339 Dict {module name -> [list of uploaded versions]}. |
| 325 """ | 340 """ |
| 326 return self.run_appcfg(['list_versions']) | 341 return self.run_appcfg(['list_versions']) |
| 327 | 342 |
| 328 def set_default_version(self, version, modules=None): | 343 def set_default_version(self, version, modules=None): |
| (...skipping 13 matching lines...) Expand all Loading... |
| 342 'delete_version', | 357 'delete_version', |
| 343 '--module', module, | 358 '--module', module, |
| 344 '--version', version, | 359 '--version', version, |
| 345 ]) | 360 ]) |
| 346 | 361 |
| 347 def update_modules(self, version, modules=None): | 362 def update_modules(self, version, modules=None): |
| 348 """Deploys new version of the given module names. | 363 """Deploys new version of the given module names. |
| 349 | 364 |
| 350 Supports deploying modules both with Managed VMs and AppEngine v1 runtime. | 365 Supports deploying modules both with Managed VMs and AppEngine v1 runtime. |
| 351 """ | 366 """ |
| 352 reg_modules = [] | 367 mods = [] |
| 353 mvm_modules = [] | |
| 354 try: | 368 try: |
| 355 for m in sorted(modules or self.modules): | 369 for m in sorted(modules or self.modules): |
| 356 mod = self._modules[m] | 370 mod = self._modules[m] |
| 357 if mod.data.get('vm'): | 371 if mod.data.get('vm'): |
| 358 mvm_modules.append(mod) | 372 raise UnsupportedModuleError('MVM is not supported: %s' % m) |
| 359 else: | 373 if mod.data.get('env') == 'flex': |
| 360 reg_modules.append(mod) | 374 raise UnsupportedModuleError('Flex is not supported yet: %s' % m) |
| 361 if mod.data.get('runtime') == 'go' and not os.environ.get('GOROOT'): | 375 if mod.data.get('runtime') == 'go' and not os.environ.get('GOROOT'): |
| 362 raise BadEnvironmentConfig('GOROOT must be set when deploying Go app') | 376 raise BadEnvironmentError('GOROOT must be set when deploying Go app') |
| 377 mods.append(mod) |
| 363 except KeyError as e: | 378 except KeyError as e: |
| 364 raise ValueError('Unknown module: %s' % e) | 379 raise ValueError('Unknown module: %s' % e) |
| 365 if reg_modules: | 380 # Always make 'default' the first module to be uploaded. |
| 366 # Always make 'default' the first module to be uploaded. | 381 mods.sort(key=lambda x: '' if x == 'default' else x) |
| 367 reg_modules.sort(key=lambda x: '' if x == 'default' else x) | 382 self.run_appcfg( |
| 368 self.run_appcfg( | 383 ['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 | 384 |
| 374 def update_indexes(self): | 385 def update_indexes(self): |
| 375 """Deploys new index.yaml.""" | 386 """Deploys new index.yaml.""" |
| 376 if os.path.isfile(os.path.join(self.default_module_dir, 'index.yaml')): | 387 if os.path.isfile(os.path.join(self.default_module_dir, 'index.yaml')): |
| 377 self.run_appcfg(['update_indexes', self.default_module_dir]) | 388 self.run_appcfg(['update_indexes', self.default_module_dir]) |
| 378 | 389 |
| 379 def update_queues(self): | 390 def update_queues(self): |
| 380 """Deploys new queue.yaml.""" | 391 """Deploys new queue.yaml.""" |
| 381 if os.path.isfile(os.path.join(self.default_module_dir, 'queue.yaml')): | 392 if os.path.isfile(os.path.join(self.default_module_dir, 'queue.yaml')): |
| 382 self.run_appcfg(['update_queues', self.default_module_dir]) | 393 self.run_appcfg(['update_queues', self.default_module_dir]) |
| (...skipping 68 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 451 | 462 |
| 452 # Sort by version number (best effort, nonconforming version names will | 463 # Sort by version number (best effort, nonconforming version names will |
| 453 # appear first in the list). | 464 # appear first in the list). |
| 454 def extract_version_num(version): | 465 def extract_version_num(version): |
| 455 try: | 466 try: |
| 456 return int(version.split('-', 1)[0]) | 467 return int(version.split('-', 1)[0]) |
| 457 except ValueError: | 468 except ValueError: |
| 458 return -1 | 469 return -1 |
| 459 return sorted(actual_versions, key=extract_version_num) | 470 return sorted(actual_versions, key=extract_version_num) |
| 460 | 471 |
| 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): | 472 def get_actives(self, modules=None): |
| 483 """Returns active version(s).""" | 473 """Returns active version(s).""" |
| 484 args = [ | 474 args = [ |
| 485 'app', 'versions', 'list', | 475 'app', 'versions', 'list', |
| 486 '--project', self.app_id, | 476 '--project', self.app_id, |
| 487 '--format', 'json', | 477 '--format', 'json', |
| 488 '--hide-no-traffic', | 478 '--hide-no-traffic', |
| 489 ] | 479 ] |
| 490 raw = self.run_gcloud(args) | 480 raw = self.run_gcloud(args) |
| 491 try: | 481 try: |
| 492 data = json.loads(raw) | 482 data = json.loads(raw) |
| 493 except ValueError: | 483 except ValueError: |
| 494 sys.stderr.write('Failed to decode %r as JSON\n' % raw) | 484 sys.stderr.write('Failed to decode %r as JSON\n' % raw) |
| 495 raise | 485 raise |
| 496 # TODO(maruel): Handle when traffic_split != 1.0. | 486 # TODO(maruel): Handle when traffic_split != 1.0. |
| 497 # TODO(maruel): There's a lot more data, decide what is generally useful in | 487 # TODO(maruel): There's a lot more data, decide what is generally useful in |
| 498 # there. | 488 # there. |
| 499 return [ | 489 return [ |
| 500 { | 490 { |
| 501 'creationTime': service['version']['createTime'], | 491 'creationTime': service['version']['createTime'], |
| 502 'deployer': service['version']['createdBy'], | 492 'deployer': service['version']['createdBy'], |
| 503 'id': service['id'], | 493 'id': service['id'], |
| 504 'service': service['service'], | 494 'service': service['service'], |
| 505 } for service in data if not modules or service['service'] in modules | 495 } for service in data if not modules or service['service'] in modules |
| 506 ] | 496 ] |
| 507 | 497 |
| 508 | 498 |
| 509 def check_tool_in_path(tool): | |
| 510 """Raises BadEnvironmentConfig error if no such executable in PATH.""" | |
| 511 for path in os.environ['PATH'].split(os.pathsep): | |
| 512 exe_file = os.path.join(path, tool) | |
| 513 if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): | |
| 514 return | |
| 515 msg = 'Can\'t find "%s" in PATH.' % tool | |
| 516 if tool in KNOWN_TOOLS: | |
| 517 msg += ' ' + KNOWN_TOOLS[tool] | |
| 518 raise BadEnvironmentConfig(msg) | |
| 519 | |
| 520 | |
| 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' |
| 529 if app_dir: | 507 if app_dir: |
| 530 app_id = app_id or Application(app_dir).app_id | 508 app_id = app_id or Application(app_dir).app_id |
| (...skipping 40 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 |