1# -*- coding: utf-8 -*- #
2# Copyright 2018 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"""Class for representing various changes to a Configuration."""
16
17from __future__ import absolute_import
18from __future__ import division
19from __future__ import print_function
20from __future__ import unicode_literals
21
22import abc
23import copy
24
25from googlecloudsdk.api_lib.run import container_resource
26from googlecloudsdk.api_lib.run import job
27from googlecloudsdk.api_lib.run import k8s_object
28from googlecloudsdk.api_lib.run import revision
29from googlecloudsdk.api_lib.run import service
30from googlecloudsdk.calliope import base
31from googlecloudsdk.command_lib.run import exceptions
32from googlecloudsdk.command_lib.run import name_generator
33from googlecloudsdk.command_lib.run import platforms
34from googlecloudsdk.command_lib.util.args import labels_util
35from googlecloudsdk.command_lib.util.args import repeated
36
37import six
38
39
40class ConfigChanger(six.with_metaclass(abc.ABCMeta, object)):
41  """An abstract class representing configuration changes."""
42
43  @abc.abstractmethod
44  def Adjust(self, resource):
45    """Adjust the given Service configuration.
46
47    Args:
48      resource: the k8s_object to adjust.
49
50    Returns:
51      A k8s_object that reflects applying the requested update.
52      May be resource after a mutation or a different object.
53    """
54    return resource
55
56
57def WithChanges(resource, changes):
58  """Apply ConfigChangers to resource.
59
60  It's undefined whether the input resource is modified.
61
62  Args:
63    resource: KubernetesObject, probably a Service.
64    changes: List of ConfigChangers.
65
66  Returns:
67    Changed resource.
68  """
69  for config_change in changes:
70    resource = config_change.Adjust(resource)
71  return resource
72
73
74def _AssertValidSecretKey(key, platform):
75  if platform == platforms.PLATFORM_MANAGED:
76    if not (key.isdigit() or key == 'latest'):
77      raise exceptions.ConfigurationError(
78          "Secret key must be an integer or 'latest'.")
79
80
81class LabelChanges(ConfigChanger):
82  """Represents the user intent to modify metadata labels."""
83
84  LABELS_NOT_ALLOWED_IN_REVISION = ([service.ENDPOINT_VISIBILITY])
85
86  def __init__(self, diff, copy_to_revision=True):
87    super(LabelChanges, self).__init__()
88    self._diff = diff
89    self._copy_to_revision = copy_to_revision
90
91  def Adjust(self, resource):
92    # Currently assumes all "system"-owned labels are applied by the control
93    # plane and it's ok for us to clear them on the client.
94    update_result = self._diff.Apply(
95        k8s_object.Meta(resource.MessagesModule()).LabelsValue,
96        resource.metadata.labels)
97    maybe_new_labels = update_result.GetOrNone()
98    if maybe_new_labels:
99      resource.metadata.labels = maybe_new_labels
100      if self._copy_to_revision and hasattr(resource.template, 'labels'):
101        # Service labels are the source of truth and *overwrite* revision labels
102        # See go/run-labels-prd for deets.
103        # However, we need to preserve the nonce if there is one.
104        nonce = resource.template.labels.get(revision.NONCE_LABEL)
105        resource.template.metadata.labels = copy.deepcopy(maybe_new_labels)
106        for label_to_remove in self.LABELS_NOT_ALLOWED_IN_REVISION:
107          if label_to_remove in resource.template.labels:
108            del resource.template.labels[label_to_remove]
109        if nonce:
110          resource.template.labels[revision.NONCE_LABEL] = nonce
111    return resource
112
113
114class ReplaceServiceChange(ConfigChanger):
115  """Represents the user intent to replace the service."""
116
117  def __init__(self, new_service):
118    super(ReplaceServiceChange, self).__init__()
119    self._service = new_service
120
121  def Adjust(self, resource):
122    """Returns a replacement for resource.
123
124    The returned service is the service provided to the constructor. If
125    resource.metadata.resourceVersion is not empty to None returned service
126    has metadata.resourceVersion set to this value.
127
128    Args:
129      resource: service.Service, The service to adjust.
130    """
131    if resource.metadata.resourceVersion:
132      self._service.metadata.resourceVersion = resource.metadata.resourceVersion
133      # Knative will complain if you try to edit (incl remove) serving annots.
134      # So replicate them here.
135      for k, v in resource.annotations.items():
136        if k.startswith(k8s_object.SERVING_GROUP):
137          self._service.annotations[k] = v
138    return self._service
139
140
141class EndpointVisibilityChange(LabelChanges):
142  """Represents the user intent to modify the endpoint visibility.
143
144  Only applies to Cloud Run for Anthos.
145  """
146
147  def __init__(self, endpoint_visibility):
148    """Determine label changes for modifying endpoint visibility.
149
150    Args:
151      endpoint_visibility: bool, True if Cloud Run on GKE service should only be
152        addressable from within the cluster. False if it should be publicly
153        addressable.
154    """
155    if endpoint_visibility:
156      diff = labels_util.Diff(
157          additions={service.ENDPOINT_VISIBILITY: service.CLUSTER_LOCAL})
158    else:
159      diff = labels_util.Diff(subtractions=[service.ENDPOINT_VISIBILITY])
160    # Don't copy this label to the revision because it's not supported there.
161    # See b/154664962.
162    super(EndpointVisibilityChange, self).__init__(diff, False)
163
164
165class SetAnnotationChange(ConfigChanger):
166  """Represents the user intent to set an annotation."""
167
168  def __init__(self, key, value):
169    super(SetAnnotationChange, self).__init__()
170    self._key = key
171    self._value = value
172
173  def Adjust(self, resource):
174    resource.annotations[self._key] = self._value
175    return resource
176
177
178class DeleteAnnotationChange(ConfigChanger):
179  """Represents the user intent to delete an annotation."""
180
181  def __init__(self, key):
182    super(DeleteAnnotationChange, self).__init__()
183    self._key = key
184
185  def Adjust(self, resource):
186    annotations = resource.annotations
187    if self._key in annotations:
188      del annotations[self._key]
189    return resource
190
191
192class SetTemplateAnnotationChange(ConfigChanger):
193  """Represents the user intent to set a template annotation."""
194
195  def __init__(self, key, value):
196    super(SetTemplateAnnotationChange, self).__init__()
197    self._key = key
198    self._value = value
199
200  def Adjust(self, resource):
201    resource.template.annotations[self._key] = self._value
202    return resource
203
204
205class DeleteTemplateAnnotationChange(ConfigChanger):
206  """Represents the user intent to delete a template annotation."""
207
208  def __init__(self, key):
209    super(DeleteTemplateAnnotationChange, self).__init__()
210    self._key = key
211
212  def Adjust(self, resource):
213    annotations = resource.template.annotations
214    if self._key in annotations:
215      del annotations[self._key]
216    return resource
217
218
219class SetLaunchStageAnnotationChange(ConfigChanger):
220  """Sets a VPC connector annotation on the service."""
221
222  def __init__(self, launch_stage):
223    super(SetLaunchStageAnnotationChange, self).__init__()
224    self._launch_stage = launch_stage
225
226  def Adjust(self, resource):
227    if self._launch_stage == base.ReleaseTrack.GA:
228      return resource
229    else:
230      resource.annotations[
231          k8s_object.LAUNCH_STAGE_ANNOTATION] = self._launch_stage.id
232      return resource
233
234
235class SetClientNameAndVersionAnnotationChange(ConfigChanger):
236  """Sets the client name and version annotations."""
237
238  def __init__(self, client_name, client_version):
239    super(SetClientNameAndVersionAnnotationChange, self).__init__()
240    self._client_name = client_name
241    self._client_version = client_version
242
243  def Adjust(self, resource):
244    if self._client_name is not None:
245      resource.annotations[
246          k8s_object.CLIENT_NAME_ANNOTATION] = self._client_name
247      resource.template.annotations[
248          k8s_object.CLIENT_NAME_ANNOTATION] = self._client_name
249    if self._client_version is not None:
250      resource.annotations[
251          k8s_object.CLIENT_VERSION_ANNOTATION] = self._client_version
252      resource.template.annotations[
253          k8s_object.CLIENT_VERSION_ANNOTATION] = self._client_version
254    return resource
255
256
257class SandboxChange(ConfigChanger):
258  """Sets a sandbox annotation on the service."""
259
260  def __init__(self, sandbox):
261    super(SandboxChange, self).__init__()
262    self._sandbox = sandbox
263
264  def Adjust(self, resource):
265    resource.template.annotations[
266        container_resource.SANDBOX_ANNOTATION] = self._sandbox
267    return resource
268
269
270class VpcConnectorChange(ConfigChanger):
271  """Sets a VPC connector annotation on the service."""
272
273  def __init__(self, connector_name):
274    super(VpcConnectorChange, self).__init__()
275    self._connector_name = connector_name
276
277  def Adjust(self, resource):
278    resource.template.annotations[
279        container_resource.VPC_ACCESS_ANNOTATION] = self._connector_name
280    return resource
281
282
283class ClearVpcConnectorChange(ConfigChanger):
284  """Clears a VPC connector annotation on the service."""
285
286  def Adjust(self, resource):
287    annotations = resource.template.annotations
288    if container_resource.VPC_ACCESS_ANNOTATION in annotations:
289      del annotations[container_resource.VPC_ACCESS_ANNOTATION]
290    if container_resource.EGRESS_SETTINGS_ANNOTATION in annotations:
291      del annotations[container_resource.EGRESS_SETTINGS_ANNOTATION]
292    return resource
293
294
295class ImageChange(ConfigChanger):
296  """A Cloud Run container deployment."""
297
298  deployment_type = 'container'
299
300  def __init__(self, image):
301    super(ImageChange, self).__init__()
302    self.image = image
303
304  def Adjust(self, resource):
305    resource.annotations[container_resource.USER_IMAGE_ANNOTATION] = (
306        self.image)
307    if hasattr(resource.template, 'annotations'):
308      resource.template.annotations[
309          container_resource.USER_IMAGE_ANNOTATION] = (
310              self.image)
311    resource.image = self.image
312    return resource
313
314
315def _PruneMapping(mapping, keys_to_remove, clear_others):
316  if clear_others:
317    mapping.clear()
318  else:
319    for var_or_path in keys_to_remove:
320      if var_or_path in mapping:
321        del mapping[var_or_path]
322
323
324class EnvVarLiteralChanges(ConfigChanger):
325  """Represents the user intent to modify environment variables string literals."""
326
327  def __init__(self, updates, removes, clear_others):
328    """Initialize a new EnvVarLiteralChanges object.
329
330    Args:
331      updates: {str, str}, Update env var names and values.
332      removes: [str], List of env vars to remove.
333      clear_others: bool, If true, clear all non-updated env vars.
334    """
335    super(EnvVarLiteralChanges, self).__init__()
336    self._updates = updates
337    self._removes = removes
338    self._clear_others = clear_others
339
340  def Adjust(self, resource):
341    """Mutates the given config's env vars to match the desired changes.
342
343    Args:
344      resource: k8s_object to adjust
345
346    Returns:
347      The adjusted resource
348
349    Raises:
350      ConfigurationError if there's an attempt to replace the source of an
351        existing environment variable whose source is of a different type
352        (e.g. env var's secret source can't be replaced with a config map
353        source).
354    """
355    _PruneMapping(resource.template.env_vars.literals, self._removes,
356                  self._clear_others)
357
358    try:
359      resource.template.env_vars.literals.update(self._updates)
360    except KeyError as e:
361      raise exceptions.ConfigurationError(
362          'Cannot update environment variable [{}] to string literal '
363          'because it has already been set with a different type.'.format(
364              e.args[0]))
365    return resource
366
367
368class SecretEnvVarChanges(ConfigChanger):
369  """Represents the user intent to modify environment variable secrets."""
370
371  def __init__(self, updates, removes, clear_others):
372    """Initialize a new SecretEnvVarChanges object.
373
374    Args:
375      updates: {str, str}, Update env var names and values.
376      removes: [str], List of env vars to remove.
377      clear_others: bool, If true, clear all non-updated env vars.
378
379    Raises:
380      ConfigurationError if a key hasn't been provided for a source.
381    """
382    super(SecretEnvVarChanges, self).__init__()
383    self._updates = {}
384    for name, v in updates.items():
385      # Split the given values into 2 parts:
386      #    [env var source name, source data item key]
387      value = v.split(':', 1)
388      if len(value) < 2:
389        value.append(self._OmittedSecretKeyDefault(name))
390      self._updates[name] = value
391    self._removes = removes
392    self._clear_others = clear_others
393
394  def _OmittedSecretKeyDefault(self, name):
395    if platforms.GetPlatform() == platforms.PLATFORM_MANAGED:
396      return 'latest'
397    raise exceptions.ConfigurationError(
398        'Missing required item key for environment variable [{}].'.format(name))
399
400  def Adjust(self, resource):
401    """Mutates the given config's env vars to match the desired changes.
402
403    Args:
404      resource: k8s_object to adjust
405
406    Returns:
407      The adjusted resource
408
409    Raises:
410      ConfigurationError if there's an attempt to replace the source of an
411        existing environment variable whose source is of a different type
412        (e.g. env var's secret source can't be replaced with a config map
413        source).
414    """
415    env_vars = resource.template.env_vars.secrets
416    _PruneMapping(env_vars, self._removes, self._clear_others)
417
418    for name, (source_name, source_key) in self._updates.items():
419      try:
420        env_vars[name] = self._MakeEnvVarSource(resource.MessagesModule(),
421                                                source_name, source_key)
422      except KeyError:
423        raise exceptions.ConfigurationError(
424            'Cannot update environment variable [{}] to the given type '
425            'because it has already been set with a different type.'.format(
426                name))
427    return resource
428
429  def _MakeEnvVarSource(self, messages, name, key):
430    _AssertValidSecretKey(key, platforms.GetPlatform())
431    return messages.EnvVarSource(
432        secretKeyRef=messages.SecretKeySelector(name=name, key=key))
433
434
435class ConfigMapEnvVarChanges(ConfigChanger):
436  """Represents the user intent to modify environment variable config maps."""
437
438  def __init__(self, updates, removes, clear_others):
439    """Initialize a new ConfigMapEnvVarChanges object.
440
441    Args:
442      updates: {str, str}, Update env var names and values.
443      removes: [str], List of env vars to remove.
444      clear_others: bool, If true, clear all non-updated env vars.
445
446    Raises:
447      ConfigurationError if a key hasn't been provided for a source.
448    """
449    super(ConfigMapEnvVarChanges, self).__init__()
450    self._updates = {}
451    for name, v in updates.items():
452      # Split the given values into 2 parts:
453      #    [env var source name, source data item key]
454      value = v.split(':', 1)
455      if len(value) < 2:
456        value.append(self._OmittedSecretKeyDefault(name))
457      self._updates[name] = value
458    self._removes = removes
459    self._clear_others = clear_others
460
461  def _OmittedSecretKeyDefault(self, name):
462    if platforms.GetPlatform() == platforms.PLATFORM_MANAGED:
463      return 'latest'
464    raise exceptions.ConfigurationError(
465        'Missing required item key for environment variable [{}].'.format(name))
466
467  def Adjust(self, resource):
468    """Mutates the given config's env vars to match the desired changes.
469
470    Args:
471      resource: k8s_object to adjust
472
473    Returns:
474      The adjusted resource
475
476    Raises:
477      ConfigurationError if there's an attempt to replace the source of an
478        existing environment variable whose source is of a different type
479        (e.g. env var's secret source can't be replaced with a config map
480        source).
481    """
482    env_vars = resource.template.env_vars.config_maps
483    _PruneMapping(env_vars, self._removes, self._clear_others)
484
485    for name, (source_name, source_key) in self._updates.items():
486      try:
487        env_vars[name] = self._MakeEnvVarSource(resource.MessagesModule(),
488                                                source_name, source_key)
489      except KeyError:
490        raise exceptions.ConfigurationError(
491            'Cannot update environment variable [{}] to the given type '
492            'because it has already been set with a different type.'.format(
493                name))
494    return resource
495
496  def _MakeEnvVarSource(self, messages, name, key):
497    return messages.EnvVarSource(
498        configMapKeyRef=messages.ConfigMapKeySelector(
499            name=name,
500            key=key))
501
502
503class ResourceChanges(ConfigChanger):
504  """Represents the user intent to update resource limits."""
505
506  def __init__(self, memory=None, cpu=None):
507    super(ResourceChanges, self).__init__()
508    self._memory = memory
509    self._cpu = cpu
510
511  def Adjust(self, resource):
512    """Mutates the given config's resource limits to match what's desired."""
513    if self._memory is not None:
514      resource.template.resource_limits['memory'] = self._memory
515    if self._cpu is not None:
516      resource.template.resource_limits['cpu'] = self._cpu
517    return resource
518
519
520class CloudSQLChanges(ConfigChanger):
521  """Represents the intent to update the Cloug SQL instances."""
522
523  def __init__(self, project, region, args):
524    """Initializes the intent to update the Cloud SQL instances.
525
526    Args:
527      project: Project to use as the default project for Cloud SQL instances.
528      region: Region to use as the default region for Cloud SQL instances
529      args: Args to the command.
530    """
531    super(CloudSQLChanges, self).__init__()
532    self._project = project
533    self._region = region
534    self._args = args
535
536  # Here we are a proxy through to the actual args to set some extra augmented
537  # information on each one, so each cloudsql instance gets the region and
538  # project.
539  @property
540  def add_cloudsql_instances(self):
541    return self._AugmentArgs('add_cloudsql_instances')
542
543  @property
544  def remove_cloudsql_instances(self):
545    return self._AugmentArgs('remove_cloudsql_instances')
546
547  @property
548  def set_cloudsql_instances(self):
549    return self._AugmentArgs('set_cloudsql_instances')
550
551  @property
552  def clear_cloudsql_instances(self):
553    return getattr(self._args, 'clear_cloudsql_instances', None)
554
555  def _AugmentArgs(self, arg_name):
556    val = getattr(self._args, arg_name, None)
557    if val is None:
558      return None
559    return [self._Augment(i) for i in val]
560
561  def Adjust(self, resource):
562    def GetCurrentInstances():
563      annotation_val = resource.template.annotations.get(
564          container_resource.CLOUDSQL_ANNOTATION)
565      if annotation_val:
566        return annotation_val.split(',')
567      return []
568
569    instances = repeated.ParsePrimitiveArgs(
570        self, 'cloudsql-instances', GetCurrentInstances)
571    if instances is not None:
572      resource.template.annotations[
573          container_resource.CLOUDSQL_ANNOTATION] = ','.join(instances)
574    return resource
575
576  def _Augment(self, instance_str):
577    instance = instance_str.split(':')
578    if len(instance) == 3:
579      ret = tuple(instance)
580    elif len(instance) == 1:
581      if not self._project:
582        raise exceptions.CloudSQLError(
583            'To specify a Cloud SQL instance by plain name, you must specify a '
584            'project.')
585      if not self._region:
586        raise exceptions.CloudSQLError(
587            'To specify a Cloud SQL instance by plain name, you must be '
588            'deploying to a managed Cloud Run region.')
589      ret = self._project, self._region, instance[0]
590    else:
591      raise exceptions.CloudSQLError(
592          'Malformed CloudSQL instance string: {}'.format(
593              instance_str))
594    return ':'.join(ret)
595
596
597class ConcurrencyChanges(ConfigChanger):
598  """Represents the user intent to update concurrency preference."""
599
600  def __init__(self, concurrency):
601    super(ConcurrencyChanges, self).__init__()
602    self._concurrency = None if concurrency == 'default' else int(concurrency)
603
604  def Adjust(self, resource):
605    """Mutates the given config's resource limits to match what's desired."""
606    resource.template.concurrency = self._concurrency
607    return resource
608
609
610class TimeoutChanges(ConfigChanger):
611  """Represents the user intent to update request duration."""
612
613  def __init__(self, timeout):
614    super(TimeoutChanges, self).__init__()
615    self._timeout = timeout
616
617  def Adjust(self, resource):
618    """Mutates the given config's timeout to match what's desired."""
619    resource.template.timeout = self._timeout
620    return resource
621
622
623class ServiceAccountChanges(ConfigChanger):
624  """Represents the user intent to change service account for the revision."""
625
626  def __init__(self, service_account):
627    super(ServiceAccountChanges, self).__init__()
628    self._service_account = service_account
629
630  def Adjust(self, resource):
631    """Mutates the given config's service account to match what's desired."""
632    resource.template.service_account = self._service_account
633    return resource
634
635
636_MAX_RESOURCE_NAME_LENGTH = 63
637
638
639class RevisionNameChanges(ConfigChanger):
640  """Represents the user intent to change revision name."""
641
642  def __init__(self, revision_suffix):
643    super(RevisionNameChanges, self).__init__()
644    self._revision_suffix = revision_suffix
645
646  def Adjust(self, resource):
647    """Mutates the given config's revision name to match what's desired."""
648    max_prefix_length = (
649        _MAX_RESOURCE_NAME_LENGTH - len(self._revision_suffix) - 1)
650    resource.template.name = '{}-{}'.format(resource.name[:max_prefix_length],
651                                            self._revision_suffix)
652    return resource
653
654
655def _GenerateVolumeName(prefix):
656  """Randomly generated name with the given prefix."""
657  return name_generator.GenerateName(sections=3, separator='-', prefix=prefix)
658
659
660def _UniqueVolumeName(source_name, existing_volumes):
661  """Generate unique volume name.
662
663  The names that connect volumes and mounts must be unique even if their
664  source volume names match.
665
666  Args:
667    source_name: (Potentially clashing) name.
668    existing_volumes: Names in use.
669
670  Returns:
671    Unique name.
672  """
673  volume_name = None
674  while volume_name is None or volume_name in existing_volumes:
675    volume_name = _GenerateVolumeName(source_name)
676  return volume_name
677
678
679def _PruneVolumes(volume_mounts, volumes):
680  """Delete all volumes no longer being mounted.
681
682  Args:
683    volume_mounts: resource.template.volume_mounts
684    volumes: resource.template.volumes
685  """
686  for volume in list(volumes):
687    if volume not in volume_mounts.values():
688      del volumes[volume]
689
690
691class SecretVolumeChanges(ConfigChanger):
692  """Represents the user intent to change volumes with secret source types."""
693
694  def __init__(self, updates, removes, clear_others):
695    """Initialize a new SecretVolumeChanges object.
696
697    Args:
698      updates: {str, str}, Update mount path and volume fields.
699      removes: [str], List of mount paths to remove.
700      clear_others: bool, If true, clear all non-updated volumes and mounts of
701        the given [volume_type].
702    """
703    super(SecretVolumeChanges, self).__init__()
704    self._updates = {}
705    for k, v in updates.items():
706      # Split the given values into 2 parts:
707      #    [volume source name, data item key]
708      update_value = v.split(':', 1)
709      # Pad with None if no data item key specified
710      if len(update_value) < 2:
711        update_value.append(None)
712      self._updates[k] = update_value
713    self._removes = removes
714    self._clear_others = clear_others
715
716  def Adjust(self, resource):
717    """Mutates the given config's volumes to match the desired changes.
718
719    Args:
720      resource: k8s_object to adjust
721
722    Returns:
723      The adjusted resource
724
725    Raises:
726      ConfigurationError if there's an attempt to replace the volume a mount
727        points to whose existing volume has a source of a different type than
728        the new volume (e.g. mount that points to a volume with a secret source
729        can't be replaced with a volume that has a config map source).
730    """
731    volume_mounts = resource.template.volume_mounts.secrets
732    volumes = resource.template.volumes.secrets
733
734    _PruneMapping(volume_mounts, self._removes, self._clear_others)
735
736    for file_path, (source_name, source_key) in self._updates.items():
737      volume_name = _UniqueVolumeName(source_name, resource.template.volumes)
738
739      # volume_mounts is a special mapping that filters for the current kind
740      # of mount and KeyErrors on existing keys with other types.
741      try:
742        volume_mounts[file_path] = volume_name
743      except KeyError:
744        raise exceptions.ConfigurationError(
745            'Cannot update mount [{}] because its mounted volume '
746            'is of a different source type.'.format(file_path))
747      volumes[volume_name] = self._MakeVolumeSource(resource.MessagesModule(),
748                                                    source_name, source_key)
749
750    _PruneVolumes(volume_mounts, volumes)
751    return resource
752
753  def _MakeVolumeSource(self, messages, name, key=None):
754    source = messages.SecretVolumeSource(secretName=name)
755    if key is not None:
756      _AssertValidSecretKey(key, platforms.GetPlatform())
757      source.items.append(messages.KeyToPath(key=key, path=key))
758    return source
759
760
761class ConfigMapVolumeChanges(ConfigChanger):
762  """Represents the user intent to change volumes with config map source types."""
763
764  def __init__(self, updates, removes, clear_others):
765    """Initialize a new ConfigMapVolumeChanges object.
766
767    Args:
768      updates: {str, [str, str]}, Update mount path and volume fields.
769      removes: [str], List of mount paths to remove.
770      clear_others: bool, If true, clear all non-updated volumes and mounts of
771        the given [volume_type].
772    """
773    super(ConfigMapVolumeChanges, self).__init__()
774    self._updates = {}
775    for k, v in updates.items():
776      # Split the given values into 2 parts:
777      #    [volume source name, data item key]
778      update_value = v.split(':', 1)
779      # Pad with None if no data item key specified
780      if len(update_value) < 2:
781        update_value.append(None)
782      self._updates[k] = update_value
783    self._removes = removes
784    self._clear_others = clear_others
785
786  def Adjust(self, resource):
787    """Mutates the given config's volumes to match the desired changes.
788
789    Args:
790      resource: k8s_object to adjust
791
792    Returns:
793      The adjusted resource
794
795    Raises:
796      ConfigurationError if there's an attempt to replace the volume a mount
797        points to whose existing volume has a source of a different type than
798        the new volume (e.g. mount that points to a volume with a secret source
799        can't be replaced with a volume that has a config map source).
800    """
801    volume_mounts = resource.template.volume_mounts.config_maps
802    volumes = resource.template.volumes.config_maps
803
804    _PruneMapping(volume_mounts, self._removes, self._clear_others)
805
806    for path, (source_name, source_key) in self._updates.items():
807      volume_name = _UniqueVolumeName(source_name, resource.template.volumes)
808
809      # volume_mounts is a special mapping that filters for the current kind
810      # of mount and KeyErrors on existing keys with other types.
811      try:
812        volume_mounts[path] = volume_name
813      except KeyError:
814        raise exceptions.ConfigurationError(
815            'Cannot update mount [{}] because its mounted volume '
816            'is of a different source type.'.format(path))
817      volumes[volume_name] = self._MakeVolumeSource(resource.MessagesModule(),
818                                                    source_name, source_key)
819
820    _PruneVolumes(volume_mounts, volumes)
821
822    return resource
823
824  def _MakeVolumeSource(self, messages, name, key=None):
825    source = messages.ConfigMapVolumeSource(name=name)
826    if key is not None:
827      source.items.append(messages.KeyToPath(key=key, path=key))
828    return source
829
830
831class NoTrafficChange(ConfigChanger):
832  """Represents the user intent to block traffic for a new revision."""
833
834  def Adjust(self, resource):
835    """Removes LATEST from the services traffic assignments."""
836    if resource.configuration:
837      raise exceptions.UnsupportedOperationError(
838          'This service is using an old version of Cloud Run for Anthos '
839          'that does not support traffic features. Please upgrade to 0.8 '
840          'or later.')
841
842    if not resource.generation:
843      raise exceptions.ConfigurationError(
844          '--no-traffic not supported when creating a new service.')
845
846    resource.spec_traffic.ZeroLatestTraffic(
847        resource.status.latestReadyRevisionName)
848    return resource
849
850
851class TrafficChanges(ConfigChanger):
852  """Represents the user intent to change a service's traffic assignments."""
853
854  def __init__(self,
855               new_percentages,
856               by_tag=False,
857               tags_to_update=None,
858               tags_to_remove=None,
859               clear_other_tags=False):
860    super(TrafficChanges, self).__init__()
861    self._new_percentages = new_percentages
862    self._by_tag = by_tag
863    self._tags_to_update = tags_to_update or {}
864    self._tags_to_remove = tags_to_remove or []
865    self._clear_other_tags = clear_other_tags
866
867  def Adjust(self, resource):
868    """Mutates the given service's traffic assignments."""
869    if self._tags_to_update or self._tags_to_remove or self._clear_other_tags:
870      resource.spec_traffic.UpdateTags(self._tags_to_update,
871                                       self._tags_to_remove,
872                                       self._clear_other_tags)
873    if self._new_percentages:
874      if self._by_tag:
875        tag_to_key = resource.spec_traffic.TagToKey()
876        percentages = {}
877        for tag in self._new_percentages:
878          try:
879            percentages[tag_to_key[tag]] = self._new_percentages[tag]
880          except KeyError:
881            raise exceptions.ConfigurationError(
882                'There is no revision tagged with [{}] in the traffic allocation for [{}].'
883                .format(tag, resource.name))
884      else:
885        percentages = self._new_percentages
886      resource.spec_traffic.UpdateTraffic(percentages)
887    return resource
888
889
890class TagOnDeployChange(ConfigChanger):
891  """The intent to provide a tag for the revision you're currently deploying."""
892
893  def __init__(self, tag):
894    super(TagOnDeployChange, self).__init__()
895    self._tag = tag
896
897  def Adjust(self, resource):
898    """Gives the revision that's being created the given tag."""
899    tags_to_update = {self._tag: resource.template.name}
900    resource.spec_traffic.UpdateTags(tags_to_update, [], False)
901    return resource
902
903
904class ContainerCommandChange(ConfigChanger):
905  """Represents the user intent to change the 'command' for the container."""
906
907  def __init__(self, command):
908    super(ContainerCommandChange, self).__init__()
909    self._commands = command
910
911  def Adjust(self, resource):
912    resource.template.container.command = self._commands
913    return resource
914
915
916class ContainerArgsChange(ConfigChanger):
917  """Represents the user intent to change the 'args' for the container."""
918
919  def __init__(self, args):
920    super(ContainerArgsChange, self).__init__()
921    self._args = args
922
923  def Adjust(self, resource):
924    resource.template.container.args = self._args
925    return resource
926
927
928_HTTP2_NAME = 'h2c'
929_DEFAULT_PORT = 8080
930
931
932class ContainerPortChange(ConfigChanger):
933  """Represents the user intent to change the port name and/or number."""
934
935  def __init__(self, port=None, use_http2=None):
936    """Initialize a ContainerPortChange.
937
938    Args:
939      port: str, the port number to set the port to, "default" to unset the
940        containerPort field, or None to not modify the port number.
941      use_http2: bool, True to set the port name for http/2, False to unset it,
942        or None to not modify the port name.
943    """
944    super(ContainerPortChange, self).__init__()
945    self._port = port
946    self._http2 = use_http2
947
948  def Adjust(self, resource):
949    """Modify an existing ContainerPort or create a new one."""
950    port_msg = (
951        resource.template.container.ports[0]
952        if resource.template.container.ports else
953        resource.MessagesModule().ContainerPort())
954    # Set port to given value or clear field
955    if self._port == 'default':
956      port_msg.reset('containerPort')
957    elif self._port is not None:
958      port_msg.containerPort = int(self._port)
959    # Set name for http/2 or clear field
960    if self._http2:
961      port_msg.name = _HTTP2_NAME
962    elif self._http2 is not None:
963      port_msg.reset('name')
964    # A port number must be specified
965    if port_msg.name and not port_msg.containerPort:
966      port_msg.containerPort = _DEFAULT_PORT
967
968    # Use the ContainerPort iff it's not empty
969    if port_msg.containerPort:
970      resource.template.container.ports = [port_msg]
971    else:
972      resource.template.container.reset('ports')
973    return resource
974
975
976class SpecChange(ConfigChanger):
977  """Represents the user intent to update field in the resource's spec."""
978
979  def __init__(self, field, value):
980    super(SpecChange, self).__init__()
981    self._field = field
982    self._value = value
983
984  def Adjust(self, resource):
985    setattr(resource.spec, self._field, self._value)
986    return resource
987
988
989class JobMaxAttemptsChange(ConfigChanger):
990  """Represents the user intent to update a job's restart policy."""
991
992  def __init__(self, max_attempts):
993    super(JobMaxAttemptsChange, self).__init__()
994    self._max_attempts = max_attempts
995
996  def Adjust(self, resource):
997    if self._max_attempts == 1:
998      resource.template.restart_policy = job.RestartPolicy.NEVER
999      resource.backoff_limit = 0
1000    else:
1001      resource.template.restart_policy = job.RestartPolicy.ON_FAILURE
1002      resource.backoff_limit = self._max_attempts - 1
1003    return resource
1004