File: //lib/google-cloud-sdk/lib/googlecloudsdk/command_lib/iot/util.py
# -*- coding: utf-8 -*- #
# Copyright 2017 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 IoT commands."""
from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals
from apitools.base.py import encoding
from googlecloudsdk.api_lib.cloudiot import devices
from googlecloudsdk.api_lib.cloudiot import registries
from googlecloudsdk.command_lib.iot import flags
from googlecloudsdk.command_lib.util.apis import arg_utils
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import properties
from googlecloudsdk.core import resources
from googlecloudsdk.core.util import files
from googlecloudsdk.core.util import http_encoding
from googlecloudsdk.core.util import times
import six
LOCATIONS_COLLECTION = 'cloudiot.projects.locations'
REGISTRIES_COLLECTION = 'cloudiot.projects.locations.registries'
DEVICES_COLLECTION = 'cloudiot.projects.locations.registries.devices'
DEVICE_CONFIGS_COLLECTION = 'cloudiot.projects.locations.registries.devices.configVersions'
_PROJECT = lambda: properties.VALUES.core.project.Get(required=True)
# Maximum number of public key credentials for a device.
MAX_PUBLIC_KEY_NUM = 3
# Maximum number of metadata pairs for a device.
MAX_METADATA_PAIRS = 500
# Maximum size of a metadata values (32 KB).
MAX_METADATA_VALUE_SIZE = 1024 * 32
# Maximum size of metadata keys and values (256 KB).
MAX_METADATA_SIZE = 1024 * 256
# Mapping of apitools request message fields to their json parameters
# TODO (b/124063772): Remove this mapping fix once apitools base fix is applied
# pylint: disable=line-too-long, for readability.
_CUSTOM_JSON_FIELD_MAPPINGS = {
'gatewayListOptions_gatewayType': 'gatewayListOptions.gatewayType',
'gatewayListOptions_associationsGatewayId': 'gatewayListOptions.associationsGatewayId',
'gatewayListOptions_associationsDeviceId': 'gatewayListOptions.associationsDeviceId',
}
# pylint: enable=line-too-long
class InvalidPublicKeySpecificationError(exceptions.Error):
"""Indicates an issue with supplied public key credential(s)."""
class InvalidKeyFileError(exceptions.Error):
"""Indicates that a provided key file is malformed."""
class BadCredentialIndexError(exceptions.Error):
"""Indicates that a user supplied a bad index for resource's credentials."""
def __init__(self, name, credentials, index, resource='device'):
super(BadCredentialIndexError, self).__init__(
'Invalid credential index [{index}]; {resource} [{name}] has '
'{num_credentials} credentials. (Indexes are zero-based.))'.format(
index=index, name=name, num_credentials=len(credentials),
resource=resource))
class InvalidAuthMethodError(exceptions.Error):
"""Indicates that auth method was provided for non-gateway device."""
class BadDeviceError(exceptions.Error):
"""Indicates that a given device is malformed."""
class InvalidMetadataError(exceptions.Error):
"""Indicates an error with the supplied device metadata."""
def RegistriesUriFunc(resource):
return ParseRegistry(resource.name).SelfLink()
def DevicesUriFunc(resource):
return ParseDevice(resource.name).SelfLink()
def ParseEnableMqttConfig(enable_mqtt_config, client=None):
if enable_mqtt_config is None:
return None
client = client or registries.RegistriesClient()
mqtt_config_enum = client.mqtt_config_enum
if enable_mqtt_config:
return mqtt_config_enum.MQTT_ENABLED
else:
return mqtt_config_enum.MQTT_DISABLED
def ParseEnableHttpConfig(enable_http_config, client=None):
if enable_http_config is None:
return None
client = client or registries.RegistriesClient()
http_config_enum = client.http_config_enum
if enable_http_config:
return http_config_enum.HTTP_ENABLED
else:
return http_config_enum.HTTP_DISABLED
def ParseLogLevel(log_level, enum_class):
if log_level is None:
return None
return arg_utils.ChoiceToEnum(log_level, enum_class)
def AddBlockedToRequest(ref, args, req):
"""Python hook for yaml commands to process the blocked flag."""
del ref
req.device.blocked = args.blocked
return req
_ALLOWED_KEYS = ['type', 'path', 'expiration-time']
_REQUIRED_KEYS = ['type', 'path']
def _ValidatePublicKeyDict(public_key):
unrecognized_keys = (set(public_key.keys()) - set(_ALLOWED_KEYS))
if unrecognized_keys:
raise TypeError(
'Unrecognized keys [{}] for public key specification.'.format(
', '.join(unrecognized_keys)))
for key in _REQUIRED_KEYS:
if key not in public_key:
raise InvalidPublicKeySpecificationError(
'--public-key argument missing value for `{}`.'.format(key))
def _ConvertStringToFormatEnum(type_, messages):
"""Convert string values to Enum object type."""
if (type_ == flags.KeyTypes.RS256.choice_name or
type_ == flags.KeyTypes.RSA_X509_PEM.choice_name):
return messages.PublicKeyCredential.FormatValueValuesEnum.RSA_X509_PEM
elif type_ == flags.KeyTypes.RSA_PEM.choice_name:
return messages.PublicKeyCredential.FormatValueValuesEnum.RSA_PEM
elif type_ == flags.KeyTypes.ES256_X509_PEM.choice_name:
return messages.PublicKeyCredential.FormatValueValuesEnum.ES256_X509_PEM
elif (type_ == flags.KeyTypes.ES256.choice_name or
type_ == flags.KeyTypes.ES256_PEM.choice_name):
return messages.PublicKeyCredential.FormatValueValuesEnum.ES256_PEM
else:
# Should have been caught by argument parsing
raise ValueError('Invalid key type [{}]'.format(type_))
def _ReadKeyFileFromPath(path):
if not path:
raise ValueError('path is required')
try:
return files.ReadFileContents(path)
except files.Error as err:
raise InvalidKeyFileError('Could not read key file [{}]:\n\n{}'.format(
path, err))
def ParseCredential(path, type_str, expiration_time=None, messages=None):
messages = messages or devices.GetMessagesModule()
type_ = _ConvertStringToFormatEnum(type_str, messages)
contents = _ReadKeyFileFromPath(path)
if expiration_time:
expiration_time = times.FormatDateTime(expiration_time)
return messages.DeviceCredential(
expirationTime=expiration_time,
publicKey=messages.PublicKeyCredential(
format=type_,
key=contents
)
)
def ParseCredentials(public_keys, messages=None):
"""Parse a DeviceCredential from user-supplied arguments.
Returns a list of DeviceCredential with the appropriate type, expiration time
(if provided), and contents of the file for each public key.
Args:
public_keys: list of dict (maximum 3) representing public key credentials.
The dict should have the following keys:
- 'type': Required. The key type. One of [es256, rs256]
- 'path': Required. Path to a valid key file on disk.
- 'expiration-time': Optional. datetime, the expiration time for the
credential.
messages: module or None, the apitools messages module for Cloud IoT (uses a
default module if not provided).
Returns:
List of DeviceCredential (possibly empty).
Raises:
TypeError: if an invalid public_key specification is given in public_keys
ValueError: if an invalid public key type is given (that is, neither es256
nor rs256)
InvalidPublicKeySpecificationError: if a public_key specification is missing
a required part, or too many public keys are provided.
InvalidKeyFileError: if a valid combination of flags is given, but the
specified key file is not valid or not readable.
"""
messages = messages or devices.GetMessagesModule()
if not public_keys:
return []
if len(public_keys) > MAX_PUBLIC_KEY_NUM:
raise InvalidPublicKeySpecificationError(
('Too many public keys specified: '
'[{}] given, but maximum [{}] allowed.').format(
len(public_keys), MAX_PUBLIC_KEY_NUM))
credentials = []
for key in public_keys:
_ValidatePublicKeyDict(key)
credentials.append(
ParseCredential(key.get('path'), key.get('type'),
key.get('expiration-time'), messages=messages))
return credentials
def AddCredentialsToRequest(ref, args, req):
"""Python hook for yaml commands to process the credential flag."""
del ref
req.device.credentials = ParseCredentials(args.public_keys)
return req
def ParseRegistryCredential(path, messages=None):
messages = messages or devices.GetMessagesModule()
contents = _ReadKeyFileFromPath(path)
format_enum = messages.PublicKeyCertificate.FormatValueValuesEnum
return messages.RegistryCredential(
publicKeyCertificate=messages.PublicKeyCertificate(
certificate=contents,
format=format_enum.X509_CERTIFICATE_PEM))
def GetRegistry():
registry = resources.REGISTRY.Clone()
registry.RegisterApiByName('cloudiot', 'v1')
return registry
def ParseLocation(region):
return GetRegistry().Parse(
region,
params={'projectsId': _PROJECT}, collection=LOCATIONS_COLLECTION)
def ParseRegistry(registry, region=None):
return GetRegistry().Parse(
registry,
params={'projectsId': _PROJECT, 'locationsId': region},
collection=REGISTRIES_COLLECTION)
def ParseDevice(device, registry=None, region=None):
return GetRegistry().Parse(
device,
params={
'projectsId': _PROJECT,
'locationsId': region,
'registriesId': registry
},
collection=DEVICES_COLLECTION)
def GetDeviceConfigRef(device_ref):
return GetRegistry().Parse(
device_ref.devicesId,
params={
'projectsId': device_ref.projectsId,
'locationsId': device_ref.locationsId,
'registriesId': device_ref.registriesId
},
collection=DEVICE_CONFIGS_COLLECTION)
def ParsePubsubTopic(topic):
if topic is None:
return None
return GetRegistry().Parse(
topic,
params={'projectsId': _PROJECT}, collection='pubsub.projects.topics')
def ReadConfigData(args):
"""Read configuration data from the parsed arguments.
See command_lib.iot.flags for the flag definitions.
Args:
args: a parsed argparse Namespace object containing config_data and
config_file.
Returns:
str, the binary configuration data
Raises:
ValueError: unless exactly one of --config-data, --config-file given
"""
if args.IsSpecified('config_data') and args.IsSpecified('config_file'):
raise ValueError('Both --config-data and --config-file given.')
if args.IsSpecified('config_data'):
return http_encoding.Encode(args.config_data)
elif args.IsSpecified('config_file'):
return files.ReadBinaryFileContents(args.config_file)
else:
raise ValueError('Neither --config-data nor --config-file given.')
def _CheckMetadataValueSize(value):
if not value:
raise InvalidMetadataError('Metadata value cannot be empty.')
if len(value) > MAX_METADATA_VALUE_SIZE:
raise InvalidMetadataError('Maximum size of metadata values are 32KB.')
def _ValidateAndCreateAdditionalProperty(messages, key, value):
_CheckMetadataValueSize(value)
return messages.Device.MetadataValue.AdditionalProperty(key=key, value=value)
def _ReadMetadataValueFromFile(path):
if not path:
raise ValueError('path is required')
try:
return files.ReadFileContents(path)
except files.Error as err:
raise InvalidMetadataError('Could not read value file [{}]:\n\n{}'.format(
path, err))
def ParseMetadata(metadata, metadata_from_file, messages=None):
"""Parse and create metadata object from the parsed arguments.
Args:
metadata: dict, key-value pairs passed in from the --metadata flag.
metadata_from_file: dict, key-path pairs passed in from the
--metadata-from-file flag.
messages: module or None, the apitools messages module for Cloud IoT (uses a
default module if not provided).
Returns:
MetadataValue or None, the populated metadata message for a Device.
Raises:
InvalidMetadataError: if there was any issue parsing the metadata.
"""
if not metadata and not metadata_from_file:
return None
metadata = metadata or dict()
metadata_from_file = metadata_from_file or dict()
if len(metadata) + len(metadata_from_file) > MAX_METADATA_PAIRS:
raise InvalidMetadataError('Maximum number of metadata key-value pairs '
'is {}.'.format(MAX_METADATA_PAIRS))
if set(metadata.keys()) & set(metadata_from_file.keys()):
raise InvalidMetadataError('Cannot specify the same key in both '
'--metadata and --metadata-from-file.')
total_size = 0
messages = messages or devices.GetMessagesModule()
additional_properties = []
for key, value in six.iteritems(metadata):
total_size += len(key) + len(value)
additional_properties.append(
_ValidateAndCreateAdditionalProperty(messages, key, value))
for key, path in metadata_from_file.items():
value = _ReadMetadataValueFromFile(path)
total_size += len(key) + len(value)
additional_properties.append(
_ValidateAndCreateAdditionalProperty(messages, key, value))
if total_size > MAX_METADATA_SIZE:
raise InvalidMetadataError('Maximum size of metadata key-value pairs '
'is 256KB.')
return messages.Device.MetadataValue(
additionalProperties=additional_properties)
def AddMetadataToRequest(ref, args, req):
"""Python hook for yaml commands to process the metadata flags."""
del ref
metadata = ParseMetadata(args.metadata, args.metadata_from_file)
req.device.metadata = metadata
return req
def ParseEventNotificationConfig(event_notification_configs, messages=None):
"""Creates a list of EventNotificationConfigs from args."""
messages = messages or registries.GetMessagesModule()
if event_notification_configs:
configs = []
for config in event_notification_configs:
topic_ref = ParsePubsubTopic(config['topic'])
configs.append(messages.EventNotificationConfig(
pubsubTopicName=topic_ref.RelativeName(),
subfolderMatches=config.get('subfolder', None)))
return configs
return None
def AddEventNotificationConfigsToRequest(ref, args, req):
"""Python hook for yaml commands to process event config flags."""
del ref
configs = ParseEventNotificationConfig(args.event_notification_configs)
req.deviceRegistry.eventNotificationConfigs = configs or []
return req
def AddCreateGatewayArgsToRequest(ref, args, req):
"""Python hook for yaml create command to process gateway flags."""
del ref
gateway = args.device_type
auth_method = args.auth_method
# Don't set gateway config if no flags provided
if not (gateway or auth_method):
return req
messages = devices.GetMessagesModule()
req.device.gatewayConfig = messages.GatewayConfig()
if auth_method:
if not gateway or gateway == 'non-gateway':
raise InvalidAuthMethodError(
'auth_method can only be set on gateway devices.')
auth_enum = flags.GATEWAY_AUTH_METHOD_ENUM_MAPPER.GetEnumForChoice(
auth_method)
req.device.gatewayConfig.gatewayAuthMethod = auth_enum
if gateway:
gateway_enum = flags.CREATE_GATEWAY_ENUM_MAPPER.GetEnumForChoice(gateway)
req.device.gatewayConfig.gatewayType = gateway_enum
return req
def AddBindArgsToRequest(ref, args, req):
"""Python hook for yaml gateways bind command to process resource_args."""
del ref
messages = devices.GetMessagesModule()
gateway_ref = args.CONCEPTS.gateway.Parse()
device_ref = args.CONCEPTS.device.Parse()
registry_ref = gateway_ref.Parent()
bind_request = messages.BindDeviceToGatewayRequest(
deviceId=device_ref.Name(), gatewayId=gateway_ref.Name())
req.bindDeviceToGatewayRequest = bind_request
req.parent = registry_ref.RelativeName()
return req
def AddUnBindArgsToRequest(ref, args, req):
"""Python hook for yaml gateways unbind command to process resource_args."""
del ref
messages = devices.GetMessagesModule()
gateway_ref = args.CONCEPTS.gateway.Parse()
device_ref = args.CONCEPTS.device.Parse()
registry_ref = gateway_ref.Parent()
unbind_request = messages.UnbindDeviceFromGatewayRequest(
deviceId=device_ref.Name(), gatewayId=gateway_ref.Name())
req.unbindDeviceFromGatewayRequest = unbind_request
req.parent = registry_ref.RelativeName()
return req
# TODO(b/124063772): Workaround for apitools issues with nested GET request
# message fields.
def RegistriesDevicesListRequestHook(ref, args, req):
"""Add Api field query string mappings to list requests."""
del ref
del args
msg = devices.GetMessagesModule()
updated_requests_type = (
msg.CloudiotProjectsLocationsRegistriesDevicesListRequest)
for req_field, mapped_param in _CUSTOM_JSON_FIELD_MAPPINGS.items():
encoding.AddCustomJsonFieldMapping(updated_requests_type,
req_field,
mapped_param)
return req
# Argument Processors
def GetCommandFromFileProcessor(path):
"""Builds a binary data for a SendCommandToDeviceRequest message from a path.
Args:
path: the path arg given to the command.
Raises:
ValueError: if the path does not exist or can not be read.
Returns:
binary data to be set on a message.
"""
try:
return files.ReadBinaryFileContents(path)
except Exception as e:
raise ValueError('Command File [{}] can not be opened: {}'.format(path, e))