383 lines
13 KiB
Python
383 lines
13 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2015 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 all projects commands."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import datetime
|
|
import enum
|
|
import re
|
|
|
|
from apitools.base.py import exceptions as apitools_exceptions
|
|
from apitools.base.py import list_pager
|
|
from apitools.base.py.exceptions import HttpForbiddenError
|
|
from googlecloudsdk.api_lib.cloudresourcemanager import organizations
|
|
from googlecloudsdk.api_lib.cloudresourcemanager import projects_api
|
|
from googlecloudsdk.api_lib.cloudresourcemanager import projects_util
|
|
from googlecloudsdk.api_lib.iam import policies
|
|
from googlecloudsdk.api_lib.resource_manager import folders
|
|
from googlecloudsdk.api_lib.resource_manager import tags
|
|
from googlecloudsdk.calliope import base
|
|
from googlecloudsdk.command_lib.iam import iam_util
|
|
from googlecloudsdk.command_lib.projects import exceptions
|
|
from googlecloudsdk.command_lib.resource_manager import tag_utils
|
|
from googlecloudsdk.core import exceptions as core_exceptions
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import resources
|
|
|
|
PROJECTS_COLLECTION = 'cloudresourcemanager.projects'
|
|
PROJECTS_API_VERSION = projects_util.DEFAULT_API_VERSION
|
|
_CLOUD_CONSOLE_LAUNCH_DATE = datetime.datetime(2012, 10, 11)
|
|
LIST_FORMAT = """
|
|
table(
|
|
projectId:sort=1,
|
|
name,
|
|
projectNumber,
|
|
tags.environment:label='ENVIRONMENT'
|
|
)
|
|
"""
|
|
|
|
_VALID_PROJECT_REGEX = re.compile(
|
|
r'^'
|
|
# An optional domain-like component, ending with a colon, e.g.,
|
|
# google.com:
|
|
r'(?:(?:[-a-z0-9]{1,63}\.)*(?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?):)?'
|
|
# Followed by a required identifier-like component, for example:
|
|
# waffle-house match
|
|
# -foozle no match
|
|
# Foozle no match
|
|
# We specifically disallow project number, even though some GCP backends
|
|
# could accept them.
|
|
# We also allow a leading digit as some legacy project ids can have
|
|
# a leading digit.
|
|
r'(?:(?:[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?))'
|
|
r'$')
|
|
|
|
|
|
@enum.unique
|
|
class Environment(enum.Enum):
|
|
PRODUCTION = 'Production'
|
|
DEVELOPMENT = 'Development'
|
|
TEST = 'Test'
|
|
STAGING = 'Staging'
|
|
|
|
|
|
_ENV_STANDARD_TO_VARIANT_VALUE_MAPPING = {
|
|
# Production
|
|
Environment.PRODUCTION: {'Prod', 'prod', 'Production', 'production'},
|
|
# Development
|
|
Environment.DEVELOPMENT: {'Dev', 'dev', 'Development', 'development'},
|
|
# Test
|
|
Environment.TEST: {
|
|
'Test',
|
|
'test',
|
|
'Testing',
|
|
'testing QA',
|
|
'qa',
|
|
'Quality assurance',
|
|
'quality assurance',
|
|
},
|
|
# Staging
|
|
Environment.STAGING: {'Staging', 'staging', 'Stage', 'stage'},
|
|
}
|
|
|
|
_ENV_VARIANT_TO_STANDARD_VALUE_MAPPING = {}
|
|
for (
|
|
standard_value,
|
|
variant_values,
|
|
) in _ENV_STANDARD_TO_VARIANT_VALUE_MAPPING.items():
|
|
for variant_value in variant_values:
|
|
_ENV_VARIANT_TO_STANDARD_VALUE_MAPPING[variant_value] = standard_value
|
|
|
|
|
|
def ValidateProjectIdentifier(project):
|
|
"""Checks to see if the project string is valid project name or number."""
|
|
if not isinstance(project, str):
|
|
return False
|
|
|
|
if project.isdigit() or _VALID_PROJECT_REGEX.match(project):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def GetProjectNumber(project_id):
|
|
return projects_api.Get(ParseProject(project_id)).projectNumber
|
|
|
|
|
|
def ParseProject(project_id, api_version=PROJECTS_API_VERSION):
|
|
# Override the default API map version so we can increment API versions on a
|
|
# API interface basis.
|
|
registry = resources.REGISTRY.Clone()
|
|
registry.RegisterApiByName('cloudresourcemanager', api_version)
|
|
return registry.Parse(
|
|
None, params={'projectId': project_id}, collection=PROJECTS_COLLECTION)
|
|
|
|
|
|
def ProjectsUriFunc(resource, api_version=PROJECTS_API_VERSION):
|
|
project_id = (
|
|
resource.get('projectId', None)
|
|
if isinstance(resource, dict) else resource.projectId)
|
|
ref = ParseProject(project_id, api_version)
|
|
return ref.SelfLink()
|
|
|
|
|
|
def IdFromName(project_name):
|
|
"""Returns a candidate id for a new project with the given name.
|
|
|
|
Args:
|
|
project_name: Human-readable name of the project.
|
|
|
|
Returns:
|
|
A candidate project id, or 'None' if no reasonable candidate is found.
|
|
"""
|
|
|
|
def SimplifyName(name):
|
|
name = name.lower()
|
|
name = re.sub(r'[^a-z0-9\s/._-]', '', name, flags=re.U)
|
|
name = re.sub(r'[\s/._-]+', '-', name, flags=re.U)
|
|
name = name.lstrip('-0123456789').rstrip('-')
|
|
return name
|
|
|
|
def CloudConsoleNowString():
|
|
now = datetime.datetime.utcnow()
|
|
return '{}{:02}'.format((now - _CLOUD_CONSOLE_LAUNCH_DATE).days, now.hour)
|
|
|
|
def GenIds(name):
|
|
base_ = SimplifyName(name)
|
|
# Cloud Console generates the two following candidates in the opposite
|
|
# order, but they are validating uniqueness and we're not, so we put the
|
|
# "more unique" suggestion first.
|
|
yield base_ + '-' + CloudConsoleNowString()
|
|
yield base_
|
|
# Cloud Console has an four-tier "allocate an unused id" architecture for
|
|
# coining ids *not* based on the project name. This might be sensible for
|
|
# an interface where ids are expected to be auto-generated, but seems like
|
|
# major overkill (and a shift in paradigm from "assistant" to "wizard") for
|
|
# gcloud. -shearer@ 2016-11
|
|
|
|
def IsValidId(i):
|
|
# TODO(b/32950431) could check availability of id
|
|
return 6 <= len(i) <= 30
|
|
|
|
for i in GenIds(project_name):
|
|
if IsValidId(i):
|
|
return i
|
|
return None
|
|
|
|
|
|
def GetDetailedHelpForRemoveIamPolicyBinding():
|
|
"""Returns detailed_help for a remove-iam-policy-binding command."""
|
|
detailed_help = iam_util.GetDetailedHelpForRemoveIamPolicyBinding(
|
|
'project', 'example-project-id-1', condition=True
|
|
)
|
|
detailed_help[
|
|
'DESCRIPTION'
|
|
] += ' One binding consists of a member, a role and an optional condition.'
|
|
detailed_help['API REFERENCE'] = (
|
|
'This command uses the cloudresourcemanager/v1 API. The full'
|
|
' documentation for this API can be found at:'
|
|
' https://cloud.google.com/resource-manager'
|
|
)
|
|
return detailed_help
|
|
|
|
|
|
def SetIamPolicyFromFileHook(ref, args, request):
|
|
"""Hook to perserve SetIAMPolicy behavior for declarative surface."""
|
|
del ref
|
|
del args
|
|
update_mask = request.setIamPolicyRequest.updateMask
|
|
if update_mask:
|
|
# To preserve the existing set-iam-policy behavior of always overwriting
|
|
# bindings and etag, add bindings and etag to update_mask.
|
|
mask_fields = update_mask.split(',')
|
|
if 'bindings' not in mask_fields:
|
|
mask_fields.append('bindings')
|
|
|
|
if 'etag' not in update_mask:
|
|
mask_fields.append('etag')
|
|
request.setIamPolicyRequest.updateMask = ','.join(mask_fields)
|
|
return request
|
|
|
|
|
|
def GetIamPolicyWithAncestors(project_id, include_deny, release_track):
|
|
"""Get IAM policy for given project and its ancestors.
|
|
|
|
Args:
|
|
project_id: project id
|
|
include_deny: boolean that represents if we should show the deny policies in
|
|
addition to the grants
|
|
release_track: which release track, include deny is only supported for ALPHA
|
|
or BETA
|
|
|
|
Returns:
|
|
IAM policy for given project and its ancestors
|
|
"""
|
|
iam_policies = []
|
|
ancestry = projects_api.GetAncestry(project_id)
|
|
|
|
try:
|
|
for resource in ancestry.ancestor:
|
|
resource_type = resource.resourceId.type
|
|
resource_id = resource.resourceId.id
|
|
# this is the given project
|
|
if resource_type == 'project':
|
|
project_ref = ParseProject(project_id)
|
|
iam_policies.append({
|
|
'type': 'project',
|
|
'id': project_id,
|
|
'policy': projects_api.GetIamPolicy(project_ref)
|
|
})
|
|
if include_deny:
|
|
deny_policies = policies.ListDenyPolicies(project_id, 'project',
|
|
release_track)
|
|
for deny_policy in deny_policies:
|
|
iam_policies.append({
|
|
'type': 'project',
|
|
'id': project_id,
|
|
'policy': deny_policy
|
|
})
|
|
if resource_type == 'folder':
|
|
iam_policies.append({
|
|
'type': resource_type,
|
|
'id': resource_id,
|
|
'policy': folders.GetIamPolicy(resource_id)
|
|
})
|
|
if include_deny:
|
|
deny_policies = policies.ListDenyPolicies(resource_id, 'folder',
|
|
release_track)
|
|
for deny_policy in deny_policies:
|
|
iam_policies.append({
|
|
'type': 'folder',
|
|
'id': resource_id,
|
|
'policy': deny_policy
|
|
})
|
|
if resource_type == 'organization':
|
|
iam_policies.append({
|
|
'type': resource_type,
|
|
'id': resource_id,
|
|
'policy': organizations.Client().GetIamPolicy(resource_id),
|
|
})
|
|
if include_deny:
|
|
deny_policies = policies.ListDenyPolicies(resource_id, 'organization',
|
|
release_track)
|
|
for deny_policy in deny_policies:
|
|
iam_policies.append({
|
|
'type': 'organization',
|
|
'id': resource_id,
|
|
'policy': deny_policy
|
|
})
|
|
|
|
return iam_policies
|
|
except HttpForbiddenError:
|
|
raise exceptions.AncestorsIamPolicyAccessDeniedError(
|
|
'User is not permitted to access IAM policy for one or more of the'
|
|
' ancestors')
|
|
|
|
|
|
def GetEnvironmentTag(project_id):
|
|
"""Returns the environment tag for the project id."""
|
|
messages = tags.TagMessages()
|
|
project_ref = ParseProject(project_id)
|
|
resource_name = tag_utils.GetCanonicalResourceName(
|
|
project_ref.RelativeName(), None, base.ReleaseTrack.GA
|
|
)
|
|
try:
|
|
effective_tags_request = (
|
|
messages.CloudresourcemanagerEffectiveTagsListRequest(
|
|
parent=resource_name
|
|
)
|
|
)
|
|
effective_tags_response = list_pager.YieldFromList(
|
|
tags.EffectiveTagsService(),
|
|
effective_tags_request,
|
|
batch_size_attribute='pageSize',
|
|
field='effectiveTags',
|
|
)
|
|
|
|
for effective_tag in effective_tags_response:
|
|
if effective_tag.namespacedTagKey.endswith('/environment'):
|
|
return (
|
|
effective_tag.namespacedTagKey.split('/')[-1],
|
|
effective_tag.namespacedTagValue.split('/')[-1],
|
|
)
|
|
except (apitools_exceptions.HttpError, core_exceptions.Error) as e:
|
|
# Gracefully print a warning message.
|
|
log.info(
|
|
'Unable to get environment tag for project [{0}]: {1}'.format(
|
|
project_id, e
|
|
)
|
|
)
|
|
return (None, None)
|
|
|
|
|
|
def GetStandardEnvironmentValue(value):
|
|
return _ENV_VARIANT_TO_STANDARD_VALUE_MAPPING.get(value, None)
|
|
|
|
|
|
def PrintEnvironmentTagMessage(project_id):
|
|
"""Prints environment tag message given project ID."""
|
|
env_tag_key, env_tag_value = GetEnvironmentTag(project_id)
|
|
if not env_tag_key:
|
|
log.info(
|
|
"Project '{0}' lacks an 'environment' tag. Please create or add a tag"
|
|
" with key 'environment' and a value like 'Production',"
|
|
" 'Development', 'Test', or 'Staging'. Add an 'environment' tag using"
|
|
" `gcloud resource-manager tags bindings create`. See"
|
|
" https://cloud.google.com/resource-manager/docs/creating-managing-projects#designate_project_environments_with_tags"
|
|
" for details.".format(project_id)
|
|
)
|
|
return
|
|
|
|
env_standard_value = GetStandardEnvironmentValue(
|
|
env_tag_value
|
|
)
|
|
if not env_standard_value:
|
|
log.info(
|
|
"Project '{0}' has an 'environment' tag with an"
|
|
" unrecognized value. Please use a standard value such as"
|
|
" 'Production', 'Development', 'Test', or 'Staging'. You can update"
|
|
" the tag value using `gcloud resource-manager tags bindings"
|
|
" create`. Refer to https://cloud.google.com/resource-manager/docs/"
|
|
"creating-managing-projects#designate_project_environments_with_tags"
|
|
" for more guidance.".format(project_id)
|
|
)
|
|
return
|
|
|
|
if env_standard_value == Environment.PRODUCTION:
|
|
log.warning(
|
|
"Project '{0}' is designated as '{2}'(tagged 'environment: {1}')."
|
|
" Making changes could affect your '{2}' apps."
|
|
' Learn more at https://cloud.google.com/resource-manager/docs/'
|
|
'creating-managing-projects#designate_project_environments_with_tags'
|
|
.format(
|
|
project_id, env_tag_value, env_standard_value.value
|
|
)
|
|
)
|
|
else:
|
|
log.info(
|
|
"Caution: Project '{0}' is designated as '{2}'(tagged"
|
|
" 'environment: {1}'). Making changes could affect your '{2}'"
|
|
' apps. If incorrect, you can update it by managing the tag'
|
|
" binding for the 'environment' key using `gcloud"
|
|
" resource-manager tags bindings create`. Learn more at"
|
|
' https://cloud.google.com/resource-manager/docs/creating-managing-projects#designate_project_environments_with_tags'
|
|
.format(
|
|
project_id, env_tag_value, env_standard_value.value
|
|
)
|
|
)
|