1# -*- coding: utf-8 -*- # 2# Copyright 2014 Google LLC. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15"""Convenience functions for dealing with metadata.""" 16 17from __future__ import absolute_import 18from __future__ import division 19from __future__ import unicode_literals 20 21import copy 22 23from googlecloudsdk.api_lib.compute import constants 24from googlecloudsdk.api_lib.compute import exceptions 25from googlecloudsdk.calliope import arg_parsers 26from googlecloudsdk.calliope import exceptions as calliope_exceptions 27from googlecloudsdk.core import log 28from googlecloudsdk.core.util import files 29 30import six 31 32 33class InvalidSshKeyException(exceptions.Error): 34 """InvalidSshKeyException is for invalid ssh keys in metadata""" 35 36 37def _DictToMetadataMessage(message_classes, metadata_dict): 38 """Converts a metadata dict to a Metadata message.""" 39 message = message_classes.Metadata() 40 if metadata_dict: 41 for key, value in sorted(six.iteritems(metadata_dict)): 42 message.items.append(message_classes.Metadata.ItemsValueListEntry( 43 key=key, 44 value=value)) 45 return message 46 47 48def _MetadataMessageToDict(metadata_message): 49 """Converts a Metadata message to a dict.""" 50 res = {} 51 if metadata_message: 52 for item in metadata_message.items: 53 res[item.key] = item.value 54 return res 55 56 57def _ValidateSshKeys(metadata_dict): 58 """Validates the ssh-key entries in metadata. 59 60 The ssh-key entry in metadata should start with <username> and it cannot 61 be a private key 62 (i.e. <username>:ssh-rsa <key-blob> <username>@<example.com> or 63 <username>:ssh-rsa <key-blob> 64 google-ssh {"userName": <username>@<example.com>, "expireOn": <date>} 65 when the key can expire.) 66 67 Args: 68 metadata_dict: A dictionary object containing metadata. 69 70 Raises: 71 InvalidSshKeyException: If the <username> at the front is missing or private 72 key(s) are detected. 73 """ 74 75 ssh_keys = metadata_dict.get(constants.SSH_KEYS_METADATA_KEY, '') 76 ssh_keys_legacy = metadata_dict.get(constants.SSH_KEYS_LEGACY_METADATA_KEY, 77 '') 78 ssh_keys_combined = '\n'.join((ssh_keys, ssh_keys_legacy)) 79 80 if 'PRIVATE KEY' in ssh_keys_combined: 81 raise InvalidSshKeyException( 82 'Private key(s) are detected. Note that only public keys ' 83 'should be added.') 84 85 keys = ssh_keys_combined.split('\n') 86 keys_missing_username = [] 87 for key in keys: 88 if key and _SshKeyStartsWithKeyType(key): 89 keys_missing_username.append(key) 90 91 if keys_missing_username: 92 message = ('The following key(s) are missing the <username> at the front\n' 93 '{}\n\n' 94 'Format ssh keys following ' 95 'https://cloud.google.com/compute/docs/' 96 'instances/adding-removing-ssh-keys') 97 message_content = message.format('\n'.join(keys_missing_username)) 98 raise InvalidSshKeyException(message_content) 99 100 101def _SshKeyStartsWithKeyType(key): 102 """Checks if the key starts with any key type in constants.SSH_KEY_TYPES. 103 104 Args: 105 key: A ssh key in metadata. 106 107 Returns: 108 True if the key starts with any key type in constants.SSH_KEY_TYPES, returns 109 false otherwise. 110 111 """ 112 key_starts_with_types = [ 113 key.startswith(key_type) for key_type in constants.SSH_KEY_TYPES 114 ] 115 return any(key_starts_with_types) 116 117 118def ConstructMetadataDict(metadata=None, metadata_from_file=None): 119 """Returns the dict of metadata key:value pairs based on the given dicts. 120 121 Args: 122 metadata: A dict mapping metadata keys to metadata values or None. 123 metadata_from_file: A dict mapping metadata keys to file names containing 124 the keys' values or None. 125 126 Raises: 127 ToolException: If metadata and metadata_from_file contain duplicate 128 keys or if there is a problem reading the contents of a file in 129 metadata_from_file. 130 131 Returns: 132 A dict of metadata key:value pairs. 133 """ 134 metadata = metadata or {} 135 metadata_from_file = metadata_from_file or {} 136 137 new_metadata_dict = copy.deepcopy(metadata) 138 for key, file_path in six.iteritems(metadata_from_file): 139 if key in new_metadata_dict: 140 raise calliope_exceptions.ToolException( 141 'Encountered duplicate metadata key [{0}].'.format(key)) 142 new_metadata_dict[key] = files.ReadFileContents(file_path) 143 return new_metadata_dict 144 145 146def ConstructMetadataMessage(message_classes, 147 metadata=None, 148 metadata_from_file=None, 149 existing_metadata=None): 150 """Creates a Metadata message from the given dicts of metadata. 151 152 Args: 153 message_classes: An object containing API message classes. 154 metadata: A dict mapping metadata keys to metadata values or None. 155 metadata_from_file: A dict mapping metadata keys to file names containing 156 the keys' values or None. 157 existing_metadata: If not None, the given metadata values are combined with 158 this Metadata message. 159 160 Raises: 161 ToolException: If metadata and metadata_from_file contain duplicate 162 keys or if there is a problem reading the contents of a file in 163 metadata_from_file. 164 165 Returns: 166 A Metadata protobuf. 167 """ 168 new_metadata_dict = ConstructMetadataDict(metadata, metadata_from_file) 169 170 existing_metadata_dict = _MetadataMessageToDict(existing_metadata) 171 existing_metadata_dict.update(new_metadata_dict) 172 try: 173 _ValidateSshKeys(existing_metadata_dict) 174 except InvalidSshKeyException as e: 175 log.warning(e) 176 177 new_metadata_message = _DictToMetadataMessage(message_classes, 178 existing_metadata_dict) 179 180 if existing_metadata: 181 new_metadata_message.fingerprint = existing_metadata.fingerprint 182 183 return new_metadata_message 184 185 186def MetadataEqual(metadata1, metadata2): 187 """Returns True if both metadata messages have the same key/value pairs.""" 188 return _MetadataMessageToDict(metadata1) == _MetadataMessageToDict(metadata2) 189 190 191def RemoveEntries(message_classes, existing_metadata, 192 keys=None, remove_all=False): 193 """Removes keys from existing_metadata. 194 195 Args: 196 message_classes: An object containing API message classes. 197 existing_metadata: The Metadata message to remove keys from. 198 keys: The keys to remove. This can be None if remove_all is True. 199 remove_all: If True, all entries from existing_metadata are 200 removed. 201 202 Returns: 203 A new Metadata message with entries removed and the same 204 fingerprint as existing_metadata if existing_metadata contains 205 a fingerprint. 206 """ 207 if remove_all: 208 new_metadata_message = message_classes.Metadata() 209 elif keys: 210 existing_metadata_dict = _MetadataMessageToDict(existing_metadata) 211 for key in keys: 212 existing_metadata_dict.pop(key, None) 213 new_metadata_message = _DictToMetadataMessage( 214 message_classes, existing_metadata_dict) 215 216 new_metadata_message.fingerprint = existing_metadata.fingerprint 217 218 return new_metadata_message 219 220 221def AddMetadataArgs(parser, required=False): 222 """Adds --metadata and --metadata-from-file flags.""" 223 metadata_help = """\ 224 Metadata to be made available to the guest operating system 225 running on the instances. Each metadata entry is a key/value 226 pair separated by an equals sign. Each metadata key must be unique 227 and have a max of 128 bytes in length. Each value must have a max of 228 256 KB in length. Multiple arguments can be 229 passed to this flag, e.g., 230 ``--metadata key-1=value-1,key-2=value-2,key-3=value-3''. 231 The combined total size for all metadata entries is 512 KB. 232 233 In images that have Compute Engine tools installed on them, 234 such as the 235 link:https://cloud.google.com/compute/docs/images[official images], 236 the following metadata keys have special meanings: 237 238 *startup-script*::: Specifies a script that will be executed 239 by the instances once they start running. For convenience, 240 ``--metadata-from-file'' can be used to pull the value from a 241 file. 242 243 *startup-script-url*::: Same as ``startup-script'' except that 244 the script contents are pulled from a publicly-accessible 245 location on the web. 246 """ 247 if required: 248 metadata_help += """\n 249 At least one of [--metadata] or [--metadata-from-file] is required. 250 """ 251 parser.add_argument( 252 '--metadata', 253 type=arg_parsers.ArgDict(min_length=1), 254 default={}, 255 help=metadata_help, 256 metavar='KEY=VALUE', 257 action=arg_parsers.StoreOnceAction) 258 259 metadata_from_file_help = """\ 260 Same as ``--metadata'' except that the value for the entry will 261 be read from a local file. This is useful for values that are 262 too large such as ``startup-script'' contents. 263 """ 264 if required: 265 metadata_from_file_help += """\n 266 At least one of [--metadata] or [--metadata-from-file] is required. 267 """ 268 parser.add_argument( 269 '--metadata-from-file', 270 type=arg_parsers.ArgDict(min_length=1), 271 default={}, 272 help=metadata_from_file_help, 273 metavar='KEY=LOCAL_FILE_PATH') 274