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