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 |