2352 lines
71 KiB
Python
2352 lines
71 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2018 Google LLC. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Class for representing various changes to a Configuration."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import annotations
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
|
|
import abc
|
|
import argparse
|
|
import collections
|
|
from collections.abc import Collection, Container, Iterable, Mapping, MutableMapping
|
|
import copy
|
|
import dataclasses
|
|
import itertools
|
|
import json
|
|
import types
|
|
from typing import Any, ClassVar
|
|
|
|
from googlecloudsdk.api_lib.run import container_resource
|
|
from googlecloudsdk.api_lib.run import job
|
|
from googlecloudsdk.api_lib.run import k8s_object
|
|
from googlecloudsdk.api_lib.run import revision
|
|
from googlecloudsdk.api_lib.run import service
|
|
from googlecloudsdk.api_lib.run import worker_pool
|
|
from googlecloudsdk.calliope import base
|
|
from googlecloudsdk.command_lib.run import exceptions
|
|
from googlecloudsdk.command_lib.run import name_generator
|
|
from googlecloudsdk.command_lib.run import platforms
|
|
from googlecloudsdk.command_lib.run import secrets_mapping
|
|
from googlecloudsdk.command_lib.run import volumes
|
|
from googlecloudsdk.command_lib.util.args import labels_util
|
|
from googlecloudsdk.command_lib.util.args import repeated
|
|
from googlecloudsdk.generated_clients.apis.run.v1 import run_v1_messages
|
|
import six
|
|
|
|
|
|
class ConfigChanger(six.with_metaclass(abc.ABCMeta, object)):
|
|
"""An abstract class representing configuration changes."""
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def adjusts_template(self):
|
|
"""Returns True if any template-level changes should be made."""
|
|
|
|
@abc.abstractmethod
|
|
def Adjust(self, resource):
|
|
"""Adjust the given Service configuration.
|
|
|
|
Args:
|
|
resource: the k8s_object to adjust.
|
|
|
|
Returns:
|
|
A k8s_object that reflects applying the requested update.
|
|
May be resource after a mutation or a different object.
|
|
"""
|
|
|
|
|
|
class NonTemplateConfigChanger(ConfigChanger):
|
|
"""An abstract class representing non-template configuration changes."""
|
|
|
|
@property
|
|
def adjusts_template(self):
|
|
return False
|
|
|
|
|
|
class TemplateConfigChanger(ConfigChanger):
|
|
"""An abstract class representing template configuration changes."""
|
|
|
|
@property
|
|
def adjusts_template(self):
|
|
return True
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ContainerConfigChanger(TemplateConfigChanger):
|
|
"""An abstract class representing container configuration changes.
|
|
|
|
Attributes:
|
|
container_name: Name of the container to modify. If None the primary
|
|
container is modified.
|
|
"""
|
|
|
|
container_name: str | None = None
|
|
|
|
@abc.abstractmethod
|
|
def AdjustContainer(
|
|
self,
|
|
container: container_resource.Container,
|
|
messages_mod: types.ModuleType,
|
|
):
|
|
"""Mutate the given container.
|
|
|
|
This method is called by this class's Adjust method and should apply the
|
|
desired changes directly to container.
|
|
|
|
Args:
|
|
container: the container to adjust.
|
|
messages_mod: Run v1 messages module.
|
|
"""
|
|
|
|
def Adjust(self, resource: container_resource.ContainerResource):
|
|
"""Returns a modified resource.
|
|
|
|
Adjusts resource by applying changes to the container specified by
|
|
self.container_name if present or the primary container otherwise. Calls
|
|
AdjustContainer to apply changes to the selected container.
|
|
|
|
Args:
|
|
resource: The resoure to modify.
|
|
"""
|
|
if self.container_name is not None:
|
|
container = resource.template.containers[self.container_name]
|
|
else:
|
|
container = resource.template.container
|
|
|
|
self.AdjustContainer(container, resource.MessagesModule())
|
|
return resource
|
|
|
|
|
|
def WithChanges(resource, changes):
|
|
"""Apply ConfigChangers to resource.
|
|
|
|
It's undefined whether the input resource is modified.
|
|
|
|
Args:
|
|
resource: KubernetesObject, probably a Service.
|
|
changes: List of ConfigChangers.
|
|
|
|
Returns:
|
|
Changed resource.
|
|
"""
|
|
for config_change in changes:
|
|
resource = config_change.Adjust(resource)
|
|
return resource
|
|
|
|
|
|
def AdjustsTemplate(changes):
|
|
"""Returns True if there's any template-level changes."""
|
|
return any([c.adjusts_template for c in changes])
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class LabelChanges(ConfigChanger):
|
|
"""Represents the user intent to modify metadata labels.
|
|
|
|
Attributes:
|
|
diff: Label diff to apply.
|
|
copy_to_revision: A boolean indicating that label changes should be copied
|
|
to the resource's template.
|
|
"""
|
|
|
|
_LABELS_NOT_ALLOWED_IN_REVISION: ClassVar[frozenset[str]] = frozenset(
|
|
[service.ENDPOINT_VISIBILITY]
|
|
)
|
|
|
|
diff: labels_util.Diff
|
|
copy_to_revision: bool = True
|
|
|
|
@property
|
|
def adjusts_template(self):
|
|
return self.copy_to_revision
|
|
|
|
def Adjust(self, resource):
|
|
# Currently assumes all "system"-owned labels are applied by the control
|
|
# plane and it's ok for us to clear them on the client.
|
|
update_result = self.diff.Apply(
|
|
k8s_object.Meta(resource.MessagesModule()).LabelsValue,
|
|
resource.metadata.labels,
|
|
)
|
|
maybe_new_labels = update_result.GetOrNone()
|
|
if maybe_new_labels:
|
|
resource.metadata.labels = maybe_new_labels
|
|
# For job, resource.template points to task template which has no
|
|
# metadata. Use job specific execution_template instead.
|
|
template = (
|
|
resource.execution_template
|
|
if hasattr(resource, 'execution_template')
|
|
else resource.template
|
|
)
|
|
if self.copy_to_revision and hasattr(template, 'labels'):
|
|
# Service labels are the source of truth and *overwrite* revision labels
|
|
# See go/run-labels-prd for deets.
|
|
# However, we need to preserve the nonce if there is one.
|
|
nonce = template.labels.get(revision.NONCE_LABEL)
|
|
template.metadata.labels = copy.deepcopy(maybe_new_labels)
|
|
for label_to_remove in self._LABELS_NOT_ALLOWED_IN_REVISION:
|
|
if label_to_remove in template.labels:
|
|
del template.labels[label_to_remove]
|
|
if nonce:
|
|
template.labels[revision.NONCE_LABEL] = nonce
|
|
return resource
|
|
|
|
|
|
class JobNonceChange(TemplateConfigChanger):
|
|
"""Adds a new nonce to the job template, for forcing an image pull."""
|
|
|
|
def Adjust(self, resource):
|
|
resource.execution_template.labels[job.NONCE_LABEL] = (
|
|
name_generator.GenerateName(3, separator='_')
|
|
)
|
|
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ReplaceJobChange(NonTemplateConfigChanger):
|
|
"""Represents the user intent to replace the job.
|
|
|
|
Attributes:
|
|
new_job: New job that will replace the existing job.
|
|
"""
|
|
|
|
new_job: job.Job
|
|
|
|
def Adjust(self, resource):
|
|
"""Returns a replacement for resource.
|
|
|
|
The returned job is the job provided to the constructor. If
|
|
resource.metadata.resourceVersion is not empty, has metadata.resourceVersion
|
|
of returned job set to this value.
|
|
|
|
Args:
|
|
resource: job.Job, The job to adjust.
|
|
"""
|
|
if resource.metadata.resourceVersion:
|
|
self.new_job.metadata.resourceVersion = resource.metadata.resourceVersion
|
|
return self.new_job
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ReplaceServiceChange(NonTemplateConfigChanger):
|
|
"""Represents the user intent to replace the service.
|
|
|
|
Attributes:
|
|
new_service: New service that will replace the existing service.
|
|
"""
|
|
|
|
new_service: service.Service
|
|
|
|
def Adjust(self, resource):
|
|
"""Returns a replacement for resource.
|
|
|
|
The returned service is the service provided to the constructor. If
|
|
resource.metadata.resourceVersion is not empty, has metadata.resourceVersion
|
|
of returned service set to this value.
|
|
|
|
Args:
|
|
resource: service.Service, The service to adjust.
|
|
"""
|
|
if resource.metadata.resourceVersion:
|
|
self.new_service.metadata.resourceVersion = (
|
|
resource.metadata.resourceVersion
|
|
)
|
|
# Knative will complain if you try to edit (incl remove) serving annots.
|
|
# So replicate them here.
|
|
for k, v in resource.annotations.items():
|
|
if k.startswith(k8s_object.SERVING_GROUP):
|
|
self.new_service.annotations[k] = v
|
|
return self.new_service
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ReplaceWorkerPoolChange(NonTemplateConfigChanger):
|
|
"""Represents the user intent to replace the worker pool.
|
|
|
|
Attributes:
|
|
new_worker_pool: New worker pool that will replace the existing worker pool.
|
|
"""
|
|
|
|
new_worker_pool: worker_pool.WorkerPool
|
|
|
|
def Adjust(self, resource):
|
|
"""Returns a replacement for resource.
|
|
|
|
The returned worker pool is the worker pool provided to the constructor. If
|
|
resource.metadata.resourceVersion is not empty, has metadata.resourceVersion
|
|
of returned worker pool set to this value.
|
|
|
|
Args:
|
|
resource: worker_pool.WorkerPool, The worker pool to adjust.
|
|
"""
|
|
if resource.metadata.resourceVersion:
|
|
self.new_worker_pool.metadata.resourceVersion = (
|
|
resource.metadata.resourceVersion
|
|
)
|
|
return self.new_worker_pool
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True, init=False)
|
|
class EndpointVisibilityChange(LabelChanges):
|
|
"""Represents the user intent to modify the endpoint visibility.
|
|
|
|
Only applies to Cloud Run for Anthos.
|
|
"""
|
|
|
|
endpoint_visibility: dataclasses.InitVar[bool]
|
|
|
|
def __init__(self, endpoint_visibility):
|
|
"""Determine label changes for modifying endpoint visibility.
|
|
|
|
Args:
|
|
endpoint_visibility: bool, True if Cloud Run on GKE service should only be
|
|
addressable from within the cluster. False if it should be publicly
|
|
addressable.
|
|
"""
|
|
if endpoint_visibility:
|
|
diff = labels_util.Diff(
|
|
additions={service.ENDPOINT_VISIBILITY: service.CLUSTER_LOCAL}
|
|
)
|
|
else:
|
|
diff = labels_util.Diff(subtractions=[service.ENDPOINT_VISIBILITY])
|
|
# Don't copy this label to the revision because it's not supported there.
|
|
# See b/154664962.
|
|
super().__init__(diff, False)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SetAnnotationChange(NonTemplateConfigChanger):
|
|
"""Represents the user intent to set an annotation.
|
|
|
|
Attributes:
|
|
key: Annotation to set.
|
|
value: Annotation value to set.
|
|
"""
|
|
|
|
key: str
|
|
value: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.annotations[self.key] = self.value
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class DeleteAnnotationChange(NonTemplateConfigChanger):
|
|
"""Represents the user intent to delete an annotation.
|
|
|
|
Attributes:
|
|
key: Annotation to delete.
|
|
"""
|
|
|
|
key: str
|
|
|
|
def Adjust(self, resource):
|
|
annotations = resource.annotations
|
|
if self.key in annotations:
|
|
del annotations[self.key]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SetTemplateAnnotationChange(TemplateConfigChanger):
|
|
"""Represents the user intent to set a template annotation.
|
|
|
|
Attributes:
|
|
key: Template annotation to set.
|
|
value: Annotation value to set.
|
|
"""
|
|
|
|
key: str
|
|
value: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[self.key] = self.value
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class DeleteTemplateAnnotationChange(TemplateConfigChanger):
|
|
"""Represents the user intent to delete a template annotation.
|
|
|
|
Attributes:
|
|
key: Template annotation to delete.
|
|
"""
|
|
|
|
key: str
|
|
|
|
def Adjust(self, resource):
|
|
annotations = resource.template.annotations
|
|
if self.key in annotations:
|
|
del annotations[self.key]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class BaseImagesAnnotationChange(TemplateConfigChanger):
|
|
"""Represents the user intent to update the 'base-images' template annotation.
|
|
|
|
The value of the annotation is a string representation of a json map of
|
|
container_name -> base_image_url. E.g.: '{"mycontainer":"my_base_image_url"}'.
|
|
|
|
Attributes:
|
|
updates: {container:url} map of values that need to be added/updated
|
|
deletes: List of containers whose base image url needs to be deleted.
|
|
"""
|
|
|
|
updates: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
deletes: list[str] = dataclasses.field(default_factory=list)
|
|
|
|
def _mergeBaseImageUrls(
|
|
self,
|
|
resource: revision.Revision,
|
|
existing_base_image_urls: dict[str, str],
|
|
updates: dict[str, str],
|
|
deletes: list[str],
|
|
):
|
|
|
|
if deletes:
|
|
for container in deletes:
|
|
if container in existing_base_image_urls:
|
|
del existing_base_image_urls[container]
|
|
if updates:
|
|
for container, url in updates.items():
|
|
existing_base_image_urls[container] = url
|
|
return self._constructBaseImageUrls(resource, existing_base_image_urls)
|
|
|
|
def _constructBaseImageUrls(
|
|
self, resource: revision.Revision, urls: dict[str, str]
|
|
):
|
|
containers = frozenset(
|
|
[x or '' for x in resource.template.containers.keys()]
|
|
)
|
|
base_images_str = ', '.join(
|
|
f'"{key}":"{value}"' for key, value in urls.items() if key in containers
|
|
)
|
|
return '{' + base_images_str + '}' if base_images_str else ''
|
|
|
|
def Adjust(self, resource: revision.Revision):
|
|
"""Updates the revision to use automatic base image updates."""
|
|
|
|
annotations = resource.template.annotations
|
|
existing_value = annotations.get(revision.BASE_IMAGES_ANNOTATION, '')
|
|
|
|
if existing_value:
|
|
existing_base_image_urls = json.loads(existing_value)
|
|
new_value = self._mergeBaseImageUrls(
|
|
resource, existing_base_image_urls, self.updates, self.deletes
|
|
)
|
|
else:
|
|
new_value = self._constructBaseImageUrls(resource, self.updates)
|
|
|
|
if new_value:
|
|
resource.template.annotations[revision.BASE_IMAGES_ANNOTATION] = new_value
|
|
resource.template.spec.runtimeClassName = (
|
|
revision.BASE_IMAGE_UPDATE_RUNTIME_CLASS_NAME
|
|
)
|
|
elif revision.BASE_IMAGES_ANNOTATION in annotations:
|
|
del resource.template.annotations[revision.BASE_IMAGES_ANNOTATION]
|
|
if (
|
|
resource.template.spec.runtimeClassName
|
|
== revision.BASE_IMAGE_UPDATE_RUNTIME_CLASS_NAME
|
|
):
|
|
resource.template.spec.runtimeClassName = ''
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SourcesAnnotationChange(TemplateConfigChanger):
|
|
"""Represents the user intent to update the 'sources' template annotation.
|
|
|
|
The value of the annotation is a string representation of a json map of
|
|
container_name -> GCS objects. E.g.: '{"foo":"gs://my-bucket/my-object"}'.
|
|
|
|
Attributes:
|
|
updates: {container:url} map of values that need to be added/updated
|
|
deletes: List of containers whose source needs to be deleted.
|
|
"""
|
|
|
|
updates: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
deletes: list[str] = dataclasses.field(default_factory=list)
|
|
|
|
def _mergeSources(
|
|
self,
|
|
resource: revision.Revision,
|
|
existing_sources: dict[str, str],
|
|
updates: dict[str, str],
|
|
deletes: list[str],
|
|
):
|
|
if deletes:
|
|
for container in deletes:
|
|
if container in existing_sources:
|
|
del existing_sources[container]
|
|
if updates:
|
|
for container, url in updates.items():
|
|
existing_sources[container] = url
|
|
return self._constructSources(resource, existing_sources)
|
|
|
|
def _constructSources(
|
|
self, resource: revision.Revision, urls: dict[str, str]
|
|
):
|
|
containers = frozenset(
|
|
[x or '' for x in resource.template.containers.keys()]
|
|
)
|
|
return json.dumps(
|
|
{x: y for x, y in urls.items() if x in containers},
|
|
separators=(',', ':'),
|
|
)
|
|
|
|
def Adjust(self, resource: revision.Revision):
|
|
"""Updates the revision to use zip deploys."""
|
|
|
|
annotations = resource.template.annotations
|
|
existing_value = annotations.get(revision.SOURCES_ANNOTATION, '')
|
|
|
|
if existing_value:
|
|
existing_sources = json.loads(existing_value)
|
|
new_value = self._mergeSources(
|
|
resource, existing_sources, self.updates, self.deletes
|
|
)
|
|
else:
|
|
new_value = self._constructSources(resource, self.updates)
|
|
|
|
if new_value and new_value != '{}':
|
|
resource.template.annotations[revision.SOURCES_ANNOTATION] = new_value
|
|
elif revision.SOURCES_ANNOTATION in annotations:
|
|
del resource.template.annotations[revision.SOURCES_ANNOTATION]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class IngressContainerBaseImagesAnnotationChange(BaseImagesAnnotationChange):
|
|
"""Represents the user intent to update the 'base-images' template annotation.
|
|
|
|
The value of the annotation is a string representation of a json map of
|
|
container_name -> base_image_url. E.g.: '{"mycontainer":"my_base_image_url"}'.
|
|
|
|
This class changes the base image annotation for the default container, which
|
|
is either the container in a service with one container or the one with a port
|
|
set in a service with multiple containers.
|
|
|
|
Attributes:
|
|
base_image: url of the base image for the default container or None
|
|
"""
|
|
|
|
base_image: str | None = None
|
|
|
|
def Adjust(self, resource: revision.Revision):
|
|
"""Updates the revision to use automatic base image updates."""
|
|
|
|
if self.base_image:
|
|
self.updates[resource.template.container.name or ''] = self.base_image
|
|
else:
|
|
self.deletes.append(resource.template.container.name or '')
|
|
|
|
return super().Adjust(resource)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SetLaunchStageAnnotationChange(NonTemplateConfigChanger):
|
|
"""Sets launch stage annotation on a resource.
|
|
|
|
Attributes:
|
|
launch_stage: The launch stage to set.
|
|
"""
|
|
|
|
launch_stage: base.ReleaseTrack
|
|
|
|
def Adjust(self, resource):
|
|
if self.launch_stage == base.ReleaseTrack.GA:
|
|
return resource
|
|
else:
|
|
resource.annotations[k8s_object.LAUNCH_STAGE_ANNOTATION] = (
|
|
self.launch_stage.id
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SetRegionsAnnotationChange(NonTemplateConfigChanger):
|
|
"""Sets regions annotation on a resource.
|
|
|
|
Attributes:
|
|
regions: A comma-separated list of regions.
|
|
"""
|
|
|
|
regions: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.annotations[k8s_object.MULTI_REGION_REGIONS_ANNOTATION] = (
|
|
self.regions
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RegionsChangeAnnotationChange(NonTemplateConfigChanger):
|
|
"""Adds or removes regions annotation on an existing service.
|
|
|
|
Attributes:
|
|
existing: the existing Service.
|
|
to_add: A comma-separated list of regions to add to existing.
|
|
to_remove: A comma-separated list of regions to remove from existing.
|
|
"""
|
|
|
|
to_add: str
|
|
to_remove: str
|
|
|
|
def Adjust(self, resource):
|
|
final_list = self.GetFinalList(resource)
|
|
resource.annotations[k8s_object.MULTI_REGION_REGIONS_ANNOTATION] = ','.join(
|
|
final_list
|
|
)
|
|
return resource
|
|
|
|
def GetFinalList(self, resource):
|
|
"""Returns the final list of regions after applying the changes."""
|
|
annotation = (
|
|
resource.annotations.get(k8s_object.MULTI_REGION_REGIONS_ANNOTATION)
|
|
or None
|
|
)
|
|
existing = annotation.split(',') if annotation else []
|
|
to_add = self.to_add.split(',') if self.to_add else []
|
|
to_remove = self.to_remove.split(',') if self.to_remove else []
|
|
final_list = [x for x in existing if x not in to_remove]
|
|
final_list.extend([x for x in to_add if x not in existing])
|
|
return final_list
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class MultiRegionDomainNameChange(NonTemplateConfigChanger):
|
|
"""Sets a multi-region domain name annotation on the service.
|
|
|
|
Attributes:
|
|
domain_name: The sandbox annotation value to set.
|
|
"""
|
|
|
|
domain_name: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.annotations[k8s_object.GCLB_DOMAIN_NAME_ANNOTATION] = (
|
|
self.domain_name
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SetClientNameAndVersionAnnotationChange(ConfigChanger):
|
|
"""Sets the client name and version annotations.
|
|
|
|
Attributes:
|
|
client_name: Client name to set.
|
|
client_version: Client version to set.
|
|
set_on_template: A boolean indicating whether the client name and version
|
|
annotations should be set on the resource template as well.
|
|
"""
|
|
|
|
client_name: str
|
|
client_version: str
|
|
set_on_template: bool = True
|
|
|
|
@property
|
|
def adjusts_template(self):
|
|
return self.set_on_template
|
|
|
|
def Adjust(self, resource):
|
|
if self.client_name is not None:
|
|
resource.annotations[k8s_object.CLIENT_NAME_ANNOTATION] = self.client_name
|
|
if self.set_on_template and hasattr(resource.template, 'annotations'):
|
|
resource.template.annotations[k8s_object.CLIENT_NAME_ANNOTATION] = (
|
|
self.client_name
|
|
)
|
|
if self.client_version is not None:
|
|
resource.annotations[k8s_object.CLIENT_VERSION_ANNOTATION] = (
|
|
self.client_version
|
|
)
|
|
if self.set_on_template and hasattr(resource.template, 'annotations'):
|
|
resource.template.annotations[k8s_object.CLIENT_VERSION_ANNOTATION] = (
|
|
self.client_version
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SandboxChange(TemplateConfigChanger):
|
|
"""Sets a sandbox annotation on the service.
|
|
|
|
Attributes:
|
|
sandbox: The sandbox annotation value to set.
|
|
"""
|
|
|
|
sandbox: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[container_resource.SANDBOX_ANNOTATION] = (
|
|
self.sandbox
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class VpcConnectorChange(TemplateConfigChanger):
|
|
"""Sets a VPC connector annotation on the service.
|
|
|
|
Attributes:
|
|
connector_name: The VPC connector name to set in the annotation.
|
|
"""
|
|
|
|
connector_name: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[container_resource.VPC_ACCESS_ANNOTATION] = (
|
|
self.connector_name
|
|
)
|
|
return resource
|
|
|
|
|
|
class ClearVpcConnectorChange(TemplateConfigChanger):
|
|
"""Clears a VPC connector annotation on the service."""
|
|
|
|
def Adjust(self, resource):
|
|
annotations = resource.template.annotations
|
|
if container_resource.VPC_ACCESS_ANNOTATION in annotations:
|
|
del annotations[container_resource.VPC_ACCESS_ANNOTATION]
|
|
if container_resource.EGRESS_SETTINGS_ANNOTATION in annotations:
|
|
del annotations[container_resource.EGRESS_SETTINGS_ANNOTATION]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(init=False, frozen=True)
|
|
class ImageChange(ContainerConfigChanger):
|
|
"""A Cloud Run container deployment.
|
|
|
|
Attributes:
|
|
image: The image to set in the adjusted container.
|
|
"""
|
|
|
|
image: str
|
|
|
|
def __init__(self, image, **kwargs):
|
|
super().__init__(**kwargs)
|
|
object.__setattr__(self, 'image', image)
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
container.image = self.image
|
|
|
|
|
|
def _PruneMapping(
|
|
mapping: MutableMapping[str, str],
|
|
keys_to_remove: Collection[str],
|
|
clear_others: bool,
|
|
):
|
|
if clear_others:
|
|
mapping.clear()
|
|
else:
|
|
for var_or_path in keys_to_remove:
|
|
if var_or_path in mapping:
|
|
del mapping[var_or_path]
|
|
|
|
|
|
def _PruneManagedVolumeMapping(
|
|
resource,
|
|
res_volumes,
|
|
volume_mounts: MutableMapping[str, str],
|
|
removes: Collection[str],
|
|
clear_others: bool,
|
|
external_mounts: Container[str],
|
|
):
|
|
"""Remove the specified volume mappings from the config."""
|
|
if clear_others:
|
|
volume_mounts.clear()
|
|
else:
|
|
for remove in removes:
|
|
mount, path = remove.rsplit('/', 1)
|
|
if mount in volume_mounts:
|
|
volume_name = volume_mounts[mount]
|
|
if volume_name in external_mounts:
|
|
volume_name = _CopyToNewVolume(
|
|
resource,
|
|
volume_name,
|
|
mount,
|
|
copy.deepcopy(res_volumes[volume_name]),
|
|
res_volumes,
|
|
volume_mounts,
|
|
)
|
|
new_paths = []
|
|
for key_to_path in res_volumes[volume_name].items:
|
|
if path != key_to_path.path:
|
|
new_paths.append(key_to_path)
|
|
if not new_paths:
|
|
# there are no more versions in the volume
|
|
del volume_mounts[mount]
|
|
else:
|
|
res_volumes[volume_name].items = new_paths
|
|
|
|
|
|
def _CopyToNewVolume(
|
|
resource,
|
|
volume_name,
|
|
mount_point,
|
|
volume_source,
|
|
res_volumes,
|
|
volume_mounts,
|
|
):
|
|
"""Copies existing volume to volume with a new name."""
|
|
new_volume_name = _UniqueVolumeName(
|
|
volume_source.secretName, resource.template.volumes
|
|
)
|
|
try:
|
|
volume_mounts[mount_point] = new_volume_name
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update mount [{}] because its mounted volume '
|
|
'is of a different source type.'.format(mount_point)
|
|
)
|
|
# the volume does not exist so we need a new one
|
|
new_paths = {item.path for item in volume_source.items}
|
|
old_volume = res_volumes[volume_name]
|
|
for item in old_volume.items:
|
|
if item.path not in new_paths:
|
|
volume_source.items.append(item)
|
|
res_volumes[new_volume_name] = volume_source
|
|
return new_volume_name
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class EnvVarLiteralChanges(ContainerConfigChanger):
|
|
"""Represents the user intent to modify environment variables string literals.
|
|
|
|
Attributes:
|
|
updates: Updated env var names and values to set.
|
|
removes: Env vars to remove.
|
|
clear_others: If true clear all non-updated env vars.
|
|
"""
|
|
|
|
updates: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
removes: Collection[str] = dataclasses.field(default_factory=list)
|
|
clear_others: bool = False
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
"""Mutates the given config's env vars to match the desired changes.
|
|
|
|
Args:
|
|
container: container to adjust
|
|
messages_mod: messages module
|
|
|
|
Returns:
|
|
The adjusted container
|
|
|
|
Raises:
|
|
ConfigurationError if there's an attempt to replace the source of an
|
|
existing environment variable whose source is of a different type
|
|
(e.g. env var's secret source can't be replaced with a config map
|
|
source).
|
|
"""
|
|
_PruneMapping(container.env_vars.literals, self.removes, self.clear_others)
|
|
|
|
try:
|
|
container.env_vars.literals.update(self.updates)
|
|
except KeyError as e:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update environment variable [{}] to string literal '
|
|
'because it has already been set with a different type.'.format(
|
|
e.args[0]
|
|
)
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SecretEnvVarChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to modify environment variable secrets.
|
|
|
|
Attributes:
|
|
updates: Env var names and values to update.
|
|
removes: List of env vars to remove.
|
|
clear_others: If true clear all non-updated env vars.
|
|
container_name: Name of the container to update. If None, the resource's
|
|
primary container is update.
|
|
"""
|
|
|
|
updates: Mapping[str, secrets_mapping.ReachableSecret]
|
|
removes: Collection[str]
|
|
clear_others: bool
|
|
container_name: str | None = None
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's env vars to match the desired changes.
|
|
|
|
Args:
|
|
resource: k8s_object to adjust
|
|
|
|
Returns:
|
|
The adjusted resource
|
|
|
|
Raises:
|
|
ConfigurationError if there's an attempt to replace the source of an
|
|
existing environment variable whose source is of a different type
|
|
(e.g. env var's secret source can't be replaced with a config map
|
|
source).
|
|
"""
|
|
if self.container_name:
|
|
env_vars = resource.template.containers[
|
|
self.container_name
|
|
].env_vars.secrets
|
|
else:
|
|
env_vars = resource.template.env_vars.secrets
|
|
_PruneMapping(env_vars, self.removes, self.clear_others)
|
|
|
|
for name, reachable_secret in self.updates.items():
|
|
try:
|
|
env_vars[name] = reachable_secret.AsEnvVarSource(resource)
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update environment variable [{}] to the given type '
|
|
'because it has already been set with a different type.'.format(
|
|
name
|
|
)
|
|
)
|
|
secrets_mapping.PruneAnnotation(resource)
|
|
return resource
|
|
|
|
|
|
class ConfigMapEnvVarChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to modify environment variable config maps."""
|
|
|
|
def __init__(self, updates, removes, clear_others):
|
|
"""Initialize a new ConfigMapEnvVarChanges object.
|
|
|
|
Args:
|
|
updates: {str, str}, Update env var names and values.
|
|
removes: [str], List of env vars to remove.
|
|
clear_others: bool, If true, clear all non-updated env vars.
|
|
|
|
Raises:
|
|
ConfigurationError if a key hasn't been provided for a source.
|
|
"""
|
|
super().__init__()
|
|
self._updates = {}
|
|
for name, v in updates.items():
|
|
# Split the given values into 2 parts:
|
|
# [env var source name, source data item key]
|
|
value = v.split(':', 1)
|
|
if len(value) < 2:
|
|
value.append(self._OmittedSecretKeyDefault(name))
|
|
self._updates[name] = value
|
|
self._removes = removes
|
|
self._clear_others = clear_others
|
|
|
|
def _OmittedSecretKeyDefault(self, name):
|
|
if platforms.IsManaged():
|
|
return 'latest'
|
|
raise exceptions.ConfigurationError(
|
|
'Missing required item key for environment variable [{}].'.format(name)
|
|
)
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's env vars to match the desired changes.
|
|
|
|
Args:
|
|
resource: k8s_object to adjust
|
|
|
|
Returns:
|
|
The adjusted resource
|
|
|
|
Raises:
|
|
ConfigurationError if there's an attempt to replace the source of an
|
|
existing environment variable whose source is of a different type
|
|
(e.g. env var's secret source can't be replaced with a config map
|
|
source).
|
|
"""
|
|
env_vars = resource.template.env_vars.config_maps
|
|
_PruneMapping(env_vars, self._removes, self._clear_others)
|
|
|
|
for name, (source_name, source_key) in self._updates.items():
|
|
try:
|
|
env_vars[name] = self._MakeEnvVarSource(
|
|
resource.MessagesModule(), source_name, source_key
|
|
)
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update environment variable [{}] to the given type '
|
|
'because it has already been set with a different type.'.format(
|
|
name
|
|
)
|
|
)
|
|
return resource
|
|
|
|
def _MakeEnvVarSource(self, messages, name, key):
|
|
return messages.EnvVarSource(
|
|
configMapKeyRef=messages.ConfigMapKeySelector(name=name, key=key)
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ResourceChanges(ContainerConfigChanger):
|
|
"""Represents the user intent to update resource limits.
|
|
|
|
Attributes:
|
|
memory: Updated memory limit to set in the container. Specified as string
|
|
ending in 'Mi' or 'Gi'. If None the memory limit is not changed.
|
|
cpu: Updated cpu limit to set in the container if not None.
|
|
gpu: Updated gpu limit to set in the container if not None.
|
|
"""
|
|
|
|
memory: str | None = None
|
|
cpu: str | None = None
|
|
gpu: str | None = None
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
"""Mutates the given config's resource limits to match what's desired."""
|
|
if self.memory is not None:
|
|
container.resource_limits['memory'] = self.memory
|
|
if self.cpu is not None:
|
|
container.resource_limits['cpu'] = self.cpu
|
|
if self.gpu is not None:
|
|
if self.gpu == '0':
|
|
container.resource_limits.pop('nvidia.com/gpu', None)
|
|
else:
|
|
container.resource_limits['nvidia.com/gpu'] = self.gpu
|
|
|
|
|
|
def _MakeProbe(
|
|
messages: types.ModuleType, settings: dict[str, str]
|
|
) -> run_v1_messages.Probe:
|
|
"""Creates a probe from the given settings.
|
|
|
|
Args:
|
|
messages: Run v1 messages module.
|
|
settings: a dict of settings for the probe.
|
|
|
|
Returns:
|
|
A new Run v1 probe.
|
|
"""
|
|
|
|
def _ParseInt(settings, key):
|
|
if not settings[key]:
|
|
return None
|
|
try:
|
|
return int(settings[key])
|
|
except ValueError:
|
|
raise exceptions.ArgumentError(
|
|
'Value for key [{}] must be an integer.'.format(key)
|
|
)
|
|
|
|
probe = messages.Probe()
|
|
for key in settings:
|
|
if key.startswith('tcpSocket'):
|
|
probe.tcpSocket = messages.TCPSocketAction()
|
|
elif key.startswith('httpGet'):
|
|
probe.httpGet = messages.HTTPGetAction()
|
|
elif key.startswith('grpc'):
|
|
probe.grpc = messages.GRPCAction()
|
|
else:
|
|
# Set the basic fields directly.
|
|
setattr(probe, key, _ParseInt(settings, key))
|
|
# TCP
|
|
if 'tcpSocket.port' in settings:
|
|
probe.tcpSocket.port = _ParseInt(settings, 'tcpSocket.port')
|
|
# HTTP
|
|
if 'httpGet.port' in settings:
|
|
probe.httpGet.port = _ParseInt(settings, 'httpGet.port')
|
|
if 'httpGet.path' in settings:
|
|
probe.httpGet.path = settings['httpGet.path']
|
|
# gRPC
|
|
if 'grpc.port' in settings:
|
|
probe.grpc.port = _ParseInt(settings, 'grpc.port')
|
|
if 'grpc.service' in settings:
|
|
probe.grpc.service = settings['grpc.service']
|
|
return probe
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class StartupProbeChanges(ContainerConfigChanger):
|
|
"""Represents the user intent to update startup probe settings.
|
|
|
|
Attributes:
|
|
settings: Values to set in the probe.
|
|
clear: If true, clear the startup probe.
|
|
"""
|
|
|
|
settings: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
clear: bool = False
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
if self.clear:
|
|
container.startupProbe = None
|
|
return
|
|
container.startupProbe = _MakeProbe(messages_mod, self.settings)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class LivenessProbeChanges(ContainerConfigChanger):
|
|
"""Represents the user intent to update liveness probe settings.
|
|
|
|
Attributes:
|
|
settings: values to set in the probe.
|
|
clear: If true, clear the liveness probe.
|
|
"""
|
|
|
|
settings: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
clear: bool = False
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
if self.clear:
|
|
container.livenessProbe = None
|
|
return
|
|
container.livenessProbe = _MakeProbe(messages_mod, self.settings)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ReadinessProbeChanges(ContainerConfigChanger):
|
|
"""Represents the user intent to update readiness probe settings.
|
|
|
|
Attributes:
|
|
settings: values to set in the probe.
|
|
clear: If true, clear the readiness probe.
|
|
"""
|
|
|
|
settings: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
clear: bool = False
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
if self.clear:
|
|
container.readinessProbe = None
|
|
return
|
|
container.readinessProbe = _MakeProbe(messages_mod, self.settings)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class CloudSQLChanges(TemplateConfigChanger):
|
|
"""Represents the intent to update the Cloug SQL instances.
|
|
|
|
Attributes:
|
|
project: Project to use as the default project for Cloud SQL instances.
|
|
region: Region to use as the default region for Cloud SQL instances
|
|
args: Args to the command.
|
|
"""
|
|
|
|
add_cloudsql_instances: list[str]
|
|
remove_cloudsql_instances: list[str]
|
|
set_cloudsql_instances: list[str]
|
|
clear_cloudsql_instances: bool | None = None
|
|
|
|
@classmethod
|
|
def FromArgs(
|
|
cls,
|
|
project: str | None = None,
|
|
region: str | None = None,
|
|
*,
|
|
args: argparse.Namespace,
|
|
):
|
|
"""Returns a CloudSQLChanges object from the given args.
|
|
|
|
Args:
|
|
project: Optional project. If absent project must be specified in each
|
|
Cloud SQL instance.
|
|
region: Optional region. If absent region must be specified in each Cloud
|
|
SQL instance.
|
|
args: Command line args to parse CloudSQL flags from.
|
|
"""
|
|
|
|
def AugmentArgs(arg_name):
|
|
val = getattr(args, arg_name, None)
|
|
if val is None:
|
|
return None
|
|
return [Augment(i) for i in val]
|
|
|
|
def Augment(instance_str):
|
|
instance = instance_str.split(':')
|
|
if len(instance) == 3:
|
|
return ':'.join(instance)
|
|
elif len(instance) == 1:
|
|
if not project:
|
|
raise exceptions.CloudSQLError(
|
|
'To specify a Cloud SQL instance by plain name, you must specify'
|
|
' a project.'
|
|
)
|
|
if not region:
|
|
raise exceptions.CloudSQLError(
|
|
'To specify a Cloud SQL instance by plain name, you must be '
|
|
'deploying to a managed Cloud Run region.'
|
|
)
|
|
return ':'.join(itertools.chain([project, region], instance))
|
|
else:
|
|
raise exceptions.CloudSQLError(
|
|
'Malformed CloudSQL instance string: {}'.format(instance_str)
|
|
)
|
|
|
|
# Augment args so each cloudsql instance gets the region and project.
|
|
return cls(
|
|
add_cloudsql_instances=AugmentArgs('add_cloudsql_instances'),
|
|
remove_cloudsql_instances=AugmentArgs('remove_cloudsql_instances'),
|
|
set_cloudsql_instances=AugmentArgs('set_cloudsql_instances'),
|
|
clear_cloudsql_instances=getattr(
|
|
args, 'clear_cloudsql_instances', None
|
|
),
|
|
)
|
|
|
|
def Adjust(self, resource):
|
|
def GetCurrentInstances():
|
|
annotation_val = resource.template.annotations.get(
|
|
container_resource.CLOUDSQL_ANNOTATION
|
|
)
|
|
if annotation_val:
|
|
return annotation_val.split(',')
|
|
return []
|
|
|
|
instances = repeated.ParsePrimitiveArgs(
|
|
self, 'cloudsql-instances', GetCurrentInstances
|
|
)
|
|
if instances is not None:
|
|
resource.template.annotations[container_resource.CLOUDSQL_ANNOTATION] = (
|
|
','.join(instances)
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ConcurrencyChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to update concurrency preference.
|
|
|
|
Attributes:
|
|
concurrency: The concurrency value to set in the resource template. If None
|
|
concurrency is cleared.
|
|
"""
|
|
|
|
concurrency: int | None = None
|
|
|
|
@classmethod
|
|
def FromFlag(cls, concurrency):
|
|
"""Returns a ConcurrencyChanges object from the --concurrency flag value.
|
|
|
|
Args:
|
|
concurrency: The concurrency flag value. If 'default' concurrency is
|
|
cleared, otherwise should be an integer concurrency value to set.
|
|
"""
|
|
return cls(None if concurrency == 'default' else int(concurrency))
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's resource limits to match what's desired."""
|
|
resource.template.concurrency = self.concurrency
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class TimeoutChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to update request duration.
|
|
|
|
Attributes:
|
|
timeout: The timeout to set in the resource template.
|
|
"""
|
|
|
|
timeout: str
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's timeout to match what's desired."""
|
|
resource.template.timeout = self.timeout
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ServiceAccountChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to change service account for the revision.
|
|
|
|
Attributes:
|
|
service_account: The service account to set.
|
|
"""
|
|
|
|
service_account: str
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's service account to match what's desired."""
|
|
resource.template.service_account = self.service_account
|
|
return resource
|
|
|
|
|
|
_MAX_RESOURCE_NAME_LENGTH = 63
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RevisionNameChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to change revision name.
|
|
|
|
Attributes:
|
|
revision_suffix: Suffix to append to the revision name.
|
|
"""
|
|
|
|
revision_suffix: str
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's revision name to match what's desired."""
|
|
if not self.revision_suffix:
|
|
resource.template.name = ''
|
|
return resource
|
|
|
|
max_prefix_length = (
|
|
_MAX_RESOURCE_NAME_LENGTH - len(self.revision_suffix) - 1
|
|
)
|
|
resource.template.name = '{}-{}'.format(
|
|
resource.name[:max_prefix_length], self.revision_suffix
|
|
)
|
|
return resource
|
|
|
|
|
|
def GenerateVolumeName(prefix):
|
|
"""Randomly generated name with the given prefix."""
|
|
return name_generator.GenerateName(sections=2, separator='-', prefix=prefix)
|
|
|
|
|
|
def _UniqueVolumeName(source_name, existing_volumes):
|
|
"""Generate unique volume name.
|
|
|
|
The names that connect volumes and mounts must be unique even if their
|
|
source volume names match.
|
|
|
|
Args:
|
|
source_name: (Potentially clashing) name.
|
|
existing_volumes: Names in use.
|
|
|
|
Returns:
|
|
Unique name.
|
|
"""
|
|
volume_name = None
|
|
while volume_name is None or volume_name in existing_volumes:
|
|
volume_name = GenerateVolumeName(source_name)
|
|
return volume_name
|
|
|
|
|
|
def _PruneVolumes(mounted_volumes, res_volumes):
|
|
"""Delete all volumes no longer being mounted.
|
|
|
|
Args:
|
|
mounted_volumes: set of volumes mounted in any container
|
|
res_volumes: resource.template.volumes
|
|
"""
|
|
for volume in list(res_volumes):
|
|
if volume not in mounted_volumes:
|
|
del res_volumes[volume]
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SecretVolumeChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to change volumes with secret source types.
|
|
|
|
Attributes:
|
|
updates: Updates to mount path and volume fields.
|
|
removes: List of mount paths to remove.
|
|
clear_others: If true clear all non-updated volumes and mounts of the given
|
|
[volume_type].
|
|
container_name: Name of the container to update.
|
|
"""
|
|
|
|
updates: Mapping[str, secrets_mapping.ReachableSecret]
|
|
removes: Collection[str]
|
|
clear_others: bool
|
|
container_name: str | None = None
|
|
|
|
def _UpdateManagedVolumes(
|
|
self, resource, volume_mounts, res_volumes, external_mounts
|
|
):
|
|
"""Update volumes for Cloud Run. Ensure only one secret per directory."""
|
|
new_volumes = {}
|
|
volumes_to_mounts = collections.defaultdict(list)
|
|
for path, vol in volume_mounts.items():
|
|
volumes_to_mounts[vol].append(path)
|
|
|
|
for file_path, reachable_secret in self.updates.items():
|
|
mount_point = file_path.rsplit('/', 1)[0]
|
|
if mount_point in new_volumes:
|
|
if new_volumes[mount_point].secretName != reachable_secret.secret_name:
|
|
# we don't support subpaths in managed so if there's a second
|
|
# secret in the same directory, error.
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update secret at [{}] because a different secret is '
|
|
'already mounted in the same directory.'.format(file_path)
|
|
)
|
|
reachable_secret.AppendToSecretVolumeSource(
|
|
resource, new_volumes[mount_point]
|
|
)
|
|
else:
|
|
new_volumes[mount_point] = reachable_secret.AsSecretVolumeSource(
|
|
resource
|
|
)
|
|
|
|
for mount_point, volume_source in new_volumes.items():
|
|
if mount_point in volume_mounts:
|
|
volume_name = volume_mounts[mount_point]
|
|
if (
|
|
len(volumes_to_mounts[volume_name]) > 1
|
|
or volume_name in external_mounts
|
|
):
|
|
# the volume is used by more than one path, let's separate it into a
|
|
# separate volume
|
|
volumes_to_mounts[volume_name].remove(mount_point)
|
|
new_name = _CopyToNewVolume(
|
|
resource,
|
|
volume_name,
|
|
mount_point,
|
|
volume_source,
|
|
res_volumes,
|
|
volume_mounts,
|
|
)
|
|
volumes_to_mounts[new_name].append(mount_point)
|
|
continue
|
|
else:
|
|
volume = res_volumes[volume_name]
|
|
if volume.secretName != volume_source.secretName:
|
|
# only allow replacing the secret if all versions are replaced
|
|
existing_paths = {item.path for item in volume.items}
|
|
new_paths = {item.path for item in volume_source.items}
|
|
if not existing_paths.issubset(new_paths):
|
|
raise exceptions.ConfigurationError(
|
|
'Multiple secrets are specified for directory [{}]. Cloud Run'
|
|
' only supports one secret per directory'.format(mount_point)
|
|
)
|
|
else:
|
|
# we need to merge the two
|
|
new_paths = {item.path for item in volume_source.items}
|
|
for item in volume.items:
|
|
# copy over existing paths that are not overridden
|
|
if item.path not in new_paths:
|
|
volume_source.items.append(item)
|
|
else:
|
|
volume_name = _UniqueVolumeName(
|
|
volume_source.secretName, resource.template.volumes
|
|
)
|
|
try:
|
|
volume_mounts[mount_point] = volume_name
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update mount [{}] because its mounted volume '
|
|
'is of a different source type.'.format(mount_point)
|
|
)
|
|
# the volume does not exist so we need a new one
|
|
res_volumes[volume_name] = volume_source
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's volumes to match the desired changes.
|
|
|
|
Args:
|
|
resource: k8s_object to adjust
|
|
|
|
Returns:
|
|
The adjusted resource
|
|
|
|
Raises:
|
|
ConfigurationError if there's an attempt to replace the volume a mount
|
|
points to whose existing volume has a source of a different type than
|
|
the new volume (e.g. mount that points to a volume with a secret source
|
|
can't be replaced with a volume that has a config map source).
|
|
"""
|
|
if self.container_name:
|
|
container = resource.template.containers[self.container_name]
|
|
else:
|
|
container = resource.template.container
|
|
volume_mounts = container.volume_mounts.secrets
|
|
res_volumes = resource.template.volumes.secrets
|
|
external_mounts = frozenset(
|
|
itertools.chain.from_iterable(
|
|
external_container.volume_mounts.secrets.values()
|
|
for name, external_container in resource.template.containers.items()
|
|
if name != container.name
|
|
)
|
|
)
|
|
|
|
if platforms.IsManaged():
|
|
_PruneManagedVolumeMapping(
|
|
resource,
|
|
res_volumes,
|
|
volume_mounts,
|
|
self.removes,
|
|
self.clear_others,
|
|
external_mounts,
|
|
)
|
|
else:
|
|
removes = self.removes
|
|
_PruneMapping(volume_mounts, removes, self.clear_others)
|
|
if platforms.IsManaged():
|
|
self._UpdateManagedVolumes(
|
|
resource, volume_mounts, res_volumes, external_mounts
|
|
)
|
|
else:
|
|
for file_path, reachable_secret in self.updates.items():
|
|
volume_name = _UniqueVolumeName(
|
|
reachable_secret.secret_name, resource.template.volumes
|
|
)
|
|
|
|
# volume_mounts is a special mapping that filters for the current kind
|
|
# of mount and KeyErrors on existing keys with other types.
|
|
try:
|
|
mount_point = file_path
|
|
volume_mounts[mount_point] = volume_name
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update mount [{}] because its mounted volume '
|
|
'is of a different source type.'.format(file_path)
|
|
)
|
|
res_volumes[volume_name] = reachable_secret.AsSecretVolumeSource(
|
|
resource
|
|
)
|
|
|
|
_PruneVolumes(external_mounts.union(volume_mounts.values()), res_volumes)
|
|
|
|
secrets_mapping.PruneAnnotation(resource)
|
|
return resource
|
|
|
|
|
|
class ConfigMapVolumeChanges(TemplateConfigChanger):
|
|
"""Represents the user intent to change volumes with config map source types."""
|
|
|
|
def __init__(self, updates, removes, clear_others):
|
|
"""Initialize a new ConfigMapVolumeChanges object.
|
|
|
|
Args:
|
|
updates: {str, [str, str]}, Update mount path and volume fields.
|
|
removes: [str], List of mount paths to remove.
|
|
clear_others: bool, If true, clear all non-updated volumes and mounts of
|
|
the given [volume_type].
|
|
"""
|
|
super().__init__()
|
|
self._updates = {}
|
|
for k, v in updates.items():
|
|
# Split the given values into 2 parts:
|
|
# [volume source name, data item key]
|
|
update_value = v.split(':', 1)
|
|
# Pad with None if no data item key specified
|
|
if len(update_value) < 2:
|
|
update_value.append(None)
|
|
self._updates[k] = update_value
|
|
self._removes = removes
|
|
self._clear_others = clear_others
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given config's volumes to match the desired changes.
|
|
|
|
Args:
|
|
resource: k8s_object to adjust
|
|
|
|
Returns:
|
|
The adjusted resource
|
|
|
|
Raises:
|
|
ConfigurationError if there's an attempt to replace the volume a mount
|
|
points to whose existing volume has a source of a different type than
|
|
the new volume (e.g. mount that points to a volume with a secret source
|
|
can't be replaced with a volume that has a config map source).
|
|
"""
|
|
volume_mounts = resource.template.container.volume_mounts.config_maps
|
|
res_volumes = resource.template.volumes.config_maps
|
|
|
|
_PruneMapping(volume_mounts, self._removes, self._clear_others)
|
|
|
|
for path, (source_name, source_key) in self._updates.items():
|
|
volume_name = _UniqueVolumeName(source_name, resource.template.volumes)
|
|
|
|
# volume_mounts is a special mapping that filters for the current kind
|
|
# of mount and KeyErrors on existing keys with other types.
|
|
try:
|
|
volume_mounts[path] = volume_name
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'Cannot update mount [{}] because its mounted volume '
|
|
'is of a different source type.'.format(path)
|
|
)
|
|
res_volumes[volume_name] = self._MakeVolumeSource(
|
|
resource.MessagesModule(), source_name, source_key
|
|
)
|
|
|
|
mounted_volumes = frozenset(
|
|
itertools.chain.from_iterable(
|
|
container.volume_mounts.config_maps.values()
|
|
for container in resource.template.containers.values()
|
|
)
|
|
)
|
|
_PruneVolumes(mounted_volumes, res_volumes)
|
|
|
|
return resource
|
|
|
|
def _MakeVolumeSource(self, messages, name, key=None):
|
|
source = messages.ConfigMapVolumeSource(name=name)
|
|
if key is not None:
|
|
source.items.append(messages.KeyToPath(key=key, path=key))
|
|
return source
|
|
|
|
|
|
class NoTrafficChange(NonTemplateConfigChanger):
|
|
"""Represents the user intent to block traffic for a new revision."""
|
|
|
|
def Adjust(self, resource):
|
|
"""Removes LATEST from the services traffic assignments."""
|
|
if not resource.generation:
|
|
raise exceptions.ConfigurationError(
|
|
'--no-traffic not supported when creating a new service.'
|
|
)
|
|
|
|
resource.spec_traffic.ZeroLatestTraffic(
|
|
resource.status.latestReadyRevisionName
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class TrafficChanges(NonTemplateConfigChanger):
|
|
"""Represents the user intent to change a service's traffic assignments.
|
|
|
|
Attributes:
|
|
new_percentages: New traffic percentages to set.
|
|
by_tag: Boolean indicating that new traffic percentages are specified by
|
|
tag.
|
|
tags_to_update: Traffic tag values to update.
|
|
tags_to_remove: Traffic tags to remove.
|
|
clear_other_tags: Whether nonupdated tags should be cleared.
|
|
"""
|
|
|
|
new_percentages: Mapping[str, int]
|
|
by_tag: bool = False
|
|
tags_to_update: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
tags_to_remove: Container[str] = dataclasses.field(default_factory=list)
|
|
clear_other_tags: bool = False
|
|
|
|
def Adjust(self, resource):
|
|
"""Mutates the given service's traffic assignments."""
|
|
if self.tags_to_update or self.tags_to_remove or self.clear_other_tags:
|
|
resource.spec_traffic.UpdateTags(
|
|
self.tags_to_update,
|
|
self.tags_to_remove,
|
|
self.clear_other_tags,
|
|
)
|
|
if self.new_percentages:
|
|
if self.by_tag:
|
|
tag_to_key = resource.spec_traffic.TagToKey()
|
|
percentages = {}
|
|
for tag in self.new_percentages:
|
|
try:
|
|
percentages[tag_to_key[tag]] = self.new_percentages[tag]
|
|
except KeyError:
|
|
raise exceptions.ConfigurationError(
|
|
'There is no revision tagged with [{}] in the traffic'
|
|
' allocation for [{}].'.format(tag, resource.name)
|
|
)
|
|
else:
|
|
percentages = self.new_percentages
|
|
resource.spec_traffic.UpdateTraffic(percentages)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class TagOnDeployChange(NonTemplateConfigChanger):
|
|
"""The intent to provide a tag for the revision you're currently deploying.
|
|
|
|
Attributes:
|
|
tag: The tag to apply to the new revision.
|
|
"""
|
|
|
|
tag: str
|
|
|
|
def Adjust(self, resource):
|
|
"""Gives the revision that's being created the given tag."""
|
|
tags_to_update = {self.tag: resource.template.name}
|
|
resource.spec_traffic.UpdateTags(tags_to_update, [], False)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(init=False, frozen=True)
|
|
class ContainerCommandChange(ContainerConfigChanger):
|
|
"""Represents the user intent to change the 'command' for the container.
|
|
|
|
Attributes:
|
|
command: The command to set in the adjusted container.
|
|
"""
|
|
|
|
command: str
|
|
|
|
def __init__(self, command, **kwargs):
|
|
super().__init__(**kwargs)
|
|
object.__setattr__(self, 'command', command)
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
container.command = self.command
|
|
|
|
|
|
@dataclasses.dataclass(init=False, frozen=True)
|
|
class ContainerArgsChange(ContainerConfigChanger):
|
|
"""Represents the user intent to change the 'args' for the container.
|
|
|
|
Attributes:
|
|
args: The args to set in the adjusted container.
|
|
"""
|
|
|
|
args: list[str]
|
|
|
|
def __init__(self, args, **kwargs):
|
|
super().__init__(**kwargs)
|
|
object.__setattr__(self, 'args', args)
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
container.args = self.args
|
|
|
|
|
|
_HTTP2_NAME = 'h2c'
|
|
_DEFAULT_PORT = 8080
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ContainerPortChange(ContainerConfigChanger):
|
|
"""Represents the user intent to change the port name and/or number.
|
|
|
|
Attributes:
|
|
port: The port to set, "default" to unset the containerPort field, or None
|
|
to not modify the port number.
|
|
use_http2: True to set the port name for http/2, False to unset it, or None
|
|
to not modify the port name.
|
|
**kwargs: ContainerConfigChanger args.
|
|
"""
|
|
|
|
port: str | None = None
|
|
use_http2: bool | None = None
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
"""Modify an existing ContainerPort or create a new one."""
|
|
port_msg = (
|
|
container.ports[0] if container.ports else messages_mod.ContainerPort()
|
|
)
|
|
old_port = port_msg.containerPort or 8080 # default port
|
|
# Set port to given value or clear field
|
|
if self.port == 'default':
|
|
port_msg.reset('containerPort')
|
|
elif self.port is not None:
|
|
port_msg.containerPort = int(self.port)
|
|
# Set name for http/2 or clear field
|
|
if self.use_http2:
|
|
port_msg.name = _HTTP2_NAME
|
|
elif self.use_http2 is not None:
|
|
port_msg.reset('name')
|
|
# A port number must be specified
|
|
if port_msg.name and not port_msg.containerPort:
|
|
port_msg.containerPort = _DEFAULT_PORT
|
|
|
|
# Use the ContainerPort iff it's not empty
|
|
if port_msg.containerPort:
|
|
container.ports = [port_msg]
|
|
else:
|
|
container.reset('ports')
|
|
|
|
# we also need to reset tcp startup probe port if it exists
|
|
if container.startupProbe and container.startupProbe.tcpSocket:
|
|
if container.startupProbe.tcpSocket.port == old_port:
|
|
if port_msg.containerPort:
|
|
container.startupProbe.tcpSocket.port = port_msg.containerPort
|
|
else:
|
|
container.startupProbe.tcpSocket.reset('port')
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ExecutionTemplateSpecChange(TemplateConfigChanger):
|
|
"""Represents the intent to update field in an execution template's spec.
|
|
|
|
Attributes:
|
|
field: The field to update in the execution template spec.
|
|
value: The value to set in the updated field.
|
|
"""
|
|
|
|
field: str
|
|
value: Any
|
|
|
|
def Adjust(self, resource):
|
|
setattr(resource.execution_template.spec, self.field, self.value)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class JobMaxRetriesChange(TemplateConfigChanger):
|
|
"""Represents the user intent to update a job's restart policy.
|
|
|
|
Attributes:
|
|
max_retries: The max retry number to set in the job's restart policy.
|
|
"""
|
|
|
|
max_retries: int
|
|
|
|
def Adjust(self, resource):
|
|
resource.task_template.spec.maxRetries = self.max_retries
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class JobPriorityTierChange(TemplateConfigChanger):
|
|
"""Represents the intent to update the job's priority tier.
|
|
|
|
Attributes:
|
|
priority_tier: The priority tier to set.
|
|
"""
|
|
|
|
priority_tier: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.execution_template.spec.priorityTier = (
|
|
run_v1_messages.ExecutionSpec.PriorityTierValueValuesEnum(
|
|
self.priority_tier.upper()
|
|
)
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class DelayExecutionChange(TemplateConfigChanger):
|
|
"""Represents the intent to update the job's delay execution setting.
|
|
|
|
Attributes:
|
|
delay_execution: True if the triggered execution can be delayed.
|
|
"""
|
|
|
|
delay_execution: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.execution_template.spec.delayExecution = self.delay_execution
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class JobTaskTimeoutChange(TemplateConfigChanger):
|
|
"""Represents the user intent to update a job's instance deadline.
|
|
|
|
Attributes:
|
|
timeout_seconds: The timeout in seconds to set in the job's instance
|
|
deadline.
|
|
"""
|
|
|
|
timeout_seconds: int
|
|
|
|
def Adjust(self, resource):
|
|
resource.task_template.spec.timeoutSeconds = self.timeout_seconds
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class CpuThrottlingChange(TemplateConfigChanger):
|
|
"""Sets the cpu-throttling annotation on the service template.
|
|
|
|
Attributes:
|
|
throttling: The throttling annotation value to set.
|
|
"""
|
|
|
|
throttling: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[
|
|
container_resource.CPU_THROTTLE_ANNOTATION
|
|
] = str(self.throttling)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class StartupCpuBoostChange(TemplateConfigChanger):
|
|
"""Sets the startup-cpu-boost annotation on the service template.
|
|
|
|
Attributes:
|
|
cpu_boost: Boolean indicating whether CPU boost should be enabled.
|
|
"""
|
|
|
|
cpu_boost: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[
|
|
container_resource.COLD_START_BOOST_ANNOTATION
|
|
] = str(self.cpu_boost)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class HealthCheckChange(TemplateConfigChanger):
|
|
"""Sets the health-check-disabled annotation on the revision template.
|
|
|
|
Attributes:
|
|
health_check: Boolean indicating whether the health check should be enabled.
|
|
"""
|
|
|
|
health_check: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[
|
|
container_resource.DISABLE_HEALTH_CHECK_ANNOTATION
|
|
] = str(not self.health_check)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class DefaultUrlChange(NonTemplateConfigChanger):
|
|
"""Sets the default-url-disabled annotation on the service.
|
|
|
|
Attributes:
|
|
default_url: Boolean indicating whether the default URL should be enabled.
|
|
"""
|
|
|
|
default_url: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.annotations[container_resource.DISABLE_URL_ANNOTATION] = str(
|
|
not self.default_url
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class InvokerIamChange(NonTemplateConfigChanger):
|
|
"""Sets the invoker-iam-disabled annotation on the service.
|
|
|
|
Attributes:
|
|
invoker_iam_check: Boolean indicating whether invoker iam should be enabled
|
|
"""
|
|
|
|
invoker_iam_check: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.annotations[container_resource.DISABLE_IAM_ANNOTATION] = str(
|
|
not self.invoker_iam_check
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class NetworkInterfacesChange(TemplateConfigChanger):
|
|
"""Sets or updates the network interfaces annotation on the template.
|
|
|
|
Attributes:
|
|
network_is_set: Boolean indicating whether network was explicitly set by the
|
|
user.
|
|
network: The network to set.
|
|
subnet_is_set: Boolean indicating whether subnet was explicitly set by the
|
|
user.
|
|
subnet: The subnet to set.
|
|
network_tags_is_set: Boolean indicating whether network_tags was explicitly
|
|
set by the user.
|
|
network_tags: The network tags to set.
|
|
"""
|
|
|
|
network_is_set: bool
|
|
network: str
|
|
subnet_is_set: bool
|
|
subnet: str
|
|
network_tags_is_set: bool
|
|
network_tags: list[str]
|
|
|
|
def _SetOrClear(self, m, key, value):
|
|
if value:
|
|
# If value is present, add key=value to m.
|
|
m[key] = value
|
|
elif key in m:
|
|
# Otherwise clear the key from m.
|
|
del m[key]
|
|
|
|
def Adjust(self, resource):
|
|
annotations = resource.template.annotations
|
|
network_interface = {}
|
|
if k8s_object.NETWORK_INTERFACES_ANNOTATION in annotations:
|
|
network_interface = json.loads(
|
|
annotations[k8s_object.NETWORK_INTERFACES_ANNOTATION]
|
|
)[0]
|
|
if self.network_is_set:
|
|
self._SetOrClear(network_interface, 'network', self.network)
|
|
if self.subnet_is_set:
|
|
self._SetOrClear(network_interface, 'subnetwork', self.subnet)
|
|
if self.network_tags_is_set:
|
|
self._SetOrClear(network_interface, 'tags', self.network_tags)
|
|
value = ''
|
|
if network_interface:
|
|
value = '[{interfaces}]'.format(
|
|
interfaces=json.dumps(network_interface, sort_keys=True)
|
|
)
|
|
self._SetOrClear(
|
|
annotations, k8s_object.NETWORK_INTERFACES_ANNOTATION, value
|
|
)
|
|
# If clear network interfaces, egress setting should be cleared too.
|
|
if (
|
|
not value
|
|
and container_resource.EGRESS_SETTINGS_ANNOTATION in annotations
|
|
):
|
|
del annotations[container_resource.EGRESS_SETTINGS_ANNOTATION]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ClearNetworkInterfacesChange(TemplateConfigChanger):
|
|
"""Clears a network interfaces annotation on the resource."""
|
|
|
|
def Adjust(self, resource):
|
|
annotations = resource.template.annotations
|
|
if k8s_object.NETWORK_INTERFACES_ANNOTATION in annotations:
|
|
del annotations[k8s_object.NETWORK_INTERFACES_ANNOTATION]
|
|
if container_resource.EGRESS_SETTINGS_ANNOTATION in annotations:
|
|
del annotations[container_resource.EGRESS_SETTINGS_ANNOTATION]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class CustomAudiencesChanges(TemplateConfigChanger):
|
|
"""Represents the intent to update the custom audiences.
|
|
|
|
Attributes:
|
|
args: Args to the command.
|
|
"""
|
|
|
|
args: object
|
|
|
|
@property
|
|
def add_custom_audiences(self):
|
|
return getattr(self.args, 'add_custom_audiences', None)
|
|
|
|
@property
|
|
def remove_custom_audiences(self):
|
|
return getattr(self.args, 'remove_custom_audiences', None)
|
|
|
|
@property
|
|
def set_custom_audiences(self):
|
|
return getattr(self.args, 'set_custom_audiences', None)
|
|
|
|
@property
|
|
def clear_custom_audiences(self):
|
|
return getattr(self.args, 'clear_custom_audiences', None)
|
|
|
|
def Adjust(self, resource):
|
|
def GetCurrentCustomAudiences():
|
|
annotation_val = resource.annotations.get(
|
|
k8s_object.CUSTOM_AUDIENCES_ANNOTATION
|
|
)
|
|
if annotation_val:
|
|
return json.loads(annotation_val)
|
|
return []
|
|
|
|
audiences = repeated.ParsePrimitiveArgs(
|
|
self, 'custom-audiences', GetCurrentCustomAudiences
|
|
)
|
|
if audiences is not None:
|
|
if audiences:
|
|
resource.annotations[k8s_object.CUSTOM_AUDIENCES_ANNOTATION] = (
|
|
json.dumps(audiences)
|
|
)
|
|
elif k8s_object.CUSTOM_AUDIENCES_ANNOTATION in resource.annotations:
|
|
del resource.annotations[k8s_object.CUSTOM_AUDIENCES_ANNOTATION]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RuntimeChange(TemplateConfigChanger):
|
|
"""Sets the runtime annotation on the service template.
|
|
|
|
Attributes:
|
|
runtime: The runtime annotation value to set.
|
|
"""
|
|
|
|
runtime: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.spec.runtimeClassName = self.runtime
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class GpuTypeChange(TemplateConfigChanger):
|
|
"""Sets the gpu-type annotation on the service template.
|
|
|
|
Attributes:
|
|
gpu_type: The gpu_type annotation value to set.
|
|
"""
|
|
|
|
gpu_type: str
|
|
|
|
def Adjust(self, resource):
|
|
if self.gpu_type:
|
|
resource.template.node_selector[k8s_object.GPU_TYPE_NODE_SELECTOR] = (
|
|
self.gpu_type
|
|
)
|
|
else:
|
|
resource.template.node_selector.pop(
|
|
k8s_object.GPU_TYPE_NODE_SELECTOR, None
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class GpuZonalRedundancyChange(TemplateConfigChanger):
|
|
"""Sets the gpu zonal redundancy annotation on the revision annotations.
|
|
|
|
Attributes:
|
|
gpu_zonal_redundancy: The gpu_zonal_redundancy annotation value to set.
|
|
"""
|
|
|
|
gpu_zonal_redundancy: bool
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[
|
|
revision.GPU_ZONAL_REDUNDANCY_DISABLED_ANNOTATION
|
|
] = str(not self.gpu_zonal_redundancy)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RemoveContainersChange(TemplateConfigChanger):
|
|
"""Removes the specified containers.
|
|
|
|
Attributes:
|
|
containers: Containers to remove.
|
|
"""
|
|
|
|
containers: frozenset[str]
|
|
|
|
@classmethod
|
|
def FromContainerNames(cls, containers: Iterable[str]):
|
|
"""Returns a RemoveContainersChange that removes the specified containers.
|
|
|
|
Args:
|
|
containers: The names of containers to remove. Duplicate container names
|
|
are ignored.
|
|
"""
|
|
return cls(frozenset(containers))
|
|
|
|
def Adjust(
|
|
self, resource: k8s_object.KubernetesObject
|
|
) -> k8s_object.KubernetesObject:
|
|
for container in self.containers:
|
|
try:
|
|
del resource.template.containers[container]
|
|
except KeyError:
|
|
continue
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class ContainerDependenciesChange(TemplateConfigChanger):
|
|
"""Sets container dependencies.
|
|
|
|
Updates container dependencies to add the dependencies in new_depencies.
|
|
Additionally, dependencies to or from a container which does not exist will be
|
|
removed.
|
|
|
|
Attributes:
|
|
new_dependencies: A map of containers to their updated dependencies.
|
|
Defaults to an empty map.
|
|
"""
|
|
|
|
new_dependencies: Mapping[str, Iterable[str]] = dataclasses.field(
|
|
default_factory=dict
|
|
)
|
|
|
|
def Adjust(
|
|
self, resource: k8s_object.KubernetesObject
|
|
) -> k8s_object.KubernetesObject:
|
|
containers = frozenset(resource.template.containers.keys())
|
|
dependencies = resource.template.dependencies
|
|
# Filter removed containers from existing container dependencies.
|
|
dependencies = {
|
|
container_name: [c for c in depends_on if c in containers]
|
|
for container_name, depends_on in dependencies.items()
|
|
if container_name in containers
|
|
}
|
|
|
|
for container, depends_on in self.new_dependencies.items():
|
|
if not container:
|
|
container = resource.template.container.name
|
|
depends_on = frozenset(depends_on)
|
|
missing = depends_on - containers
|
|
if missing:
|
|
raise exceptions.ConfigurationError(
|
|
'--depends_on for container {} references nonexistent'
|
|
' containers: {}.'.format(container, ','.join(missing))
|
|
)
|
|
|
|
if depends_on:
|
|
dependencies[container] = sorted(depends_on)
|
|
else:
|
|
del dependencies[container]
|
|
|
|
resource.template.dependencies = dependencies
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RemoveVolumeChange(TemplateConfigChanger):
|
|
"""Removes volumes from the service or job template.
|
|
|
|
Attributes:
|
|
removed_volumes: The volumes to remove.
|
|
"""
|
|
|
|
removed_volumes: Iterable[str]
|
|
clear_volumes: bool
|
|
|
|
def Adjust(self, resource):
|
|
# having remove and clear is redundant, but we'll allow it.
|
|
if self.clear_volumes:
|
|
vols = list(resource.template.volumes)
|
|
for vol in vols:
|
|
del resource.template.volumes[vol]
|
|
else:
|
|
for to_remove in self.removed_volumes:
|
|
if to_remove in resource.template.volumes:
|
|
del resource.template.volumes[to_remove]
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class AddVolumeChange(TemplateConfigChanger):
|
|
"""Updates Volumes set on the service or job template.
|
|
|
|
Attributes:
|
|
new_volumes: The volumes to add.
|
|
release_track: The resource's release track. Used to verify volume types are
|
|
supported in that release track.
|
|
"""
|
|
|
|
new_volumes: Collection[Mapping[str, str]]
|
|
release_track: base.ReleaseTrack
|
|
|
|
def Adjust(self, resource):
|
|
for to_add in self.new_volumes:
|
|
volumes.add_volume(
|
|
to_add,
|
|
resource.template.volumes,
|
|
resource.MessagesModule(),
|
|
self.release_track,
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RemoveVolumeMountChange(ContainerConfigChanger):
|
|
"""Removes Volume Mounts from the container.
|
|
|
|
Attributes:
|
|
removed_mounts: Volume mounts to remove from the adjusted container.
|
|
"""
|
|
|
|
removed_mounts: Collection[str] = dataclasses.field(default_factory=list)
|
|
clear_mounts: bool = False
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
if self.clear_mounts:
|
|
# iterating over the dictionary wrapper directly while deleting from it
|
|
# casues problems.
|
|
keys = list(container.volume_mounts)
|
|
for mount in keys:
|
|
del container.volume_mounts[mount]
|
|
else:
|
|
for to_remove in self.removed_mounts:
|
|
if to_remove in container.volume_mounts:
|
|
del container.volume_mounts[to_remove]
|
|
return container
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class AddVolumeMountChange(ContainerConfigChanger):
|
|
"""Updates Volume Mounts set on the container.
|
|
|
|
Attributes:
|
|
new_mounts: Mounts to add to the adjusted container.
|
|
"""
|
|
|
|
new_mounts: Collection[Mapping[str, str]] = dataclasses.field(
|
|
default_factory=list
|
|
)
|
|
|
|
def AdjustContainer(self, container, messages_mod):
|
|
for mount in self.new_mounts:
|
|
if 'volume' not in mount or 'mount-path' not in mount:
|
|
raise exceptions.ConfigurationError(
|
|
'Added Volume mounts must have a `volume` and a `mount-path`.'
|
|
)
|
|
container.volume_mounts[mount['mount-path']] = mount['volume']
|
|
return container
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class SetServiceMeshChange(TemplateConfigChanger):
|
|
"""Sets the service mesh annotation on the service template.
|
|
|
|
Attributes:
|
|
project: The project to use for the mesh when not specified in mesh_name.
|
|
mesh: Mesh resource name in the format of MESH_NAME or
|
|
projects/PROJECT/locations/global/meshes/MESH_NAME.
|
|
"""
|
|
|
|
project: str
|
|
mesh_name: str
|
|
|
|
def Adjust(self, resource):
|
|
resource.template.annotations[revision.MESH_ANNOTATION] = (
|
|
self.mesh_name
|
|
if '/' in self.mesh_name
|
|
else f'projects/{self.project}/locations/global/meshes/{self.mesh_name}'
|
|
)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class PresetChange(TemplateConfigChanger):
|
|
"""Sets the preset annotation on the service template.
|
|
|
|
Attributes:
|
|
type: The type of preset to use.
|
|
config: The config to use for the preset.
|
|
flatten: Whether to flatten the preset values into the service template.
|
|
"""
|
|
|
|
type: str
|
|
config: Mapping[str, str] = dataclasses.field(default_factory=dict)
|
|
flatten: bool = True
|
|
|
|
def Adjust(self, resource):
|
|
# TODO(b/412784660): Add support for multiple presets and merge existing
|
|
# presets.
|
|
presets = []
|
|
if service.PRESETS_ANNOTATION in resource.annotations:
|
|
presets = json.loads(resource.annotations[service.PRESETS_ANNOTATION])
|
|
|
|
if presets and presets[0]['type'] != self.type:
|
|
presets.append({
|
|
'type': self.type,
|
|
'config': self.config,
|
|
'flatten': self.flatten,
|
|
})
|
|
else:
|
|
presets = [{
|
|
'type': self.type,
|
|
'config': self.config,
|
|
'flatten': self.flatten,
|
|
}]
|
|
resource.annotations[service.PRESETS_ANNOTATION] = json.dumps(presets)
|
|
return resource
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RemovePresetsChange(TemplateConfigChanger):
|
|
"""Removes one or more presets from annotation from the service metadata.
|
|
|
|
Attributes:
|
|
clear_presets: Whether to clear all presets.
|
|
"""
|
|
|
|
clear_presets: bool
|
|
|
|
def Adjust(self, resource):
|
|
presets = json.loads(
|
|
resource.annotations.get(service.PRESETS_ANNOTATION, '[]')
|
|
)
|
|
if presets and self.clear_presets:
|
|
del resource.annotations[service.PRESETS_ANNOTATION]
|
|
return resource
|