447 lines
13 KiB
Python
447 lines
13 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# 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.
|
|
"""General utilties for Cloud Domains commands."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import re
|
|
|
|
from apitools.base.py import encoding
|
|
|
|
from googlecloudsdk.api_lib.domains import operations
|
|
from googlecloudsdk.api_lib.domains import registrations
|
|
from googlecloudsdk.command_lib.domains import flags
|
|
from googlecloudsdk.core import exceptions
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import properties
|
|
from googlecloudsdk.core import resources
|
|
from googlecloudsdk.core import yaml
|
|
from googlecloudsdk.core.console import console_io
|
|
from googlecloudsdk.core.util import files
|
|
|
|
LOCATIONS_COLLECTION = 'domains.projects.locations'
|
|
OPERATIONS_COLLECTION = 'domains.projects.locations.operations'
|
|
REGISTRATIONS_COLLECTION = 'domains.projects.locations.registrations'
|
|
_PROJECT = lambda: properties.VALUES.core.project.Get(required=True)
|
|
_MAX_LIST_BATCH_SIZE = 200
|
|
|
|
|
|
def RegistrationsUriFunc(api_version):
|
|
def UriFunc(resource):
|
|
return ParseRegistration(api_version, resource.name).SelfLink()
|
|
return UriFunc
|
|
|
|
|
|
def AssertRegistrationOperational(api_version, registration):
|
|
messages = registrations.GetMessagesModule(api_version)
|
|
|
|
if registration.state not in [
|
|
messages.Registration.StateValueValuesEnum.ACTIVE,
|
|
messages.Registration.StateValueValuesEnum.SUSPENDED
|
|
]:
|
|
raise exceptions.Error(
|
|
'The registration resource must be in state ACTIVE or SUSPENDED, '
|
|
'not \'{}\'.'.format(registration.state))
|
|
|
|
|
|
def ParseMessageFromYamlFile(path, message_type, error_message):
|
|
"""Parse a Yaml file.
|
|
|
|
Args:
|
|
path: Yaml file path. If path is None returns None.
|
|
message_type: Message type to parse YAML into.
|
|
error_message: Error message to print in case of parsing error.
|
|
|
|
Returns:
|
|
parsed message of type message_type.
|
|
"""
|
|
if path is None:
|
|
return None
|
|
raw_message = yaml.load_path(path)
|
|
try:
|
|
parsed_message = encoding.PyValueToMessage(message_type, raw_message)
|
|
except Exception as e:
|
|
# This error may be slightly different in Py2 and Py3.
|
|
raise exceptions.Error('{}: {}'.format(error_message, e))
|
|
|
|
unknown_fields = []
|
|
for message in encoding.UnrecognizedFieldIter(parsed_message):
|
|
outer_message = ''.join([edge.field + '.' for edge in message[0]])
|
|
unknown_fields += [outer_message + field for field in message[1]]
|
|
unknown_fields.sort()
|
|
if unknown_fields:
|
|
raise exceptions.Error(
|
|
('{}.\nProblematic fields: \'{}\'').format(error_message,
|
|
', '.join(unknown_fields)))
|
|
|
|
return parsed_message
|
|
|
|
|
|
def NormalizeResourceName(domain):
|
|
"""Normalizes domain name in resource name."""
|
|
parts = domain.split('/')
|
|
parts[-1] = NormalizeDomainName(parts[-1])
|
|
return '/'.join(parts)
|
|
|
|
|
|
def NormalizeDomainName(domain):
|
|
"""Normalizes domain name (including punycoding)."""
|
|
if not domain:
|
|
raise exceptions.Error('Empty domain name')
|
|
try:
|
|
normalized = domain.encode('idna').decode() # To Punycode
|
|
normalized = normalized.lower().rstrip('.')
|
|
except UnicodeError as e:
|
|
raise exceptions.Error('Invalid domain name \'{}\': {}.'.format(domain, e))
|
|
return normalized
|
|
|
|
|
|
def PunycodeToUnicode(domain):
|
|
return domain.encode('utf-8').decode('idna')
|
|
|
|
|
|
def ValidateDomainName(domain):
|
|
if not domain:
|
|
return False
|
|
# Replace with some library function for FQDN validation.
|
|
pattern = r'^[a-z0-9-]+(\.[a-z0-9-]+)+\.{0,1}$'
|
|
if not re.match(pattern, domain) or '..' in domain:
|
|
return False
|
|
return True
|
|
|
|
|
|
def ValidateNonEmpty(s):
|
|
return s is not None and bool(s.strip())
|
|
|
|
|
|
def ValidateRegionCode(rc):
|
|
return rc is not None and len(rc) == 2 and rc.isalpha() and rc.isupper()
|
|
|
|
|
|
def ValidateEmail(email):
|
|
if not email:
|
|
return False
|
|
# Replace with some library function for email validation.
|
|
pattern = r'^[^@\s]+@[^@\s]+\.[^@\s]+$'
|
|
return bool(re.match(pattern, email))
|
|
|
|
|
|
def Prompt(prompt_string, message=None):
|
|
"""Prompt for user input.
|
|
|
|
Args:
|
|
prompt_string: Message to print in the line with prompt.
|
|
message: Optional message to print before prompt.
|
|
|
|
Returns:
|
|
User provided value.
|
|
"""
|
|
if message:
|
|
log.status.Print(message)
|
|
return console_io.PromptResponse(prompt_string)
|
|
|
|
|
|
def PromptWithValidator(prompt_string,
|
|
validator,
|
|
error_message,
|
|
message=None,
|
|
default=None):
|
|
"""Prompt for user input and validate output.
|
|
|
|
Args:
|
|
prompt_string: Message to print in the line with prompt.
|
|
validator: Validation function (str) -> bool.
|
|
error_message: Message to print if provided value is not correct.
|
|
message: Optional message to print before prompt.
|
|
default: Optional default value.
|
|
|
|
Returns:
|
|
Valid user provided value or default if not None and user chose it.
|
|
"""
|
|
if message:
|
|
log.status.Print(message)
|
|
while True:
|
|
if default is not None:
|
|
answer = console_io.PromptWithDefault(
|
|
message=prompt_string, default=default)
|
|
if not answer:
|
|
return default
|
|
else:
|
|
answer = console_io.PromptResponse(prompt_string)
|
|
if validator(answer):
|
|
return answer
|
|
else:
|
|
log.status.Print(error_message)
|
|
|
|
|
|
def GetRegistry(api_version):
|
|
registry = resources.REGISTRY.Clone()
|
|
registry.RegisterApiByName('domains', api_version)
|
|
return registry
|
|
|
|
|
|
def ParseRegistration(api_version, registration):
|
|
return GetRegistry(api_version).Parse(
|
|
registration,
|
|
params={
|
|
'projectsId': _PROJECT,
|
|
'locationsId': 'global'
|
|
},
|
|
collection=REGISTRATIONS_COLLECTION)
|
|
|
|
|
|
def ParseOperation(api_version, operation):
|
|
return GetRegistry(api_version).Parse(
|
|
operation,
|
|
params={
|
|
'projectsId': _PROJECT,
|
|
'locationsId': 'global'
|
|
},
|
|
collection=OPERATIONS_COLLECTION)
|
|
|
|
|
|
def DomainNamespace(domain):
|
|
# Return everything after the first encountered dot.
|
|
# This is needed to accommodate two-level domains like .co.uk.
|
|
return domain[domain.find('.'):]
|
|
|
|
|
|
def ParseTransferLockState(api_version, transfer_lock_state):
|
|
messages = registrations.GetMessagesModule(api_version)
|
|
if transfer_lock_state is None:
|
|
return None
|
|
return flags.TransferLockEnumMapper(messages).GetEnumForChoice(
|
|
transfer_lock_state)
|
|
|
|
|
|
def PromptForEnum(enum_mapper, enum_type, current_value):
|
|
"""Prompts the user for the new enum_type value.
|
|
|
|
Args:
|
|
enum_mapper: Instance of the EnumMapper.
|
|
enum_type: A string with enum type name to print.
|
|
current_value: Current value of the enum.
|
|
|
|
Returns:
|
|
The new enum choice or None if the enum shouldn't be updated.
|
|
"""
|
|
options = list(enum_mapper.choices)
|
|
update = console_io.PromptContinue(
|
|
f'Your current {enum_type} is: {current_value}.',
|
|
'Do you want to change it',
|
|
default=False,
|
|
)
|
|
if not update:
|
|
return None
|
|
|
|
current_choice = 0
|
|
for i, enum in enumerate(options):
|
|
if enum == enum_mapper.GetChoiceForEnum(current_value):
|
|
current_choice = i
|
|
index = console_io.PromptChoice(
|
|
options=options,
|
|
default=current_choice,
|
|
message=f'Specify new {enum_type}',
|
|
)
|
|
return options[index]
|
|
|
|
|
|
def PromptForTransferLockState(api_version, transfer_lock):
|
|
"""Prompts the user for new transfer lock state."""
|
|
messages = registrations.GetMessagesModule(api_version)
|
|
enum_mapper = flags.TransferLockEnumMapper(messages)
|
|
result = PromptForEnum(enum_mapper, 'Transfer Lock state', transfer_lock)
|
|
if result is None:
|
|
return None
|
|
return ParseTransferLockState(api_version, result)
|
|
|
|
|
|
def ParseRenewalMethod(api_version, renewal_method):
|
|
messages = registrations.GetMessagesModule(api_version)
|
|
if renewal_method is None:
|
|
return None
|
|
return flags.RenewalMethodEnumMapper(messages).GetEnumForChoice(
|
|
renewal_method
|
|
)
|
|
|
|
|
|
def PromptForRenewalMethod(api_version, preferred_renewal_method):
|
|
"""Prompts the user for new renewal method."""
|
|
messages = registrations.GetMessagesModule(api_version)
|
|
enum_mapper = flags.RenewalMethodEnumMapper(messages)
|
|
result = PromptForEnum(
|
|
enum_mapper, 'preferred Renewal Method', preferred_renewal_method
|
|
)
|
|
if result is None:
|
|
return None
|
|
return ParseRenewalMethod(api_version, result)
|
|
|
|
|
|
def PromptForAuthCode():
|
|
"""Prompts the user to enter the auth code."""
|
|
message = ('Please provide the authorization code from the domain\'s current '
|
|
'registrar to transfer the domain.')
|
|
|
|
log.status.Print(message)
|
|
auth_code = console_io.PromptPassword(
|
|
prompt='Authorization code: ',
|
|
error_message=' Authorization code must not be empty.',
|
|
validation_callable=ValidateNonEmpty)
|
|
return auth_code
|
|
|
|
|
|
def TransformMoneyType(r):
|
|
if r is None:
|
|
return None
|
|
dr = r
|
|
if not isinstance(dr, dict):
|
|
dr = encoding.MessageToDict(r)
|
|
return '{}.{:02d} {}'.format(dr['units'], int(dr.get('nanos', 0) / (10**7)),
|
|
dr.get('currencyCode', ''))
|
|
|
|
|
|
def _ParseMoney(money):
|
|
"""Parses money string as tuple (units, cents, currency)."""
|
|
match = re.match(r'^(\d+|\d+\.\d{2})\s*([A-Z]{3})$', money)
|
|
if match:
|
|
number, s = match.groups()
|
|
else:
|
|
raise ValueError('Value could not be parsed as number + currency code')
|
|
if '.' in number:
|
|
index = number.find('.')
|
|
return int(number[:index]), int(number[index + 1:]), s
|
|
else:
|
|
return int(number), 0, s
|
|
|
|
|
|
def ParseYearlyPrice(api_version, price_string):
|
|
"""Parses money string as type Money."""
|
|
if not price_string:
|
|
return None
|
|
try:
|
|
units, cents, currency = _ParseMoney(price_string)
|
|
except ValueError:
|
|
raise exceptions.Error(
|
|
(
|
|
f"Yearly price '{price_string}' is invalid. Please specify the"
|
|
' amount followed by the currency code.'
|
|
)
|
|
)
|
|
|
|
if currency == '$':
|
|
currency = 'USD'
|
|
|
|
messages = registrations.GetMessagesModule(api_version)
|
|
return messages.Money(
|
|
units=int(units), nanos=cents * 10**7, currencyCode=currency)
|
|
|
|
|
|
def EqualPrice(x, y):
|
|
if x.nanos is None:
|
|
x.nanos = 0
|
|
if y.nanos is None:
|
|
y.nanos = 0
|
|
return x == y
|
|
|
|
|
|
def PromptForYearlyPriceAck(price):
|
|
"""Asks the user to accept the yearly price."""
|
|
ack = console_io.PromptContinue(
|
|
'Yearly price: {}\n'.format(TransformMoneyType(price)),
|
|
prompt_string='Do you agree to pay this yearly price for your domain',
|
|
throw_if_unattended=True,
|
|
cancel_on_no=True,
|
|
default=False)
|
|
if ack:
|
|
return price
|
|
else:
|
|
return None
|
|
|
|
|
|
def ParseRegisterNotices(notices):
|
|
"""Parses registration notices.
|
|
|
|
Args:
|
|
notices: list of notices (lowercase-strings).
|
|
|
|
Returns:
|
|
Pair (public privacy ack: bool, hsts ack: bool).
|
|
"""
|
|
if not notices:
|
|
return False, False
|
|
return 'public-contact-data-acknowledgement' in notices, 'hsts-preloaded' in notices
|
|
|
|
|
|
def PromptForHSTSAck(domain):
|
|
ack = console_io.PromptContinue(
|
|
('{} is a secure namespace. You may purchase {} now but it will '
|
|
'require an SSL certificate for website connection.').format(
|
|
DomainNamespace(domain), domain),
|
|
throw_if_unattended=True,
|
|
cancel_on_no=True,
|
|
default=False)
|
|
return ack
|
|
|
|
|
|
def WaitForOperation(api_version, response, asynchronous):
|
|
"""Handles waiting for the operation and printing information about it.
|
|
|
|
Args:
|
|
api_version: Cloud Domains API version to call.
|
|
response: Response from the API call
|
|
asynchronous: If true, do not wait for the operation
|
|
|
|
Returns:
|
|
The last information about the operation.
|
|
"""
|
|
operation_ref = ParseOperation(api_version, response.name)
|
|
if asynchronous:
|
|
log.status.Print('Started \'{}\''.format(operation_ref.Name()))
|
|
else:
|
|
message = 'Waiting for \'{}\' to complete'
|
|
operations_client = operations.Client.FromApiVersion(api_version)
|
|
response = operations_client.WaitForOperation(
|
|
operation_ref, message.format(operation_ref.Name()))
|
|
return response
|
|
|
|
|
|
def ReadFileContents(path):
|
|
"""Reads the text contents from the given path.
|
|
|
|
Args:
|
|
path: str, The file path to read.
|
|
|
|
Raises:
|
|
Error: If the file cannot be read.
|
|
|
|
Returns:
|
|
str, The text string read from the file.
|
|
"""
|
|
if not path:
|
|
return None
|
|
return files.ReadFileContents(path)
|
|
|
|
|
|
def GetListBatchSize(args):
|
|
"""Returns the batch size for listing resources."""
|
|
if args.page_size:
|
|
return args.page_size
|
|
elif args.limit:
|
|
return min(args.limit, _MAX_LIST_BATCH_SIZE)
|
|
else:
|
|
return None
|