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