336 lines
11 KiB
Python
336 lines
11 KiB
Python
# -*- coding: utf-8 -*- #
|
|
#
|
|
# Copyright 2025 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.
|
|
"""Common utility functions for Developer Connect Insights Configs."""
|
|
import re
|
|
from apitools.base.py import exceptions as apitools_exceptions
|
|
from googlecloudsdk.api_lib.cloudresourcemanager import projects_api
|
|
from googlecloudsdk.command_lib.projects import util as projects_util
|
|
from googlecloudsdk.core import exceptions
|
|
|
|
_APPHUB_MANAGEMENT_PROJECT_PREFIX = "google-mfp"
|
|
_ARTIFACT_URI_PATTERN = r"^([^\.]+)-docker.pkg.dev/([^/]+)/([^/]+)/([^@:]+)((@sha256:[a-f0-9]+)|(:[\w\-\.]+))?$"
|
|
_CONTAINER_REGISTRY_URI_PATTERN = (
|
|
r"^(.*gcr.io)/([^/]+)/([^@:]+)((@sha256:[a-f0-9]+)|(:[\w\-\.]+))?$"
|
|
)
|
|
_PROJECT_PATTERN = r"projects/([^/]+)"
|
|
APPHUB_SERVICE_PREFIX = "//apphub.googleapis.com"
|
|
GKE_SERVICE_PREFIX = "//container.googleapis.com"
|
|
RUN_SERVICE_PREFIX = "//run.googleapis.com"
|
|
name_segment_re = r"([a-zA-Z0-9-._~%!$&'()*+,;=@]{1,64})"
|
|
|
|
app_hub_application_path_regex = re.compile(
|
|
rf"^(?:{APPHUB_SERVICE_PREFIX}/)?projects/((?:[^:]+:.)?[a-z0-9\\-]+)/locations/([\w-]{{2,40}})/applications/{name_segment_re}$"
|
|
)
|
|
gke_deployment_path_regex = re.compile(
|
|
rf"^(?:{GKE_SERVICE_PREFIX}/)?projects/((?:[^:]+:.)?[a-z0-9\\-]+)/(locations|zones)/([\w-]{{2,40}})/clusters/{name_segment_re}/k8s/namespaces/{name_segment_re}/apps/deployments/{name_segment_re}$"
|
|
)
|
|
cloud_run_service_path_regex = re.compile(
|
|
rf"^(?:{RUN_SERVICE_PREFIX}/)?projects/((?:[^:]+:.)?[a-z0-9\\-]+)/locations/([\w-]{{2,40}})/services/{name_segment_re}$"
|
|
)
|
|
project_regex = re.compile(r"^(?:projects/)?((?:[^:]+:.)?[a-z0-9\\-]+)$")
|
|
|
|
# https://cloud.google.com/artifact-registry/docs/transition/gcr-repositories#gcr-domain-support
|
|
_GCR_HOST_TO_AR_LOCATION = {
|
|
"us.gcr.io": "us",
|
|
"gcr.io": "us",
|
|
# the documentation says "europe", but it seems to only work with "eu"
|
|
"eu.gcr.io": "eu",
|
|
"asia.gcr.io": "asia",
|
|
}
|
|
|
|
|
|
class Project:
|
|
"""Represents a project."""
|
|
|
|
def __init__(self, project_identifier):
|
|
project_details = projects_api.Get(
|
|
projects_util.ParseProject(project_identifier)
|
|
)
|
|
self.project_id = project_details.projectId
|
|
self.project_number = project_details.projectNumber
|
|
|
|
def resource_name(self):
|
|
return f"projects/{self.project_id}"
|
|
|
|
|
|
def extract_project(uri):
|
|
"""Extracts the project from a resource URI."""
|
|
match = re.search(_PROJECT_PATTERN, uri)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
class ArtifactRegistryUri:
|
|
"""Parses and represents an Artifact Registry URI."""
|
|
|
|
def __init__(self, location, project, repository, image_name):
|
|
self._location = location
|
|
self.project_id = project
|
|
self._repository = repository
|
|
self._image_name = image_name
|
|
|
|
@property
|
|
def base_uri(self):
|
|
"""The artifact URI without the SHA suffix."""
|
|
# If the repository is a GCR host name, then the URI must be a gcr.io URI.
|
|
if self._repository in _GCR_HOST_TO_AR_LOCATION:
|
|
return f"{self._repository}/{self.project_id}/{self._image_name}"
|
|
return f"{self._location}-docker.pkg.dev/{self.project_id}/{self._repository}/{self._image_name}"
|
|
|
|
|
|
def validate_artifact_uri(uri):
|
|
"""Validates the artifact URI."""
|
|
# Parse the URI if it matches the expected pattern.
|
|
if match := re.match(_ARTIFACT_URI_PATTERN, uri):
|
|
location = match.group(1)
|
|
project = match.group(2)
|
|
repository = match.group(3)
|
|
image_name = match.group(4)
|
|
elif match := re.match(_CONTAINER_REGISTRY_URI_PATTERN, uri):
|
|
host_name = match.group(1)
|
|
location = _GCR_HOST_TO_AR_LOCATION.get(host_name)
|
|
if not location:
|
|
return None
|
|
|
|
project = match.group(2)
|
|
# The repository name is the same as the container registry host name.
|
|
repository = host_name
|
|
image_name = match.group(3)
|
|
else:
|
|
return None
|
|
|
|
return ArtifactRegistryUri(location, project, repository, image_name)
|
|
|
|
|
|
def is_management_project(app_hub_application):
|
|
"""Checks if the app hub application is a management project."""
|
|
return app_hub_application.startswith(_APPHUB_MANAGEMENT_PROJECT_PREFIX)
|
|
|
|
|
|
def validate_project(project_id):
|
|
"""Validates the project."""
|
|
return projects_api.Get(projects_util.ParseProject(project_id))
|
|
|
|
|
|
class GKECluster:
|
|
"""Represents a GKE cluster."""
|
|
|
|
def __init__(self, project, location_id, cluster_id):
|
|
self.project = project
|
|
self.location_id = location_id
|
|
self.cluster_id = cluster_id
|
|
|
|
def id(self):
|
|
return self.cluster_id
|
|
|
|
def resource_name(self):
|
|
return f"{GKE_SERVICE_PREFIX}/projects/{self.project}/locations/{self.location_id}/clusters/{self.cluster_id}"
|
|
|
|
|
|
class GKENamespace:
|
|
"""Represents a GKE namespace."""
|
|
|
|
def __init__(self, gke_cluster, namespace_id):
|
|
self.gke_cluster = gke_cluster
|
|
self.namespace_id = namespace_id
|
|
|
|
def resource_name(self):
|
|
return f"{GKE_SERVICE_PREFIX}/projects/{self.gke_cluster.project}/locations/{self.gke_cluster.location_id}/clusters/{self.gke_cluster.cluster_id}/k8s/namespaces/{self.namespace_id}"
|
|
|
|
|
|
class GKEWorkload:
|
|
"""Represents a GKE workload."""
|
|
|
|
def __init__(
|
|
self,
|
|
gke_namespace,
|
|
deployment_id,
|
|
):
|
|
self.gke_namespace = gke_namespace
|
|
self.deployment_id = deployment_id
|
|
|
|
def resource_name(self):
|
|
return f"{GKE_SERVICE_PREFIX}/projects/{self.gke_namespace.gke_cluster.project}/locations/{self.gke_namespace.gke_cluster.location_id}/clusters/{self.gke_namespace.gke_cluster.cluster_id}/k8s/namespaces/{self.gke_namespace.namespace_id}/apps/deployments/{self.deployment_id}"
|
|
|
|
|
|
def parse_gke_deployment_uri(uri):
|
|
"""Parses a GKE deployment URI into a GKEWorkload."""
|
|
match = gke_deployment_path_regex.fullmatch(uri)
|
|
if not match or len(match.groups()) != 6:
|
|
return False
|
|
|
|
return GKEWorkload(
|
|
GKENamespace(
|
|
GKECluster(
|
|
match.group(1),
|
|
match.group(3),
|
|
match.group(4),
|
|
),
|
|
match.group(5),
|
|
),
|
|
deployment_id=match.group(6),
|
|
)
|
|
|
|
|
|
class CloudRunService:
|
|
"""Represents a Cloud Run service."""
|
|
|
|
def __init__(self, project, location_id, service_id):
|
|
"""Initializes a CloudRunService instance.
|
|
|
|
Args:
|
|
project: The Project object.
|
|
location_id: The location of the service.
|
|
service_id: The ID of the service.
|
|
"""
|
|
self.project_id = project.project_id
|
|
self.project_number = project.project_number
|
|
self.location_id = location_id
|
|
self.service_id = service_id
|
|
|
|
def resource_name(self):
|
|
return f"{RUN_SERVICE_PREFIX}/projects/{self.project_id}/locations/{self.location_id}/services/{self.service_id}"
|
|
|
|
|
|
def parse_cloud_run_service_uri(uri):
|
|
"""Parses a Cloud Run service URI into a CloudRunService object."""
|
|
match = cloud_run_service_path_regex.fullmatch(uri)
|
|
if not match or len(match.groups()) != 3:
|
|
return False
|
|
project = Project(match.group(1))
|
|
return CloudRunService(
|
|
project,
|
|
match.group(2),
|
|
match.group(3),
|
|
)
|
|
|
|
|
|
class AppHubApplication:
|
|
"""Represents an App Hub Application."""
|
|
|
|
def __init__(self, project, location_id, application_id):
|
|
"""Initializes an AppHubApplication instance.
|
|
|
|
Args:
|
|
project: The Project object.
|
|
location_id: The location of the application.
|
|
application_id: The ID of the application.
|
|
"""
|
|
self.project_id = project.project_id
|
|
self.project_number = project.project_number
|
|
self.location_id = location_id
|
|
self.application_id = application_id
|
|
|
|
def resource_name(self):
|
|
return f"projects/{self.project_id}/locations/{self.location_id}/applications/{self.application_id}"
|
|
|
|
|
|
def parse_app_hub_application_uri(uri):
|
|
"""Parses an App Hub Application URI into an AppHubApplication."""
|
|
match = app_hub_application_path_regex.fullmatch(uri)
|
|
if not match or len(match.groups()) != 3:
|
|
raise ValueError(
|
|
"app_hub_application must be in the format"
|
|
" //apphub.googleapis.com/projects/{project}/locations/{location}/applications/{application}:"
|
|
f" {uri}"
|
|
)
|
|
project = Project(match.group(1))
|
|
if not project:
|
|
raise ValueError(
|
|
"app_hub_application must be in the format"
|
|
" //apphub.googleapis.com/projects/{project}/locations/{location}/applications/{application}:"
|
|
f" {uri}"
|
|
)
|
|
location = match.group(2)
|
|
application_id = match.group(3)
|
|
return AppHubApplication(
|
|
project, location, application_id
|
|
)
|
|
|
|
|
|
def parse_target_projects(target_projects):
|
|
"""Parses a list of target projects into an array."""
|
|
projects = []
|
|
if not target_projects:
|
|
return projects
|
|
for target_project in dict.fromkeys(target_projects):
|
|
# Validate project is in the correct format.
|
|
match = project_regex.fullmatch(target_project)
|
|
if not match or len(match.groups()) != 1:
|
|
raise ValueError(
|
|
"target_project must be in the format"
|
|
"{project} or projects/{project}:"
|
|
f" {target_project}"
|
|
)
|
|
project_id = match.group(1)
|
|
# Validate project exists and user has access.
|
|
try:
|
|
validate_project(project_id)
|
|
except apitools_exceptions.HttpForbiddenError:
|
|
raise ValueError(
|
|
"Permission denied when checking target project [{}]. Please"
|
|
" ensure your account has necessary permissions "
|
|
"or that the project exists.".format(target_project)
|
|
)
|
|
except apitools_exceptions.HttpBadRequestError:
|
|
raise ValueError(
|
|
"Invalid user-provided target project ID [{}]. Please ensure it is a"
|
|
" valid project ID".format(target_project)
|
|
)
|
|
except exceptions.Error as e:
|
|
raise ValueError(
|
|
f"Error validating target project [{target_project}]: {e}"
|
|
)
|
|
projects.append(project_id)
|
|
return projects
|
|
|
|
|
|
def parse_artifact_configs(user_artifact_configs):
|
|
"""Parses a list of artifact configs into a dictionary."""
|
|
artifact_configs_dict = {}
|
|
if not user_artifact_configs:
|
|
return artifact_configs_dict
|
|
for user_config_data in user_artifact_configs:
|
|
for uri, build_project in user_config_data.items():
|
|
valid_uri = validate_artifact_uri(uri)
|
|
try:
|
|
validate_project(build_project)
|
|
except apitools_exceptions.HttpForbiddenError:
|
|
raise ValueError(
|
|
"Permission denied when checking build project [{}]. Please"
|
|
" ensure your account has necessary permissions "
|
|
"or that the project exists.".format(build_project)
|
|
)
|
|
except apitools_exceptions.HttpBadRequestError:
|
|
raise ValueError(
|
|
"Invalid user provided build project ID [{}]. Please ensure it is a"
|
|
" valid project ID".format(build_project)
|
|
)
|
|
except exceptions.Error as e:
|
|
raise ValueError(
|
|
f"Error validating build project [{build_project}]: {e}"
|
|
)
|
|
|
|
if valid_uri:
|
|
artifact_configs_dict[valid_uri.base_uri] = build_project
|
|
else:
|
|
raise ValueError(
|
|
"Invalid user provided artifact uri, please check the format:"
|
|
f" {user_config_data}"
|
|
)
|
|
return artifact_configs_dict
|