feat: Add new gcloud commands, API clients, and third-party libraries across various services.

This commit is contained in:
2026-01-01 20:26:35 +01:00
parent 5e23cbece0
commit a19e592eb7
25221 changed files with 8324611 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2021 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.
"""Helper functions for working with Apigee archive deployments."""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import os
import zipfile
from googlecloudsdk.api_lib import apigee
from googlecloudsdk.command_lib.apigee import errors
from googlecloudsdk.core import log
from googlecloudsdk.core import requests
from googlecloudsdk.core.resource import resource_projector
from googlecloudsdk.core.util import archive
from googlecloudsdk.core.util import files
from six.moves import urllib
class LocalDirectoryArchive(object):
"""Manages a local zip archive."""
# The archive file name to save to.
_ARCHIVE_FILE_NAME = 'apigee_archive_deployment.zip'
_APIGEE_ARCHIVE_FILE_EXTENSIONS = [
'.graphql',
'.jar',
'.java',
'.js',
'.jsc',
'.json',
'.oas',
'.properties',
'.py',
'.securityPolicy',
'.wsdl',
'.xml',
'.xsd',
'.xsl',
'.yaml',
'.yml',
]
_ARCHIVE_ROOT = os.path.join('src', 'main', 'apigee')
def __init__(self, src_dir):
self._CheckIfPathExists(src_dir)
# Check if the path resolves to a directory.
if src_dir and not os.path.isdir(src_dir):
raise errors.SourcePathIsNotDirectoryError(src_dir)
self._src_dir = src_dir if src_dir is not None else files.GetCWD()
self._tmp_dir = files.TemporaryDirectory()
def _CheckIfPathExists(self, path):
"""Checks that the given file path exists."""
if path and not os.path.exists(path):
raise files.MissingFileError(
'Path to archive deployment does not exist: {}'.format(path))
def _ZipFileFilter(self, file_name):
"""Filter all files in the archive directory to only allow Apigee files."""
if not file_name.startswith(self._ARCHIVE_ROOT):
return False
_, ext = os.path.splitext(file_name)
full_path = os.path.join(self._src_dir, file_name)
# Skip hidden unix directories. Assume hidden directories and the files
# within them are not intended to be included. This check needs to happen
# first so MakeZipFromDir does not continue to process the files within the
# hidden directory which can contain the same file types that Apigee
# supports.
if os.path.basename(full_path).startswith('.'):
return False
# MakeZipFromDir will only process files in a directory if the containing
# directory first evaluates to True, so all directories are approved here.
if os.path.isdir(full_path):
return True
# Only include Apigee supported file extensions.
if (os.path.isfile(full_path) and
ext.lower() in self._APIGEE_ARCHIVE_FILE_EXTENSIONS):
return True
return False
def Zip(self):
"""Creates a zip archive of the specified directory."""
dst_file = os.path.join(self._tmp_dir.path, self._ARCHIVE_FILE_NAME)
archive.MakeZipFromDir(dst_file, self._src_dir, self._ZipFileFilter)
return dst_file
def ValidateZipFilePath(self, zip_path):
"""Checks that the zip file path exists and the file is a zip archvie."""
self._CheckIfPathExists(zip_path)
if not zipfile.is_zipfile(zip_path):
raise errors.BundleFileNotValidError(zip_path)
def Close(self):
"""Deletes the local temporary directory."""
return self._tmp_dir.Close()
def __enter__(self):
return self
def __exit__(self, exc_type, val, tb):
try:
self.Close()
except: # pylint: disable=bare-except
log.warning('Temporary directory was not successfully deleted.')
return True
def GetUploadFileId(upload_url):
"""Helper function to extract the upload file id from the signed URL.
Archive deployments must be uploaded to a provided signed URL in the form of:
https://storage.googleapis.com/<bucket id>/<file id>.zip?<additional headers>
This function extracts the file id from the URL (e.g., <file id>.zip).
Args:
upload_url: A string of the signed URL.
Returns:
A string of the file id.
"""
url = urllib.parse.urlparse(upload_url)
split_path = url.path.split('/')
return split_path[-1]
def UploadArchive(upload_url, zip_file):
"""Uploads the specified zip file with a PUT request to the provided URL.
Args:
upload_url: A string of the URL to send the PUT request to. Required to be a
signed URL from GCS.
zip_file: A string of the file path to the zip file to upload.
Returns:
A requests.Response object.
"""
sess = requests.GetSession()
# Required headers for the Apigee generated signed URL.
headers = {
'content-type': 'application/zip',
'x-goog-content-length-range': '0,1073741824'
}
with files.BinaryFileReader(zip_file) as data:
response = sess.put(upload_url, data=data, headers=headers)
return response
class ListArchives():
"""Adds additional helpful fields to a list of archives."""
def __init__(self, org):
self._org = org
self._lro_helper = apigee.LROPoller(org)
self._deployed_status = 'Deployed'
self._inprogress_status = 'In Progress'
self._failed_status = 'Failed'
self._not_found_status = 'Not Found'
self._unknown_status = 'Unknown'
self._missing_status = 'Missing'
def ExtendedArchives(self, archives):
"""Given a list of archives, extends them with a status and error field where needed.
Args:
archives: A list of archives to extend with a status and potential error.
Returns:
A list of archives with their associated status.
"""
extended_archives = sorted(
archives, key=lambda k: k['createdAt'], reverse=True)
# Go through the list of sorted arcvhives and attempt to find the first
# "Deployed" archive, at which point we can return to the user.
# If at any point there is an error, other than "Not Found", from
# describing an operation, set the current archive an all following archives
# to the "Unknown" state. This is due to the constraint of no longer being
# able to accurately determine a "Deployed" state at that point in time
# (as state is time dependent).
cascade_unknown = False
for idx, a in enumerate(extended_archives):
serilized_archive = resource_projector.MakeSerializable(a)
if cascade_unknown:
serilized_archive['operationStatus'] = self._unknown_status
elif 'operation' in a:
uuid = apigee.OperationsClient.SplitName({'name': a['operation']
})['uuid']
try:
op = apigee.OperationsClient.Describe({
'organizationsId': self._org,
'operationsId': uuid
})
status = self._StatusFromOperation(op)
serilized_archive['operationStatus'] = status['status']
if status['status'] == self._deployed_status:
extended_archives[idx] = serilized_archive
return extended_archives
elif 'error' in status:
serilized_archive['error'] = status['error']
except errors.EntityNotFoundError:
serilized_archive['operationStatus'] = self._not_found_status
except: # pylint: disable=bare-except
cascade_unknown = True
serilized_archive['operationStatus'] = self._unknown_status
else: # Archive didn't have an operation on it to query.
serilized_archive['operationStatus'] = self._missing_status
# Set the new archive with status (and potential error) fields filled out.
extended_archives[idx] = serilized_archive
return extended_archives
def _StatusFromOperation(self, op):
"""Gathers given an LRO, determines the associated archive status.
Args:
op: An Apigee LRO
Returns:
A dict in the format of
{"status": "{status}", "error": "{error if present on LRO}"}.
"""
status = {}
try:
is_done = self._lro_helper.IsDone(op)
if is_done:
# We found the currently deployed archive, no need to continue
# iterating and set other statuses.
status['status'] = self._deployed_status
else:
# Archive deployment is in progress.
status['status'] = self._inprogress_status
except errors.RequestError:
# Archive's operation did not complete successfully, mark as failed.
status['status'] = self._failed_status
# Add the error to the serialized JSON, but this will not be printed
# in the output columns.
status['error'] = op['error']['message']
return status

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2020 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.
"""Templates for bundles of arguments surrounding a single field."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.calliope import arg_parsers
class HashDelimitedArgList(arg_parsers.ArgList):
DEFAULT_DELIM_CHAR = "#"
def AddEditableListArgument(parser,
singular,
plural,
category_help,
add_metavar=None,
remove_metavar=None,
clear_arg=None,
clear_help=None,
collector_type=None,
dict_like=False,
dest=None):
"""Adds arguments to `parser` for modifying a list field.
A generic implementation of the style guidelines at
go/gcloud-style#createupdate-command-flags.
Args:
parser: the ArgumentParser to which the arguments will be added.
singular: singular form of the name of the field to be modified.
plural: singular form of the name of the field to be modified.
category_help: help text for the commands as a whole. Should explain what
the field itself is.
add_metavar: text to use as a placeholder in the add argument.
remove_metavar: text to use as a placeholder in the remove argument.
clear_arg: what to name the argument that clears the list.
clear_help: help text for the argument that clears the list.
collector_type: type for the add and remove arguments.
dict_like: whether the list field has keys and values.
dest: suffix for fields in the parsed argument object.
"""
mutex_group = parser.add_mutually_exclusive_group()
add_remove_group = mutex_group.add_argument_group(help=category_help)
add_remove_group.add_argument(
"--add-" + singular.lower().replace(" ", "-"),
action=arg_parsers.UpdateAction if dict_like else "append",
type=collector_type or
(arg_parsers.ArgDict() if dict_like else arg_parsers.ArgList()),
dest="add_" + dest if dest else None,
help="Adds a new %s to the set of %s." % (singular, plural),
metavar=add_metavar or singular.upper().replace(" ", "-"))
add_remove_group.add_argument(
"--remove-" + singular.lower().replace(" ", "-"),
action="append",
type=collector_type or arg_parsers.ArgList(),
dest="remove_" + dest if dest else None,
help="Removes an existing %s from the set of %s." % (singular, plural),
metavar=remove_metavar or singular.upper().replace(" ", "-"))
mutex_group.add_argument(
clear_arg if clear_arg else "--clear-" + plural.lower().replace(" ", "-"),
action="store_true",
dest="clear_" + dest if dest else None,
help=clear_help if clear_help else "Removes all %s." % plural)
def AddClearableArgument(parser,
name,
set_help,
clear_help,
dest=None,
**kwargs):
"""Adds arguments to `parser` for modifying or clearing a text field.
A generic implementation of the style guidelines at
go/gcloud-style#createupdate-command-flags.
Args:
parser: the ArgumentParser to which the arguments will be added.
name: name of the field to be modified.
set_help: help text for the argument that sets the field. Should explain
what the field itself is.
clear_help: help text for the argument that clears the field.
dest: suffix for destiantion fields.
**kwargs: additional parameters for the setter argument.
"""
mutex_group = parser.add_mutually_exclusive_group()
mutex_group.add_argument(
"--" + name,
help=set_help,
dest="set_" + (dest or name.replace("-", "_")),
**kwargs)
mutex_group.add_argument(
"--clear-" + name,
dest="clear_" + (dest or name.replace("-", "_")),
help=clear_help,
action="store_true")

View File

@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2020 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.
"""Default values and fallbacks for missing surface arguments."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import collections
import os
from googlecloudsdk.api_lib import apigee
from googlecloudsdk.calliope.concepts import deps
from googlecloudsdk.command_lib.apigee import errors
from googlecloudsdk.core import config
from googlecloudsdk.core import log
from googlecloudsdk.core import properties
from googlecloudsdk.core import yaml
from googlecloudsdk.core.util import files
def _CachedDataWithName(name):
"""Returns the contents of a named cache file.
Cache files are saved as hidden YAML files in the gcloud config directory.
Args:
name: The name of the cache file.
Returns:
The decoded contents of the file, or an empty dictionary if the file could
not be read for whatever reason.
"""
config_dir = config.Paths().global_config_dir
cache_path = os.path.join(config_dir, ".apigee-cached-" + name)
if not os.path.isfile(cache_path):
return {}
try:
return yaml.load_path(cache_path)
except yaml.YAMLParseError:
# Another gcloud command might be in the process of writing to the file.
# Handle as a cache miss.
return {}
def _SaveCachedDataWithName(data, name):
"""Saves `data` to a named cache file.
Cache files are saved as hidden YAML files in the gcloud config directory.
Args:
data: The data to cache.
name: The name of the cache file.
"""
config_dir = config.Paths().global_config_dir
cache_path = os.path.join(config_dir, ".apigee-cached-" + name)
files.WriteFileContents(cache_path, yaml.dump(data))
def _DeleteCachedDataWithName(name):
"""Deletes a named cache file."""
config_dir = config.Paths().global_config_dir
cache_path = os.path.join(config_dir, ".apigee-cached-" + name)
if os.path.isfile(cache_path):
try:
os.remove(cache_path)
except OSError:
return
class Fallthrough(deps.Fallthrough):
"""Base class for Apigee resource argument fallthroughs."""
_handled_fields = []
def __init__(self, hint, active=False, plural=False):
super(Fallthrough, self).__init__(None, hint, active, plural)
def __contains__(self, field):
"""Returns whether `field` is handled by this fallthrough class."""
return field in self._handled_fields
def _Call(self, parsed_args):
raise NotImplementedError(
"Subclasses of googlecloudsdk.commnand_lib.apigee.Fallthrough must "
"actually provide a fallthrough."
)
def _GetProjectMapping(project, user_provided_org=None):
"""Returns the project mapping for the given GCP project.
Args:
project: The GCP project name.
user_provided_org: The organization ID provided by the user, if any.
Returns:
The project mapping for the given GCP project.
"""
project_mappings = _CachedDataWithName("project-mapping-v2") or {}
if user_provided_org:
mapping = project_mappings.get(user_provided_org, None)
if mapping:
return mapping
else:
try:
project_mapping = apigee.OrganizationsClient.ProjectMapping(
{"organizationsId": user_provided_org}
)
if "organization" not in project_mapping:
raise errors.UnauthorizedRequestError(
message=(
'Permission denied on resource "organizations/%s" (or it may'
" not exist)"
)
% user_provided_org
)
project_mappings[project] = project_mapping
_SaveCachedDataWithName(project_mappings, "project-mapping-v2")
return project_mapping
except (errors.EntityNotFoundError, errors.UnauthorizedRequestError):
raise errors.UnauthorizedRequestError(
message=(
'Permission denied on resource "organizations/%s" (or it may'
" not exist)"
)
% user_provided_org
)
except errors.RequestError as e:
raise e
if project not in project_mappings:
try:
project_mapping = apigee.OrganizationsClient.ProjectMapping(
{"organizationsId": project}
)
if "organization" not in project_mapping:
return None
if project_mapping.get("projectId", None) != project:
return None
project_mappings[project] = project_mapping
_SaveCachedDataWithName(project_mappings, "project-mapping-v2")
except (errors.EntityNotFoundError, errors.UnauthorizedRequestError):
return None
except errors.RequestError as e:
raise e
return project_mappings[project]
def _FindMappingForProject(project):
"""Returns the Apigee organization for the given GCP project."""
project_mapping = _CachedDataWithName("project-mapping-v2") or {}
if project in project_mapping:
return project_mapping[project]
# Listing organizations is an expensive operation for users with a lot of GCP
# projects. Since the GCP project -> Apigee organization mapping is immutable
# once created, cache known mappings to avoid the extra API call.
overrides = properties.VALUES.api_endpoint_overrides.apigee.Get()
if overrides:
list_orgs = apigee.OrganizationsClient.List()
else:
list_orgs = apigee.OrganizationsClient.ListOrganizationsGlobal()
for organization in list_orgs["organizations"]:
for matching_project in organization["projectIds"]:
project_mapping[matching_project] = {}
project_mapping[matching_project] = organization
_SaveCachedDataWithName(project_mapping, "project-mapping-v2")
_DeleteCachedDataWithName("project-mapping")
if project not in project_mapping:
return None
return project_mapping[project]
def OrganizationFromGCPProject():
"""Returns the organization associated with the active GCP project."""
project = properties.VALUES.core.project.Get()
if project is None:
log.warning("Neither Apigee organization nor GCP project is known.")
return None
# Use the cached project_mapping_v2 if available. This should handle all the
# cases where the project name is same as the organization name when cache
# miss happens.
project_mapping = _GetProjectMapping(project)
if project_mapping:
return project_mapping["organization"]
# Otherwise, list all organizations and update the project_mapping cache for
# all the projects in the response.
mapping = _FindMappingForProject(project)
if mapping:
return mapping["organization"]
log.warning("No Apigee organization is known for GCP project %s.", project)
log.warning(
"Please provide the argument [--organization] on the command "
"line, or set the property [api_endpoint_overrides/apigee]."
)
return None
class GCPProductOrganizationFallthrough(Fallthrough):
"""Falls through to the organization for the active GCP project."""
_handled_fields = ["organization"]
def __init__(self):
super(GCPProductOrganizationFallthrough, self).__init__(
"set the property [project] or provide the argument [--project] on the "
"command line, using a Cloud Platform project with an associated "
"Apigee organization"
)
def _Call(self, parsed_args):
return OrganizationFromGCPProject()
class StaticFallthrough(Fallthrough):
"""Falls through to a hardcoded value."""
def __init__(self, argument, value):
super(StaticFallthrough, self).__init__(
"leave the argument unspecified for it to be chosen automatically")
self._handled_fields = [argument]
self.value = value
def _Call(self, parsed_args):
return self.value
def FallBackToDeployedProxyRevision(args):
"""If `args` provides no revision, adds the deployed revision, if unambiguous.
Args:
args: a dictionary of resource identifiers which identifies an API proxy and
an environment, to which the deployed revision should be added.
Raises:
EntityNotFoundError: no deployment that matches `args` exists.
AmbiguousRequestError: more than one deployment matches `args`.
"""
deployments = apigee.DeploymentsClient.List(args)
if not deployments:
error_identifier = collections.OrderedDict([
("organization", args["organizationsId"]),
("environment", args["environmentsId"]), ("api", args["apisId"])
])
raise errors.EntityNotFoundError("deployment", error_identifier, "undeploy")
if len(deployments) > 1:
message = "Found more than one deployment that matches this request.\n"
raise errors.AmbiguousRequestError(message + yaml.dump(deployments))
deployed_revision = deployments[0]["revision"]
log.status.Print("Using deployed revision `%s`" % deployed_revision)
args["revisionsId"] = deployed_revision
def GetOrganizationLocation(organization):
"""Returns the location of the Apigee organization."""
project = properties.VALUES.core.project.Get()
mapping = _GetProjectMapping(project, organization)
if mapping:
return mapping.get("location", None)
# Project mapping is not available, assume projectId is not same as
# organization.
mapping = _FindMappingForProject(project)
if mapping:
return mapping.get("location", None)
log.warning("No Apigee organization is known for GCP project %s.", project)
log.warning(
"Please provide the argument [--organization] on the command "
"line, or set the property [api_endpoint_overrides/apigee]."
)
raise errors.LocationResolutionError()

View File

@@ -0,0 +1,304 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2020 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.
"""Error classes for gcloud Apigee commands."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import collections
import json
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import yaml
import six
def _GetResourceIdentifierString(resource_type, resource_identifier):
"""Returns a human readable string representation of a resource identifier.
Args:
resource_type: the type of resource identified by `resource_identifier`.
resource_identifier: an ordered mapping representing a fully qualified
identifier for an Apigee Management API resource.
"""
name_words = [word[0].upper() + word[1:] for word in resource_type.split(" ")]
capitalized_type = "".join(name_words)
# Format as a namedtuple repr; it cleanly handles special character escaping
# while keeping everything on one line.
tuple_type = collections.namedtuple(capitalized_type,
resource_identifier.keys())
return repr(tuple_type(**dict(resource_identifier)))
def _GetErrorDetailsSummary(error_info):
"""Returns a string summarizing `error_info`.
Attempts to interpret error_info as an error JSON returned by the Apigee
management API. If successful, the returned string will be an error message
from that data structure - either its top-level error message, or a list of
precondition violations.
If `error_info` can't be parsed, or has no known error message, returns a YAML
formatted copy of `error_info` instead.
Args:
error_info: a dictionary containing the error data structure returned by the
Apigee Management API.
"""
try:
if "details" in error_info:
# Error response might have info on exactly what preconditions failed or
# what about the arguments was invalid.
violations = []
for item in error_info["details"]:
# Include only those details whose format is known.
detail_types = (
"type.googleapis.com/google.rpc.QuotaFailure",
"type.googleapis.com/google.rpc.PreconditionFailure",
"type.googleapis.com/edge.configstore.bundle.BadBundle",
)
if item["@type"] in detail_types and "violations" in item:
violations += item["violations"]
descriptions = [violation["description"] for violation in violations]
if descriptions:
return error_info["message"] + "\n" + yaml.dump(descriptions)
# Almost always seems to be included.
return error_info["message"]
except KeyError:
# Format of the error details is not as expected. As a fallback, just give
# the user the whole thing.
return "\n" + yaml.dump(error_info)
class AmbiguousRequestError(exceptions.Error):
"""Raised when the user makes a request for an ambiguously defined resource.
Sometimes arguments are optional in the general case because their correct
values can generally be inferred, but required for cases when that inferrence
isn't possible. This error covers that scenario.
"""
pass
class RequestError(exceptions.Error):
"""Raised when a request to the Apigee Management API has failed."""
def __init__(self,
resource_type=None,
resource_identifier=None,
method=None,
reason=None,
body=None,
message=None,
user_help=None):
self.details = None
if body:
try:
# In older versions of Python 3, the built-in JSON library will only
# accept strings, not bytes.
if not isinstance(body, str) and hasattr(body, "decode"):
body = body.decode()
self.details = json.loads(body)
if "error" in self.details:
self.details = self.details["error"]
except ValueError:
pass
self.reason = reason
self.resource_type = resource_type
self.resource_identifier = resource_identifier
self.user_help = user_help
if not message:
if not method:
method = "access"
if not resource_type:
resource_type = "resource"
message = "Failed to %s %s" % (method, resource_type)
if reason:
message += " (%s)" % (reason) if reason else ""
if resource_identifier:
message += ":\n" + _GetResourceIdentifierString(resource_type,
resource_identifier)
if self.details:
message += "\nDetails: " + _GetErrorDetailsSummary(self.details)
if user_help:
message += "\n" + user_help
super(RequestError, self).__init__(message)
def RewrittenError(self, resource_type, method):
"""Returns a copy of the error with a new resource type and method."""
body = json.dumps(self.details) if self.details else None
return type(self)(
resource_type,
self.resource_identifier,
method=method,
reason=self.reason,
body=body,
user_help=self.user_help)
class ResponseNotJSONError(RequestError):
"""Raised when a request to the Apigee API returns a malformed response."""
def __init__(self,
error,
resource_type=None,
resource_identifier=None,
body=None,
user_help=None):
if all(hasattr(error, attr) for attr in ["msg", "lineno", "colno"]):
reason = "%s at %d:%d" % (error.msg, error.lineno, error.colno)
else:
reason = six.text_type(error)
super(ResponseNotJSONError, self).__init__(
resource_type,
resource_identifier,
"parse",
reason,
json.dumps({"response": body}),
user_help=user_help)
self.base_error = error
def RewrittenError(self, resource_type, method):
"""Returns a copy of the error with a new resource type."""
body = self.details["response"] if self.details else None
return type(self)(
self.base_error,
resource_type,
self.resource_identifier,
body=body,
user_help=self.user_help)
class UnauthorizedRequestError(RequestError):
"""Raised when a request to the Apigee API had insufficient privileges."""
def __init__(self,
resource_type=None,
resource_identifier=None,
method=None,
reason=None,
body=None,
message=None,
user_help=None):
resource_type = resource_type or "resource"
method = method or "access"
if not message:
message = "Insufficient privileges to %s the requested %s" % (
method, resource_type)
if reason:
message += "; " + reason
if resource_identifier:
message += "\nRequested: " + _GetResourceIdentifierString(
resource_type, resource_identifier)
if user_help:
message += "\n" + user_help
super(UnauthorizedRequestError,
self).__init__(resource_type, resource_identifier, method, reason,
body, message, user_help)
class EntityNotFoundError(RequestError):
"""Raised when a request to the Apigee API mentions a nonexistant resource."""
def __init__(self,
resource_type=None,
resource_identifier=None,
method=None,
reason=None,
body=None,
message=None,
user_help=None):
resource_type = resource_type or "resource"
if not message:
message = "Requested %s does not exist" % (resource_type)
if resource_identifier:
message += ": " + _GetResourceIdentifierString(resource_type,
resource_identifier)
if user_help:
message += "\n" + user_help
super(EntityNotFoundError,
self).__init__(resource_type, resource_identifier, method, reason,
body, message, user_help)
class HttpRequestError(RequestError):
"""Raised for generic HTTP errors.
Used for HTTP requests sent to an endpoint other than the Apigee Management
API.
"""
def __init__(self, status_code, reason, content):
err_tmpl = ("- HTTP status: {}\n- Reason: {}\n- Message: {}\n"
"Use the --log-http flag to see more information.")
self.message = err_tmpl.format(status_code, reason, content)
super(HttpRequestError, self).__init__(message=self.message)
class MissingIdentifierError(exceptions.Error):
"""Raised when a request to the Apigee API is missing an expected identifier.
Normally this situation should be caught by a required argument being missing
or similar; this error is a fallback in case a corner case slips past those
higher level checks.
"""
def __init__(self, name):
message = "Command requires a %s but no %s was provided." % (name, name)
super(MissingIdentifierError, self).__init__(message)
class SourcePathIsNotDirectoryError(exceptions.Error):
"""Raised when the source path is not a directory.
The deploy command validates that the file path provided by the --source
command line flag is a directory, and if not, raises this exception.
"""
def __init__(self, src_path):
msg = "Source path is not a directory: {}".format(src_path)
super(SourcePathIsNotDirectoryError, self).__init__(msg)
class BundleFileNotValidError(exceptions.Error):
"""Raised when a bundle file is not valid.
The deploy command validates that the bundle file provided by the
--bundle-file command line flag is a valid zip archive, and if not, raises
this exception.
"""
def __init__(self, bundle_file):
msg = "Bundle file is not a valid zip archive: {}".format(bundle_file)
super(BundleFileNotValidError, self).__init__(msg)
class LocationResolutionError(exceptions.Error):
"""Raised when the unable to resolve the location of the Apigee organization.
All the commands in the apigee surface require a valid location to execute.
This error is raised when the gcloud command is unable to determine the
location of the Apigee organization.
"""
def __init__(self):
msg = "Failed to resolve the location of the Apigee organization."
super(LocationResolutionError, self).__init__(msg)

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2020 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.
"""Helper methods for interactive prompting."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from googlecloudsdk.command_lib.apigee import errors
from googlecloudsdk.command_lib.apigee import resource_args
from googlecloudsdk.core.console import console_io
def ResourceFromFreeformPrompt(name, long_name, list_func):
"""Prompts the user to select a resource.
Args:
name: the name of the resource. For example, "environment" or "developer".
long_name: a longer form of `name` which the user will see in prompts.
Should explain the context in which the resource will be used. For
example, "the environment to be updated".
list_func: a function that returns the names of existing resources.
Returns:
The resource's identifier if successful, or None if not.
"""
resource_list = []
try:
resource_list = list_func()
except errors.RequestError:
# Organization list flakiness, no matches, etc.
pass
entity_names = resource_args.ENTITIES[name]
if resource_list:
enter_manually = "(some other %s)" % entity_names.docs_name
choice = console_io.PromptChoice(
resource_list + [enter_manually],
prompt_string="Select %s:" % long_name)
# If the user selected an option from resource_list, not the "enter
# manually" option at the bottom...
if choice < len(resource_list):
return resource_list[choice]
valid_pattern = resource_args.ValidPatternForEntity(name)
validator = lambda response: valid_pattern.search(response) is not None
error_str = "Doesn't match the expected format of a " + entity_names.docs_name
prompt_message = "Enter %s: " % long_name
return console_io.PromptWithValidator(validator, error_str, prompt_message)
def ListFromFreeformPrompt(message, add_message, empty_done_message):
"""Returns a list of strings inputted by the user.
Args:
message: the message to display when prompting for a new string.
add_message: the menu option for adding a new string to the list.
empty_done_message: the menu option to display for the "Done" option if no
strings have been selected.
"""
chosen = []
menu_option = 0
while menu_option <= len(chosen):
if menu_option < len(chosen):
chosen = chosen[:menu_option] + chosen[menu_option + 1:]
elif menu_option == len(chosen):
input_data = console_io.PromptResponse(message)
chosen.append(input_data)
options = ["Remove `%s`" % item for item in chosen]
options.append(add_message)
options.append("Done" if chosen else empty_done_message)
menu_option = console_io.PromptChoice(options)
return chosen
def ResourceListFromPrompt(name, list_func, end_empty_message=None):
"""Returns a list of resources selected by the user.
Args:
name: the entity name for the resources being selected.
list_func: a zero argument function that will return a list of existing
resources.
end_empty_message: text for the menu option that will return an empty list.
"""
resource_list = list_func()
if not resource_list:
docs_name = resource_args.ENTITIES[name].docs_name
raise errors.EntityNotFoundError(
message=("Could not find any %s to select. Check that at least one %s "
"has been created and is properly configued for use." %
(docs_name, docs_name)))
chosen = []
available = None
menu_option = len(resource_list) + 1
while menu_option != len(resource_list):
if menu_option < len(chosen):
# Falls within the "chosen" menu options. Remove the resource at exactly
# the selected slot.
chosen = chosen[:menu_option] + chosen[menu_option + 1:]
elif menu_option < len(resource_list):
# Falls within the "available" menu options.
index = menu_option - len(chosen)
chosen.append(available[index])
available = [item for item in resource_list if item not in chosen]
options = ["Remove `%s`" % item for item in chosen]
options += ["Add `%s`" % item for item in available]
if chosen:
message = "Currently selected: %s" % ", ".join(chosen)
options.append("Done")
else:
message = "No %s selected yet" % resource_args.ENTITIES[name].docs_name
if end_empty_message is not None:
options.append(end_empty_message)
menu_option = console_io.PromptChoice(options, message=message)
return chosen

View File

@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2020 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.
"""Generalized Apigee Management API request handler.
The Apigee Management APIs were designed before One Platform, and include some
design decisions incompatible with apitools (see b/151099218). So the gcloud
apigee surface must make its own HTTPS requests instead of relying on an
apitools-generated client.
"""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
import collections
import json
from googlecloudsdk.command_lib.apigee import defaults
from googlecloudsdk.command_lib.apigee import errors
from googlecloudsdk.command_lib.apigee import resource_args
from googlecloudsdk.core import properties
from googlecloudsdk.core.credentials import requests
from six.moves import urllib
APIGEE_GLOBAL_HOST = "apigee.googleapis.com"
APIGEE_LEP_HOST = "%s-apigee.googleapis.com"
ERROR_FIELD = "error"
MESSAGE_FIELD = "message"
def _ResourceIdentifier(identifiers, entity_path):
"""Returns an OrderedDict uniquely identifying the resource to be accessed.
Args:
identifiers: a collection that maps entity type names to identifiers.
entity_path: a list of entity type names from least to most specific.
Raises:
MissingIdentifierError: an entry in entity_path is missing from
`identifiers`.
"""
resource_identifier = collections.OrderedDict()
for entity_name in entity_path:
entity = resource_args.ENTITIES[entity_name]
id_key = entity.plural + "Id"
if id_key not in identifiers or identifiers[id_key] is None:
raise errors.MissingIdentifierError(entity.singular)
resource_identifier[entity] = identifiers[id_key]
return resource_identifier
def _Communicate(url, method, body, headers):
"""Returns HTTP status, reason, and response body for a given HTTP request."""
response = requests.GetSession().request(
method, url, data=body, headers=headers, stream=True)
status = response.status_code
reason = response.reason
data = response.content
return status, reason, data
def _DecodeResponse(response):
"""Returns decoded string.
Args:
response: the raw string or bytes of JSON data
Raises:
ValueError: failure to load/decode JSON data
"""
# In older versions of Python 3, the built-in JSON library will only
# accept strings, not bytes.
if not isinstance(response, str) and hasattr(response, "decode"):
response = response.decode()
return response
def _GetResourceType(entity_collection, entity_path):
"""Gets resource type from the inputed data."""
return entity_collection or entity_path[-1]
def _BuildErrorIdentifier(resource_identifier):
"""Builds error identifier from inputed data."""
return collections.OrderedDict([
(key.singular, value) for key, value in resource_identifier.items()
])
def _ExtractErrorMessage(response):
"""Extracts error message from response, returns None if message not found."""
json_response = json.loads(response)
if ERROR_FIELD in json_response and isinstance(
json_response[ERROR_FIELD],
dict) and MESSAGE_FIELD in json_response[ERROR_FIELD]:
return json_response[ERROR_FIELD][MESSAGE_FIELD]
return None
def _GetApigeeHostByOrganization(organization):
"""Returns the Apigee host based on the organization."""
location = defaults.GetOrganizationLocation(organization)
return _GetApigeeHostByLocation(location)
def _GetApigeeHostByLocation(location=None):
"""Returns the Apigee host based on the location."""
if location is None or location == "global" or not location:
return APIGEE_GLOBAL_HOST
return APIGEE_LEP_HOST % location
def ResponseToApiRequest(identifiers,
entity_path,
entity_collection=None,
method="GET",
query_params=None,
accept_mimetype=None,
body=None,
body_mimetype="application/json",
method_override=None,
location=None):
"""Makes a request to the Apigee API and returns the response.
Args:
identifiers: a collection that maps entity type names to identifiers.
entity_path: a list of entity type names from least to most specific.
entity_collection: if provided, the final entity type; the request will not
be specific as to which entity of that type is being referenced.
method: an HTTP method string specifying what to do with the accessed
entity. If the method begins with a colon, it will be interpreted as a
Cloud custom method (https://cloud.google.com/apis/design/custom_methods)
and appended to the request URL with the POST HTTP method.
query_params: any extra query parameters to be sent in the request.
accept_mimetype: the mimetype to expect in the response body. If not
provided, the response will be parsed as JSON.
body: data to send in the request body.
body_mimetype: the mimetype of the body data, if not JSON.
method_override: the HTTP method to use for the request, when method starts
with a colon.
location: the location of the apigee organization.
Returns:
an object containing the API's response. If accept_mimetype was set, this
will be raw bytes. Otherwise, it will be a parsed JSON object.
Raises:
MissingIdentifierError: an entry in entity_path is missing from
`identifiers`.
RequestError: if the request itself fails.
"""
headers = {}
if body:
headers["Content-Type"] = body_mimetype
if accept_mimetype:
headers["Accept"] = accept_mimetype
resource_identifier = _ResourceIdentifier(identifiers, entity_path)
url_path_elements = ["v1"]
for key, value in resource_identifier.items():
url_path_elements += [key.plural, urllib.parse.quote(value)]
if entity_collection:
collection_name = resource_args.ENTITIES[entity_collection].plural
url_path_elements.append(urllib.parse.quote(collection_name))
query_string = urllib.parse.urlencode(query_params) if query_params else ""
endpoint_override = properties.VALUES.api_endpoint_overrides.apigee.Get()
if location:
scheme = "https"
# Construct the host based on the location.
host = _GetApigeeHostByLocation(location)
elif endpoint_override:
endpoint = urllib.parse.urlparse(endpoint_override)
scheme = endpoint.scheme
host = endpoint.netloc
else:
scheme = "https"
# Construct the host based on the organization location.
organization = identifiers.get("organizationsId", None)
host = _GetApigeeHostByOrganization(organization)
url_path = "/".join(url_path_elements)
if method and method[0] == ":":
url_path += method
method = "POST"
if method_override:
method = method_override
url = urllib.parse.urlunparse((scheme, host, url_path, "", query_string, ""))
status, reason, response = _Communicate(url, method, body, headers)
if status >= 400:
resource_type = _GetResourceType(entity_collection, entity_path)
if status == 404:
exception_class = errors.EntityNotFoundError
elif status in (401, 403):
exception_class = errors.UnauthorizedRequestError
else:
exception_class = errors.RequestError
error_identifier = _BuildErrorIdentifier(resource_identifier)
try:
user_help = _ExtractErrorMessage(_DecodeResponse(response))
except ValueError:
user_help = None
raise exception_class(resource_type, error_identifier, method,
reason, response, user_help=user_help)
if accept_mimetype is None:
try:
response = _DecodeResponse(response)
response = json.loads(response)
except ValueError as error:
resource_type = _GetResourceType(entity_collection, entity_path)
error_identifier = _BuildErrorIdentifier(resource_identifier)
raise errors.ResponseNotJSONError(error, resource_type, error_identifier,
response)
return response

View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*- # Lint as: python3
# Copyright 2021 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.
"""Specifications for resource-identifying command line parameters."""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import collections
import re
from googlecloudsdk.calliope import arg_parsers
from googlecloudsdk.calliope.concepts import concepts
from googlecloudsdk.command_lib.util.concepts import concept_parsers
# The singular name is used internally by gcloud to identify the reference type
# and should be named according to the rules in the Cloud SDK internal docs.
# The plural name refers to the entity name in the API resource path, e.g.,
# apigee.googleapis.com/v1/myEntityNamePlurals/...
_EntityNames = collections.namedtuple(
"EntityNames",
"singular plural docs_name valid_pattern secondary_description")
_ENTITY_TUPLES = [
_EntityNames("project", "projects", "projects", None,
"GCP project containing the {resource}."),
_EntityNames(
"organization", "organizations", "organization",
r"^[a-z][-a-z0-9]{0,30}[a-z0-9]$",
"Apigee organization containing the {resource}. If "
"unspecified, the Cloud Platform project's associated "
"organization will be used."),
_EntityNames("api", "apis", "API proxy", r"^[\s\w.-]{1,255}$",
"API proxy for the {resource}."),
_EntityNames("environment", "environments", "environment",
r"^[a-z][-a-z0-9]{0,30}[a-z0-9]$",
"Deployment environment of the {resource}."),
_EntityNames("revision", "revisions", "revision", None,
"Revision of the {resource}."),
_EntityNames("deployment", "deployments", "deployment", None,
"Relevant deployment of the {resource}."),
_EntityNames("operation", "operations", "operation", None,
"Operation operating on the {resource}."),
_EntityNames("product", "apiproducts", "API product",
r"^[A-Za-z0-9._$ %-]+$",
"Relevant product for the {resource}."),
_EntityNames("developer", "developers", "developer", None,
"Developer of the {resource}."),
_EntityNames("app", "apps", "application", None,
"Relevant application for the {resource}."),
_EntityNames("archive_deployment", "archiveDeployments",
"archive deployment", None,
"Archive deployment for {resource}")
]
ENTITIES = {item.singular: item for item in _ENTITY_TUPLES}
def _ValidPatternForEntity(name):
pattern = ENTITIES[name].valid_pattern
return r".*" if pattern is None else pattern
def ValidPatternForEntity(entity_name):
"""Returns a compiled regex that matches valid values for `entity_name`."""
return re.compile(_ValidPatternForEntity(entity_name))
def AttributeConfig(name, fallthroughs=None, help_text=None, validate=False):
"""Returns a ResourceParameterAttributeConfig for the attribute named `name`.
Args:
name: singular name of the attribute. Must exist in ENTITIES.
fallthroughs: optional list of gcloud fallthrough objects which should be
used to get this attribute's value if the user doesn't specify one.
help_text: help text to use for this resource parameter instead of the
default help text for the attribute.
validate: whether to check that user-provided value for this attribute
matches the expected pattern.
"""
validator = None
if validate:
validator = arg_parsers.RegexpValidator(
_ValidPatternForEntity(name),
"Must match the format of a valid {2} ({3})".format(*ENTITIES[name]))
return concepts.ResourceParameterAttributeConfig(
name=name,
parameter_name=ENTITIES[name].plural,
value_type=validator,
help_text=help_text or ENTITIES[name].secondary_description,
fallthroughs=fallthroughs)
def ResourceSpec(path, fallthroughs=tuple(), help_texts=None, validate=False):
"""Returns a ResourceSpec for the resource path `path`.
Args:
path: a list of attribute names. All must exist in ENTITIES.
fallthroughs: optional list of googlecloudsdk.command_lib.apigee.Fallthrough
objects which will provide default values for the attributes in `path`.
help_texts: a mapping of attribute names to help text strings, to use
instead of their default help text.
validate: whether to check that the user-provided resource matches the
expected naming conventions of the resource path.
"""
help_texts = collections.defaultdict(lambda: None, help_texts or {})
entities = [ENTITIES[name] for name in path]
ids = {}
for entity in entities:
relevant_fallthroughs = [
fallthrough for fallthrough in fallthroughs
if entity.singular in fallthrough
]
config = AttributeConfig(
entity.singular,
relevant_fallthroughs,
help_texts[entity.singular],
validate=validate)
ids[entity.plural + "Id"] = config
return concepts.ResourceSpec(
"apigee." + ".".join(entity.plural for entity in entities),
resource_name=entities[-1].docs_name,
**ids)
def AddSingleResourceArgument(parser,
resource_path,
help_text,
fallthroughs=tuple(),
positional=True,
argument_name=None,
required=None,
prefixes=False,
validate=False,
help_texts=None):
"""Creates a concept parser for `resource_path` and adds it to `parser`.
Args:
parser: the argparse.ArgumentParser to which the concept parser will be
added.
resource_path: path to the resource, in `entity.other_entity.leaf` format.
help_text: the help text to display when describing the resource as a whole.
fallthroughs: fallthrough providers for entities in resource_path.
positional: whether the leaf entity should be provided as a positional
argument, rather than as a flag.
argument_name: what to name the leaf entity argument. Defaults to the leaf
entity name from the resource path.
required: whether the user is required to provide this resource. Defaults to
True for positional arguments, False otherwise.
prefixes: whether to append prefixes to the non-leaf arguments.
validate: whether to check that the user-provided resource matches the
expected naming conventions of the resource path.
help_texts: custom help text for generated arguments. Defaults to each
entity using a generic help text.
"""
split_path = resource_path.split(".")
if argument_name is None:
leaf_element_name = split_path[-1]
if positional:
argument_name = leaf_element_name.upper()
else:
argument_name = "--" + leaf_element_name.replace("_", "-")
if required is None:
required = positional
concept_parsers.ConceptParser.ForResource(
argument_name,
ResourceSpec(split_path, fallthroughs, help_texts, validate=validate),
help_text,
required=required,
prefixes=prefixes).AddToParser(parser)