1# -*- coding: utf-8 -*- # 2# Copyright 2019 Google LLC. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15"""Configure build and deploy to Google Kubernetes Engine command.""" 16 17from __future__ import absolute_import 18from __future__ import division 19from __future__ import unicode_literals 20 21import re 22 23from apitools.base.py.exceptions import HttpNotFoundError 24from googlecloudsdk.api_lib.cloudbuild import cloudbuild_util 25from googlecloudsdk.api_lib.source import sourcerepo 26from googlecloudsdk.api_lib.storage import storage_api 27from googlecloudsdk.api_lib.util import apis 28from googlecloudsdk.calliope import actions 29from googlecloudsdk.calliope import base 30from googlecloudsdk.calliope import exceptions as c_exceptions 31from googlecloudsdk.command_lib.builds import staging_bucket_util 32from googlecloudsdk.command_lib.builds.deploy import build_util 33from googlecloudsdk.core import exceptions as core_exceptions 34from googlecloudsdk.core import log 35from googlecloudsdk.core import properties 36from googlecloudsdk.core import resources 37 38import six 39 40_CLEAN_PREVIEW_SCHEDULE_HOUR = 12 41_CLEAN_PREVIEW_SCHEDULE = '0 {} * * *'.format(_CLEAN_PREVIEW_SCHEDULE_HOUR) 42_REPO_TYPE_CODES = { 43 'github': 'ga', 44 'bitbucket_mirrored': 'bm', 45 'github_mirrored': 'gm', 46 'csr': 'cs' 47} 48 49 50class SourceRepoNotConnectedException(c_exceptions.InvalidArgumentException): 51 """Exception for when a third party CSR repo is not found. 52 53 This should not be used for regular CSR repos since they are connected by 54 default. 55 """ 56 57 def __init__(self, parameter_name, message): 58 super(SourceRepoNotConnectedException, self).__init__( 59 parameter_name, 60 '{message}\n\n' 61 'Visit https://console.cloud.google.com/cloud-build/triggers/connect?project={project} ' 62 'to connect a repository to your project.'.format( 63 message=message, 64 project=properties.VALUES.core.project.Get(required=True), 65 )) 66 67 68class ConfigureGKEDeploy(base.Command): 69 """Configure automated build and deployment to a target Google Kubernetes Engine cluster. 70 71 Configure automated build and deployment from a repository. This can be 72 triggered by a Git branch or tag push. 73 """ 74 75 @staticmethod 76 def Args(parser): 77 """Register flags for this command. 78 79 Args: 80 parser: An argparse.ArgumentParser-like object. It is mocked out in order 81 to capture some information, but behaves like an ArgumentParser. 82 """ 83 parser.add_argument( 84 '--repo-type', 85 help=""" 86 Type of repository. 87 88 `--repo-owner` must be provided if one of the following choices is 89 selected: 90 91 `github` - A GitHub (Cloud Build GitHub App) repository connected to 92 Cloud Build triggers. The deployed image will have the format 93 'gcr.io/[PROJECT_ID]/github.com/[REPO_OWNER]/[REPO_NAME]:$COMMIT_SHA'. 94 95 `bitbucket_mirrored` - A Bitbucket repository connected to Cloud Source 96 Repositories. The deployed image will have the format 97 'gcr.io/[PROJECT_ID]/bitbucket.org/[REPO_OWNER]/[REPO_NAME]:$COMMIT_SHA'. 98 99 `github_mirrored` - A GitHub repository connected to Cloud Source 100 Repositories. The deployed image will have the format 101 'gcr.io/[PROJECT_ID]/github.com/[REPO_OWNER]/[REPO_NAME]:$COMMIT_SHA'. 102 103 `--repo-owner` must not be provided if the following is selected: 104 105 `csr` - A repository on Cloud Source Repositories. The deployed image 106 will have the format 'gcr.io/[PROJECT_ID]/[REPO_NAME]:$COMMIT_SHA'. 107 108 Connect repositories at 109 https://console.cloud.google.com/cloud-build/triggers/connect. 110 """, 111 choices=['github', 'bitbucket_mirrored', 'github_mirrored', 'csr'], 112 required=True) 113 parser.add_argument( 114 '--repo-name', 115 help='Name of the repository.', 116 required=True) 117 parser.add_argument( 118 '--repo-owner', 119 help='Owner of the repository.') 120 parser.add_argument( 121 '--dockerfile', 122 help=""" 123 Path to the Dockerfile to build from, relative to the repository. 124 125 Defaults to './Dockerfile'. 126 """) 127 trigger_match = parser.add_mutually_exclusive_group(required=True) 128 trigger_match.add_argument( 129 '--branch-pattern', 130 metavar='REGEX', 131 help=''' 132 A regular expression specifying which Git branches to match. 133 134 This pattern is used as a regex search for any incoming pushes. For 135 example, --branch-pattern=foo will match "foo", "foobar", and "barfoo". 136 Events on a branch that does not match will be ignored. 137 138 The syntax of the regular expressions accepted is the syntax accepted by 139 RE2 and described at https://github.com/google/re2/wiki/Syntax. 140 ''') 141 trigger_match.add_argument( 142 '--tag-pattern', 143 metavar='REGEX', 144 help=''' 145 A regular expression specifying which Git tags to match. 146 147 This pattern is used as a regex search for any incoming pushes. For 148 example, --tag-pattern=foo will match "foo", "foobar", and "barfoo". 149 Events on a tag that does not match will be ignored. 150 151 The syntax of the regular expressions accepted is the syntax accepted by 152 RE2 and described at https://github.com/google/re2/wiki/Syntax. 153 ''') 154 pr_preview = trigger_match.add_argument_group( 155 help='Pull request preview deployment settings') 156 pr_preview.add_argument( 157 '--pull-request-preview', 158 help=''' 159 Enables previewing your application for each pull request. 160 161 This configures your application to deploy to a target cluster when 162 a pull request is created or updated against a branch specified by the 163 `--pull-request-pattern` argument. The application will be deployed 164 to the namespace 'preview-[REPO_NAME]-[PR_NUMBER]'. This namespace will 165 be deleted after a number of days specified by the `--preview-expiry` 166 argument. 167 168 The deployed preview application will still exist even after the pull 169 request is merged or closed. The preview application will eventually get 170 cleaned up by a Cloud Scheduler job after the namespace expires. You can 171 also delete the namespace manually. 172 ''', 173 action='store_true', 174 required=True 175 ) 176 pr_preview.add_argument( 177 '--preview-expiry', 178 type=int, 179 default=3, 180 help=''' 181 Number of days before a pull request preview deployment's namespace is 182 considered to be expired. An expired namespace will eventually be 183 deleted. Defaults to 3 days. 184 ''' 185 ) 186 pr_preview.add_argument( 187 '--pull-request-pattern', 188 metavar='REGEX', 189 help=""" 190 A regular expression specifying which base Git branch to match for 191 pull request events. 192 193 This pattern is used as a regex search for the base branch (the branch 194 you are trying to merge into) for pull request updates. For example, 195 --pull-request-pattern=foo will match "foo", "foobar", and "barfoo". 196 197 The syntax of the regular expressions accepted is the syntax accepted by 198 RE2 and described at https://github.com/google/re2/wiki/Syntax. 199 """, 200 required=True 201 ) 202 pr_preview.add_argument( 203 '--comment-control', 204 help="Require a repo collaborator to add '/gcbrun' as a comment in the " 205 'pull request in order to run the build.', 206 action='store_true' 207 ) 208 parser.add_argument( 209 '--gcs-config-staging-dir', 210 help=""" 211 Path to the Google Cloud Storage subdirectory into which to copy the 212 configs (suggested base and expanded Kubernetes YAML files) that are 213 used to stage and deploy your app. If the bucket in this path doesn't 214 exist, Cloud Build creates it. 215 216 If this field is not set, the configs are written to 217 ```gs://[PROJECT_ID]_cloudbuild/deploy/config```. 218 """) 219 parser.add_argument( 220 '--app-name', 221 help='If specified, the following label is added to the Kubernetes ' 222 "manifests: 'app.kubernetes.io/name: APP_NAME'. Defaults to the " 223 'repository name provided by `--repo-name`.') 224 parser.add_argument( 225 '--cluster', 226 help='Name of the target cluster to deploy to.', 227 required=True) 228 parser.add_argument( 229 '--location', 230 help='Region or zone of the target cluster to deploy to.', 231 required=True) 232 parser.add_argument( 233 '--namespace', 234 help='Namespace of the target cluster to deploy to. If this field is ' 235 "not set, the 'default' namespace is used.") 236 parser.add_argument( 237 '--config', 238 help=""" 239 Path to the Kubernetes YAML, or directory containing multiple 240 Kubernetes YAML files, used to deploy the container image. The path is 241 relative to the repository root. The files must reference the provided 242 container image or tag. 243 244 If this field is not set, a default Deployment config and Horizontal 245 Pod Autoscaler config are used to deploy the image. 246 """) 247 parser.add_argument( 248 '--expose', 249 type=int, 250 help='Port that the deployed application listens on. If set, a ' 251 "Kubernetes Service of type 'LoadBalancer' is created with a " 252 'single TCP port mapping that exposes this port.') 253 parser.add_argument( 254 '--timeout', 255 help='Maximum time a build is run before it times out. For example, ' 256 '"2h15m5s" is two hours, fifteen minutes, and five seconds. If you ' 257 'do not specify a unit, seconds is assumed. Overrides the default ' 258 'builds/timeout property value for this command invocation.', 259 action=actions.StoreProperty(properties.VALUES.builds.timeout)) 260 261 def Run(self, args): 262 """This is what gets called when the user runs this command. 263 264 Args: 265 args: an argparse namespace. All the arguments that were provided to this 266 command invocation. 267 268 Returns: 269 Some value that we want to have printed later. 270 """ 271 272 if args.pull_request_preview: 273 if args.repo_type != 'github': 274 raise c_exceptions.InvalidArgumentException( 275 '--repo-type', 276 "Repo type must be 'github' to configure pull request previewing.") 277 if args.namespace: 278 raise c_exceptions.InvalidArgumentException( 279 '--namespace', 280 'Namespace must not be provided to configure pull request ' 281 'previewing. --namespace must only be provided when configuring ' 282 'automated deployments with the --branch-pattern or --tag-pattern ' 283 'flags.') 284 if args.preview_expiry <= 0: 285 raise c_exceptions.InvalidArgumentException( 286 '--preview-expiry', 287 'Preview expiry must be > 0.') 288 289 # Determine image based on repo type 290 image = None 291 292 # Determine github app or csr 293 github_repo_name = None 294 github_repo_owner = None 295 csr_repo_name = None 296 297 project = properties.VALUES.core.project.Get(required=True) 298 299 if args.repo_type == 'github': 300 if not args.repo_owner: 301 raise c_exceptions.RequiredArgumentException( 302 '--repo-owner', 303 'Repo owner is required for --repo-type=github.') 304 image = 'gcr.io/{}/github.com/{}/{}:$COMMIT_SHA'.format( 305 project, args.repo_owner, args.repo_name) 306 github_repo_name = args.repo_name 307 github_repo_owner = args.repo_owner 308 # We do not have to verify that this repo exists because the request to 309 # create the BuildTrigger will fail with the appropriate message asking 310 # the user to connect their repo, if the repo is not found. 311 312 elif args.repo_type == 'csr': 313 if args.repo_owner: 314 raise c_exceptions.InvalidArgumentException( 315 '--repo-owner', 316 'Repo owner must not be provided for --repo-type=csr.') 317 image = 'gcr.io/{}/{}:$COMMIT_SHA'.format(project, args.repo_name) 318 csr_repo_name = args.repo_name 319 self._VerifyCSRRepoExists(csr_repo_name) 320 321 elif args.repo_type == 'bitbucket_mirrored': 322 if not args.repo_owner: 323 raise c_exceptions.RequiredArgumentException( 324 '--repo-owner', 325 'Repo owner is required for --repo-type=bitbucket_mirrored.') 326 image = 'gcr.io/{}/bitbucket.org/{}/{}:$COMMIT_SHA'.format( 327 project, args.repo_owner, args.repo_name) 328 csr_repo_name = 'bitbucket_{}_{}'.format(args.repo_owner, args.repo_name) 329 self._VerifyBitbucketCSRRepoExists( 330 csr_repo_name, args.repo_owner, args.repo_name) 331 332 elif args.repo_type == 'github_mirrored': 333 if not args.repo_owner: 334 raise c_exceptions.RequiredArgumentException( 335 '--repo-owner', 336 'Repo owner is required for --repo-type=github_mirrored.') 337 image = 'gcr.io/{}/github.com/{}/{}:$COMMIT_SHA'.format( 338 project, args.repo_owner, args.repo_name) 339 csr_repo_name = 'github_{}_{}'.format(args.repo_owner, args.repo_name) 340 self._VerifyGitHubCSRRepoExists( 341 csr_repo_name, args.repo_owner, args.repo_name) 342 343 self._VerifyClusterExists(args.cluster, args.location) 344 345 # Determine app_name 346 if args.app_name: 347 app_name = args.app_name 348 else: 349 app_name = args.repo_name 350 351 # Determine gcs_config_staging_dir_bucket, gcs_config_staging_dir_object 352 if args.gcs_config_staging_dir is None: 353 gcs_config_staging_dir_bucket = \ 354 staging_bucket_util.GetDefaultStagingBucket() 355 gcs_config_staging_dir_object = 'deploy/config' 356 else: 357 try: 358 gcs_config_staging_dir_ref = resources.REGISTRY.Parse( 359 args.gcs_config_staging_dir, collection='storage.objects') 360 gcs_config_staging_dir_object = gcs_config_staging_dir_ref.object 361 except resources.WrongResourceCollectionException: 362 gcs_config_staging_dir_ref = resources.REGISTRY.Parse( 363 args.gcs_config_staging_dir, collection='storage.buckets') 364 gcs_config_staging_dir_object = None 365 gcs_config_staging_dir_bucket = gcs_config_staging_dir_ref.bucket 366 367 gcs_client = storage_api.StorageClient() 368 try: 369 gcs_client.CreateBucketIfNotExists( 370 gcs_config_staging_dir_bucket, 371 check_ownership=args.gcs_config_staging_dir is None) 372 except storage_api.BucketInWrongProjectError: 373 # If we're using the default bucket but it already exists in a different 374 # project, then it could belong to a malicious attacker (b/33046325). 375 raise c_exceptions.RequiredArgumentException( 376 '--gcs-config-staging-dir', 377 'A bucket with name {} already exists and is owned by ' 378 'another project. Specify a bucket using ' 379 '--gcs-config-staging-dir.'.format(gcs_config_staging_dir_bucket)) 380 381 if gcs_config_staging_dir_object: 382 gcs_config_staging_path = '{}/{}'.format( 383 gcs_config_staging_dir_bucket, gcs_config_staging_dir_object) 384 else: 385 gcs_config_staging_path = gcs_config_staging_dir_bucket 386 387 if args.pull_request_preview: 388 log.status.Print('Setting up previewing {} on pull requests.\n'.format( 389 github_repo_name)) 390 self._ConfigurePRPreview( 391 repo_owner=github_repo_owner, 392 repo_name=github_repo_name, 393 pull_request_pattern=args.pull_request_pattern, 394 preview_expiry=args.preview_expiry, 395 comment_control=args.comment_control, 396 image=image, 397 dockerfile_path=args.dockerfile, 398 app_name=app_name, 399 config_path=args.config, 400 expose_port=args.expose, 401 gcs_config_staging_path=gcs_config_staging_path, 402 cluster=args.cluster, 403 location=args.location) 404 else: 405 log.status.Print('Setting up automated deployments for {}.\n'.format( 406 args.repo_name)) 407 self._ConfigureGitPushBuildTrigger( 408 repo_type=args.repo_type, 409 csr_repo_name=csr_repo_name, 410 github_repo_owner=github_repo_owner, 411 github_repo_name=github_repo_name, 412 branch_pattern=args.branch_pattern, 413 tag_pattern=args.tag_pattern, 414 image=image, 415 dockerfile_path=args.dockerfile, 416 app_name=app_name, 417 config_path=args.config, 418 namespace=args.namespace, 419 expose_port=args.expose, 420 gcs_config_staging_path=gcs_config_staging_path, 421 cluster=args.cluster, 422 location=args.location) 423 424 def _VerifyCSRRepoExists(self, csr_repo_name): 425 try: 426 csr_repo_ref = sourcerepo.ParseRepo(csr_repo_name) 427 csr_repo = sourcerepo.Source().GetRepo(csr_repo_ref) 428 if csr_repo.mirrorConfig: 429 log.error("CSR repo '{}' has mirrorConfig.url of {}", csr_repo_name, 430 csr_repo.mirrorConfig.url) 431 raise c_exceptions.InvalidArgumentException( 432 '--repo-type', 433 "Repo '{}' is found but is connected to {}. Specify the correct " 434 'value for --repo-type, along with appropriate values for ' 435 '--repo-owner and --repo-name.' 436 .format(csr_repo_name, csr_repo.mirrorConfig.url) 437 ) 438 except HttpNotFoundError: 439 raise c_exceptions.InvalidArgumentException( 440 '--repo-name', 441 "Repo '{}' is not found on CSR.".format(csr_repo_name)) 442 443 def _VerifyBitbucketCSRRepoExists(self, csr_repo_name, bitbucket_repo_owner, 444 bitbucket_repo_name): 445 try: 446 csr_repo_ref = sourcerepo.ParseRepo(csr_repo_name) 447 csr_repo = sourcerepo.Source().GetRepo(csr_repo_ref) 448 if not csr_repo.mirrorConfig: 449 raise c_exceptions.InvalidArgumentException( 450 '--repo-type', 451 "Repo '{}/{}' is found but the resolved repo name '{}' is a " 452 'regular CSR repo. Reference it with --repo-type=csr and ' 453 '--repo-name={}.' 454 .format(bitbucket_repo_owner, bitbucket_repo_name, csr_repo_name, 455 csr_repo_name)) 456 if (csr_repo.mirrorConfig and 457 not csr_repo.mirrorConfig.url.startswith('https://bitbucket.org/')): 458 log.error("CSR repo '{}' has mirrorConfig.url of {}", csr_repo_name, 459 csr_repo.mirrorConfig.url) 460 raise c_exceptions.InvalidArgumentException( 461 '--repo-type', 462 "Repo '{}/{}' is found but the resolved repo name '{}' is not " 463 'connected to a Bitbucket repo. Specify the correct value for ' 464 '--repo-type.' 465 .format(bitbucket_repo_owner, bitbucket_repo_name, csr_repo_name)) 466 except HttpNotFoundError: 467 raise SourceRepoNotConnectedException( 468 '--repo-name', 469 "Bitbucket repo '{}/{}' is not connected to CSR.".format( 470 bitbucket_repo_owner, bitbucket_repo_name)) 471 472 def _VerifyGitHubCSRRepoExists(self, csr_repo_name, github_repo_owner, 473 github_repo_name): 474 try: 475 csr_repo_ref = sourcerepo.ParseRepo(csr_repo_name) 476 csr_repo = sourcerepo.Source().GetRepo(csr_repo_ref) 477 if not csr_repo.mirrorConfig: 478 raise c_exceptions.InvalidArgumentException( 479 '--repo-type', 480 "Repo '{}/{}' is found but the resolved repo name '{}' is a " 481 'regular CSR repo. Reference it with --repo-type=csr and ' 482 '--repo-name={}.' 483 .format(github_repo_owner, github_repo_name, csr_repo_name, 484 csr_repo_name)) 485 if (csr_repo.mirrorConfig and 486 not csr_repo.mirrorConfig.url.startswith('https://github.com/')): 487 log.error("CSR repo '{}' has mirrorConfig.url of {}", csr_repo_name, 488 csr_repo.mirrorConfig.url) 489 raise c_exceptions.InvalidArgumentException( 490 '--repo-type', 491 "Repo '{}/{}' is found but the resolved repo name '{}' is not " 492 'connected to a GitHub repo. Specify the correct value for ' 493 '--repo-type.' 494 .format(github_repo_owner, github_repo_name, csr_repo_name)) 495 except HttpNotFoundError: 496 raise SourceRepoNotConnectedException( 497 '--repo-name', 498 "GitHub repo '{}/{}' is not connected to CSR.".format( 499 github_repo_owner, github_repo_name)) 500 501 def _VerifyClusterExists(self, cluster, location): 502 client = apis.GetClientInstance('container', 'v1') 503 messages = apis.GetMessagesModule('container', 'v1') 504 project = properties.VALUES.core.project.Get(required=True) 505 try: 506 cluster_res = client.projects_locations_clusters.Get( 507 messages.ContainerProjectsLocationsClustersGetRequest( 508 name='projects/{project}/locations/{location}/clusters/{cluster}' 509 .format( 510 project=project, 511 location=location, 512 cluster=cluster 513 ))) 514 except HttpNotFoundError: 515 raise c_exceptions.InvalidArgumentException( 516 '--cluster', 517 "No cluster '{cluster}' in location '{location}' in project {project}.\n\n" 518 'Visit https://console.cloud.google.com/kubernetes/list?project={project} ' 519 'to create a cluster.'.format( 520 cluster=cluster, 521 location=location, 522 project=project 523 )) 524 if cluster_res.status != messages.Cluster.StatusValueValuesEnum.RUNNING: 525 raise core_exceptions.Error( 526 'Cluster was found but status is not RUNNING. Status is {}.' 527 .format(cluster_res.status)) 528 529 def _GetTriggerIfExists(self, client, messages, project, trigger_name): 530 """Returns a BuildTrigger if one with the given name exists in a project. 531 532 Args: 533 client: Client used to make calls to Cloud Build API. 534 messages: Cloud Build messages module. This is the value returned from 535 cloudbuild_util.GetMessagesModule(). 536 project: Project of BuildTrigger to check existence. 537 trigger_name: Name of BuildTrigger to check existence. 538 539 Returns: 540 A BuildTrigger with the given trigger_name if it exists, else None. 541 """ 542 try: 543 return client.projects_triggers.Get( 544 messages.CloudbuildProjectsTriggersGetRequest( 545 projectId=project, 546 triggerId=trigger_name)) # Undocumented, but this field can be ID 547 # or name. 548 except HttpNotFoundError: 549 return None 550 551 def _UpsertBuildTrigger(self, build_trigger, add_gcb_trigger_id): 552 """Creates a BuildTrigger using the CloudBuild API if it doesn't exist, else updates it. 553 554 A BuildTrigger "exists" if one with the same name already exists in the 555 project. 556 557 Args: 558 build_trigger: Config of BuildTrigger to create. 559 add_gcb_trigger_id: If True, adds the gcb-trigger-id=<trigger-id> 560 annotation to the deployed Kubernetes objects. The annotation must be 561 added to an existing trigger because the trigger-id is only known after 562 the trigger is created. 563 564 Returns: 565 The upserted trigger. 566 """ 567 client = cloudbuild_util.GetClientInstance() 568 messages = cloudbuild_util.GetMessagesModule() 569 project = properties.VALUES.core.project.Get(required=True) 570 571 # Check if trigger with this name already exists. 572 existing = self._GetTriggerIfExists( 573 client, messages, project, build_trigger.name) 574 if existing: 575 trigger_id = existing.id 576 577 if add_gcb_trigger_id: 578 # Use the existing trigger's id to patch the trigger object we created 579 # to add gcb-trigger-id=<trigger-id> annotation for its deployed 580 # resources. 581 build_util.AddAnnotationToPrepareDeployStep( 582 build_trigger, 'gcb-trigger-id', trigger_id) 583 584 upserted_build_trigger = client.projects_triggers.Patch( 585 messages.CloudbuildProjectsTriggersPatchRequest( 586 buildTrigger=build_trigger, 587 projectId=project, 588 triggerId=trigger_id 589 ) 590 ) 591 log.debug('updated existing BuildTrigger: ' 592 + six.text_type(upserted_build_trigger)) 593 594 else: 595 upserted_build_trigger = client.projects_triggers.Create( 596 messages.CloudbuildProjectsTriggersCreateRequest( 597 buildTrigger=build_trigger, projectId=project)) 598 log.debug('created BuildTrigger: ' 599 + six.text_type(upserted_build_trigger)) 600 601 trigger_id = upserted_build_trigger.id 602 if add_gcb_trigger_id: 603 # Since <trigger-id> is only known after a BuildTrigger is created, we 604 # must patch the newly created trigger to add 605 # gcb-trigger-id=<trigger-id> annotation for its deployed resources. 606 build_util.AddAnnotationToPrepareDeployStep( 607 upserted_build_trigger, 'gcb-trigger-id', trigger_id) 608 upserted_build_trigger = client.projects_triggers.Patch( 609 messages.CloudbuildProjectsTriggersPatchRequest( 610 buildTrigger=upserted_build_trigger, 611 projectId=project, 612 triggerId=trigger_id 613 ) 614 ) 615 log.debug('updated BuildTrigger with gcb-trigger-id annotation: ' 616 + six.text_type(upserted_build_trigger)) 617 618 # Log trigger full name 619 build_trigger_ref = resources.REGISTRY.Parse( 620 None, 621 collection='cloudbuild.projects.triggers', 622 api_version='v1', 623 params={ 624 'projectId': project, 625 'triggerId': trigger_id, 626 }) 627 628 if existing: 629 log.UpdatedResource(build_trigger_ref) 630 else: 631 log.CreatedResource(build_trigger_ref) 632 633 return upserted_build_trigger 634 635 def _ConfigureGitPushBuildTrigger( 636 self, repo_type, csr_repo_name, github_repo_owner, github_repo_name, 637 branch_pattern, tag_pattern, image, dockerfile_path, app_name, 638 config_path, namespace, expose_port, gcs_config_staging_path, cluster, 639 location): 640 641 # Generate deterministic trigger name 642 if csr_repo_name: 643 full_repo_name = csr_repo_name 644 else: 645 full_repo_name = github_repo_owner + '-' + github_repo_name 646 647 name = self._FixBuildTriggerName(self._GenerateResourceName( 648 function_code='gp', # Git Push 649 repo_type=repo_type, 650 full_repo_name=full_repo_name, 651 branch_pattern=branch_pattern, 652 tag_pattern=tag_pattern)) 653 654 if branch_pattern: 655 description = 'Build and deploy on push to "{}"'.format(branch_pattern) 656 elif tag_pattern: 657 description = 'Build and deploy on "{}" tag'.format(tag_pattern) 658 659 build_trigger = build_util.CreateGitPushBuildTrigger( 660 cloudbuild_util.GetMessagesModule(), 661 name=name, 662 description=description, 663 build_timeout=properties.VALUES.builds.timeout.Get(), 664 csr_repo_name=csr_repo_name, 665 github_repo_owner=github_repo_owner, 666 github_repo_name=github_repo_name, 667 branch_pattern=branch_pattern, 668 tag_pattern=tag_pattern, 669 image=image, 670 dockerfile_path=dockerfile_path, 671 app_name=app_name, 672 config_path=config_path, 673 namespace=namespace, 674 expose_port=expose_port, 675 gcs_config_staging_path=gcs_config_staging_path, 676 cluster=cluster, 677 location=location, 678 build_tags=[app_name], 679 build_trigger_tags=[app_name], 680 ) 681 682 log.status.Print('Upserting Cloud Build trigger to build and deploy your ' 683 'application.') 684 upserted_trigger = self._UpsertBuildTrigger(build_trigger, True) 685 project = properties.VALUES.core.project.Get(required=True) 686 687 log.status.Print( 688 '\nSuccessfully created the Cloud Build trigger to build and deploy ' 689 'your application.\n\n' 690 'Visit https://console.cloud.google.com/cloud-build/triggers/edit/{trigger_id}?project={project} ' 691 'to view the trigger.\n\n' 692 'You can visit https://console.cloud.google.com/cloud-build/triggers?project={project} ' 693 'to view all Cloud Build triggers.'.format( 694 trigger_id=upserted_trigger.id, project=project) 695 ) 696 697 def _ConfigurePRPreview( 698 self, repo_owner, repo_name, pull_request_pattern, preview_expiry, 699 comment_control, image, dockerfile_path, app_name, config_path, 700 expose_port, gcs_config_staging_path, cluster, location): 701 """Configures previewing the application for each pull request. 702 703 PR previewing is only supported for GitHub repos. 704 705 This creates three resources: 706 * A BuildTrigger that builds, publishes, and deploys the application to 707 namespace 'preview-[REPO_NAME]-[PR_NUMBER]. The deployed namespace has an 708 expiry time, which indicates when it is considered to be expired. 709 * A BuildTrigger that cleans up expired namespaces of this application. 710 * A CloudScheduler Job that executes the BuildTrigger every day. This is 711 needed because BuildTriggers can't run on a Cron by themselves. 712 713 Args: 714 repo_owner: Owner of repo to be deployed. 715 repo_name: Name of repo to be deployed. 716 pull_request_pattern: Regex value of branch to trigger on. 717 preview_expiry: How long, in days, a preview namespace can exist before it 718 is expired. 719 comment_control: Whether or not a user must comment /gcbrun to trigger 720 the deployment build. 721 image: The image that will be built and deployed. The image can include a 722 tag or digest. 723 dockerfile_path: Path to the source repository's Dockerfile, relative to 724 the source repository's root directory. 725 app_name: Application name, which is set as a label to deployed objects. 726 config_path: Path to the source repository's Kubernetes configs, relative 727 to the source repository's root directory. 728 expose_port: Port that the deployed application listens on. 729 gcs_config_staging_path: Path to a GCS subdirectory to copy application 730 configs to. 731 cluster: Name of target cluster to deploy to. 732 location: Zone/region of target cluster to deploy to. 733 """ 734 # Attempt to get scheduler location before creating any resources so we can 735 # fail early if we fail to get the location due to the user's App Engine 736 # app not existing, since the scheduler job must be in the same region: 737 # https://cloud.google.com/scheduler/docs/ 738 scheduler_location = self._GetSchedulerJobLocation() 739 740 pr_preview_trigger = self._ConfigurePRPreviewBuildTrigger( 741 repo_owner=repo_owner, 742 repo_name=repo_name, 743 pull_request_pattern=pull_request_pattern, 744 preview_expiry=preview_expiry, 745 comment_control=comment_control, 746 image=image, 747 dockerfile_path=dockerfile_path, 748 app_name=app_name, 749 config_path=config_path, 750 expose_port=expose_port, 751 gcs_config_staging_path=gcs_config_staging_path, 752 cluster=cluster, 753 location=location 754 ) 755 clean_preview_trigger = self._ConfigureCleanPreviewBuildTrigger( 756 repo_owner=repo_owner, 757 repo_name=repo_name, 758 pull_request_pattern=pull_request_pattern, 759 cluster=cluster, 760 location=location, 761 app_name=app_name 762 ) 763 # TODO(b/141294571): Once cron triggers feature is done, use cron trigger 764 # instead of Scheduler job. 765 clean_preview_job = self._ConfigureCleanPreviewSchedulerJob( 766 repo_owner=repo_owner, 767 repo_name=repo_name, 768 pull_request_pattern=pull_request_pattern, 769 clean_preview_trigger_id=clean_preview_trigger.id, 770 scheduler_location=scheduler_location) 771 772 # We add scheduler job location and name as tags to the clean preview 773 # trigger to track it as created by us. 774 job_location = clean_preview_job.name.split('/')[-3] 775 job_id = clean_preview_job.name.split('/')[-1] 776 self._UpdateBuildTriggerWithSchedulerJobTags( 777 clean_preview_trigger, job_location, job_id) 778 779 log.status.Print( 780 '\nSuccessfully created resources for previewing pull requests of your ' 781 'application.\n\n' 782 'Visit https://console.cloud.google.com/cloud-build/triggers/edit/{pr_preview_trigger_id}?project={project} ' 783 'to view the Cloud Build trigger that deploys your application on pull ' 784 'request open/update.\n\n' 785 'Visit https://console.cloud.google.com/cloud-build/triggers/edit/{clean_preview_trigger_id}?project={project} ' 786 'to view the Cloud Build trigger that cleans up expired preview ' 787 'deployments.\n\n' 788 'Visit https://console.cloud.google.com/cloudscheduler/jobs/edit/{location}/{job}?project={project} ' 789 'to view the Cloud Scheduler job that periodically executes the ' 790 'trigger to clean up expired preview deployments.\n\n' 791 'WARNING: The deletion of expired preview deployments requires a Cloud ' 792 'Scheduler job that runs a Cloud Build trigger every day. Pause ' 793 "this job if you don't want to it to run anymore." 794 .format( 795 pr_preview_trigger_id=pr_preview_trigger.id, 796 clean_preview_trigger_id=clean_preview_trigger.id, 797 project=properties.VALUES.core.project.Get(required=True), 798 location=job_location, 799 job=job_id 800 ) 801 ) 802 803 def _UpdateBuildTriggerWithSchedulerJobTags( 804 self, build_trigger, job_location, job_id): 805 job_location_tag = 'cloudscheduler-job-location_' + job_location 806 job_id_tag = 'cloudscheduler-job-id_' + job_id 807 808 build_trigger_tags = [x for x in build_trigger.tags 809 if not x.startswith('cloudscheduler-job-')] 810 build_trigger_tags.append(job_location_tag) 811 build_trigger_tags.append(job_id_tag) 812 build_trigger.tags = build_trigger_tags 813 814 build_tags = [x for x in build_trigger.build.tags 815 if not x.startswith('cloudscheduler-job-')] 816 build_tags.append(job_location_tag) 817 build_tags.append(job_id_tag) 818 build_trigger.build.tags = build_tags 819 820 client = cloudbuild_util.GetClientInstance() 821 messages = cloudbuild_util.GetMessagesModule() 822 project = properties.VALUES.core.project.Get(required=True) 823 824 updated_trigger = client.projects_triggers.Patch( 825 messages.CloudbuildProjectsTriggersPatchRequest( 826 buildTrigger=build_trigger, 827 projectId=project, 828 triggerId=build_trigger.id 829 ) 830 ) 831 832 log.debug('added job id to trigger: ' + six.text_type(updated_trigger)) 833 834 def _GenerateResourceName( 835 self, function_code, repo_type, full_repo_name, 836 branch_pattern=None, tag_pattern=None): 837 """Generate a short, deterministic resource name based on parameters that should be unique. 838 839 Args: 840 function_code: A two-character code describing what function the resource 841 has. 842 repo_type: A two-character code describing the repo type. 843 full_repo_name: Deterministicly generated repo name, including owner 844 available. 845 branch_pattern: Branch pattern to match. Only one of branch_pattern or 846 tag_pattern should be provided. They can also both be omitted. 847 tag_pattern: Tag pattern to match. Only one of branch_pattern or 848 tag_pattern should be provided. They can also both be omitted. 849 850 Returns: 851 Deterministicly generated resource name. 852 """ 853 854 repo_type_code = _REPO_TYPE_CODES[repo_type] 855 if branch_pattern: 856 return '{}{}b-{}-{}'.format( 857 function_code, repo_type_code, full_repo_name, branch_pattern) 858 elif tag_pattern: 859 return '{}{}t-{}-{}'.format( 860 function_code, repo_type_code, full_repo_name, tag_pattern) 861 else: 862 return '{}{}b-{}'.format( 863 function_code, repo_type_code, full_repo_name) 864 865 def _ConfigurePRPreviewBuildTrigger( 866 self, repo_owner, repo_name, pull_request_pattern, preview_expiry, 867 comment_control, image, dockerfile_path, app_name, config_path, 868 expose_port, gcs_config_staging_path, cluster, location): 869 870 # Generate deterministic trigger name 871 name = self._FixBuildTriggerName(self._GenerateResourceName( 872 function_code='pp', # Pr Preview 873 repo_type='github', # Only supports github for now. 874 full_repo_name=repo_owner + '-' + repo_name)) 875 description = \ 876 'Build and deploy on PR create/update against "{}"'.format( 877 pull_request_pattern) 878 879 build_trigger = build_util.CreatePRPreviewBuildTrigger( 880 messages=cloudbuild_util.GetMessagesModule(), 881 name=name, 882 description=description, 883 build_timeout=properties.VALUES.builds.timeout.Get(), 884 github_repo_owner=repo_owner, 885 github_repo_name=repo_name, 886 pr_pattern=pull_request_pattern, 887 preview_expiry_days=preview_expiry, 888 comment_control=comment_control, 889 image=image, 890 dockerfile_path=dockerfile_path, 891 app_name=app_name, 892 config_path=config_path, 893 expose_port=expose_port, 894 gcs_config_staging_path=gcs_config_staging_path, 895 cluster=cluster, 896 location=location, 897 build_tags=[app_name], 898 build_trigger_tags=[app_name] 899 ) 900 901 log.status.Print('Upserting Cloud Build trigger to build and deploy your ' 902 'application on PR open/update for previewing.') 903 return self._UpsertBuildTrigger(build_trigger, True) 904 905 def _ConfigureCleanPreviewBuildTrigger( 906 self, repo_name, repo_owner, pull_request_pattern, cluster, location, 907 app_name): 908 909 # Generate deterministic trigger name 910 name = self._FixBuildTriggerName(self._GenerateResourceName( 911 function_code='cp', # Clean Preview 912 repo_type='github', # Only supports github for now. 913 full_repo_name=repo_owner + '-' + repo_name)) 914 description = \ 915 'Clean expired preview deployments for PRs against "{}"'.format( 916 pull_request_pattern) 917 918 build_trigger = build_util.CreateCleanPreviewBuildTrigger( 919 messages=cloudbuild_util.GetMessagesModule(), 920 name=name, 921 description=description, 922 github_repo_owner=repo_owner, 923 github_repo_name=repo_name, 924 cluster=cluster, 925 location=location, 926 build_tags=[app_name], 927 build_trigger_tags=[app_name] 928 ) 929 930 log.status.Print('Upserting Cloud Build trigger to clean expired preview ' 931 'deployments of your application.') 932 return self._UpsertBuildTrigger(build_trigger, False) 933 934 def _GetSchedulerJobLocation(self): 935 messages = apis.GetMessagesModule('cloudscheduler', 'v1') 936 client = apis.GetClientInstance('cloudscheduler', 'v1') 937 project = properties.VALUES.core.project.Get(required=True) 938 939 try: 940 locations_res = client.projects_locations.List( 941 messages.CloudschedulerProjectsLocationsListRequest( 942 name='projects/' + project)) 943 except HttpNotFoundError: 944 raise core_exceptions.Error( 945 'You must create an App Engine application in your project to use ' 946 'Cloud Scheduler. Visit ' 947 'https://console.developers.google.com/appengine?project={} to ' 948 'add an App Engine application.'.format(project)) 949 950 return locations_res.locations[0].labels.additionalProperties[0].value 951 952 def _ConfigureCleanPreviewSchedulerJob( 953 self, repo_owner, repo_name, pull_request_pattern, 954 clean_preview_trigger_id, scheduler_location): 955 956 log.status.Print('Upserting Cloud Scheduler to run Cloud Build trigger to ' 957 'clean expired preview deployments of your application.') 958 959 messages = apis.GetMessagesModule('cloudscheduler', 'v1') 960 client = apis.GetClientInstance('cloudscheduler', 'v1') 961 project = properties.VALUES.core.project.Get(required=True) 962 service_account_email = project + '@appspot.gserviceaccount.com' 963 964 # Generate deterministic scheduler job name (id) 965 job_id = self._FixSchedulerName(self._GenerateResourceName( 966 function_code='cp', # Clean Preview 967 repo_type='github', # Only supports github for now. 968 full_repo_name=repo_owner + '-' + repo_name)) 969 970 name = 'projects/{}/locations/{}/jobs/{}'.format( 971 project, scheduler_location, job_id) 972 973 job = messages.Job( 974 name=name, 975 description='Every day, run trigger to clean expired preview ' 976 'deployments for PRs against "{}" in {}/{}'.format( 977 pull_request_pattern, repo_owner, repo_name), 978 schedule=_CLEAN_PREVIEW_SCHEDULE, 979 timeZone='UTC', 980 httpTarget=messages.HttpTarget( 981 uri='https://cloudbuild.googleapis.com/v1/projects/{}/triggers/{}:run' 982 .format(project, clean_preview_trigger_id), 983 httpMethod=messages.HttpTarget.HttpMethodValueValuesEnum.POST, 984 body=bytes( 985 # We don't actually use the branchName value but it has to be 986 # set to an existing branch, so set it to master. 987 '{{"projectId":"{}","repoName":"{}","branchName":"master"}}' 988 .format(project, repo_name) 989 .encode('utf-8')), 990 oauthToken=messages.OAuthToken( 991 serviceAccountEmail=service_account_email 992 ) 993 ) 994 ) 995 996 existing = None 997 try: 998 existing = client.projects_locations_jobs.Get( 999 messages.CloudschedulerProjectsLocationsJobsGetRequest(name=name)) 1000 1001 upserted_job = client.projects_locations_jobs.Patch( 1002 messages.CloudschedulerProjectsLocationsJobsPatchRequest( 1003 name=name, 1004 job=job)) 1005 log.debug('updated existing CloudScheduler job: ' 1006 + six.text_type(upserted_job)) 1007 1008 except HttpNotFoundError: 1009 upserted_job = client.projects_locations_jobs.Create( 1010 messages.CloudschedulerProjectsLocationsJobsCreateRequest( 1011 parent='projects/{}/locations/{}'.format( 1012 project, scheduler_location), 1013 job=job)) 1014 log.debug('created CloudScheduler job: ' + six.text_type(upserted_job)) 1015 1016 job_id = upserted_job.name.split('/')[-1] 1017 job_ref = resources.REGISTRY.Parse( 1018 None, 1019 collection='cloudscheduler.projects.locations.jobs', 1020 api_version='v1', 1021 params={ 1022 'projectsId': project, 1023 'locationsId': scheduler_location, 1024 'jobsId': job_id, 1025 }) 1026 1027 if existing: 1028 log.UpdatedResource(job_ref) 1029 else: 1030 log.CreatedResource(job_ref) 1031 1032 return upserted_job 1033 1034 def _FixBuildTriggerName(self, name): 1035 """Fixes a BuildTrigger name to match the allowed format. 1036 1037 Args: 1038 name: Name must start with an alpha-numberic character, since this method 1039 will not check that condition. 1040 1041 Returns: 1042 Fixed name. 1043 """ 1044 # Name pattern must match 1045 # ^[a-zA-Z0-9]$|^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}[a-zA-Z0-9]$, where the max 1046 # length is 64 characters. 1047 if len(name) > 64: 1048 name = name[:64] 1049 name = re.sub('[^a-zA-Z0-9-]', '-', name) 1050 if name.endswith('-'): # Cannot trail in a '-' character. 1051 name = name[:-1] + '0' 1052 # Assume the name starts with alpha-numeric character, so we will not check. 1053 1054 return name 1055 1056 def _FixSchedulerName(self, name): 1057 """Fixes a Scheduler Job's name to match the allowed format. 1058 1059 Args: 1060 name: Name to fix. 1061 1062 Returns: 1063 Fixed name. 1064 """ 1065 # Name pattern must match "[a-zA-Z\d_-]{1,500}". 1066 if len(name) > 500: 1067 name = name[:500] 1068 name = re.sub('[^a-zA-Z0-9_-]', '-', name) 1069 1070 return name 1071