1# -*- coding: utf-8 -*- #
2# Copyright 2015 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
16"""Utilities for configuring platform specific installation."""
17
18from __future__ import absolute_import
19from __future__ import division
20from __future__ import print_function
21from __future__ import unicode_literals
22
23import os
24import re
25import shutil
26import sys
27
28from googlecloudsdk.core import properties
29from googlecloudsdk.core.console import console_io
30from googlecloudsdk.core.util import encoding
31from googlecloudsdk.core.util import files
32from googlecloudsdk.core.util import platforms
33
34import six
35
36_DEFAULT_SHELL = 'bash'
37# Shells supported by this module.
38_SUPPORTED_SHELLS = [_DEFAULT_SHELL, 'zsh', 'ksh', 'fish']
39# Map of *.{shell}.inc compatible shells. e.g. ksh can source *.bash.inc.
40_COMPATIBLE_INC_SHELL = {'ksh': 'bash'}
41
42
43def _TraceAction(action):
44  """Prints action to standard error."""
45  print(action, file=sys.stderr)
46
47
48# pylint:disable=unused-argument
49def _UpdatePathForWindows(bin_path):
50  """Update the Windows system path to include bin_path.
51
52  Args:
53    bin_path: str, The absolute path to the directory that will contain
54        Cloud SDK binaries.
55  """
56
57  # pylint:disable=g-import-not-at-top, we want to only attempt these imports
58  # on windows.
59  try:
60    import win32con
61    import win32gui
62    from six.moves import winreg
63  except ImportError:
64    _TraceAction("""\
65The installer is unable to automatically update your system PATH. Please add
66  {path}
67to your system PATH to enable easy use of the Cloud SDK Command Line Tools.
68""".format(path=bin_path))
69    return
70
71  def GetEnv(name):
72    root = winreg.HKEY_CURRENT_USER
73    subkey = 'Environment'
74    key = winreg.OpenKey(root, subkey, 0, winreg.KEY_READ)
75    try:
76      value, _ = winreg.QueryValueEx(key, name)
77    # pylint:disable=undefined-variable, This variable is defined in windows.
78    except WindowsError:
79      return ''
80    return value
81
82  def SetEnv(name, value):
83    key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0,
84                         winreg.KEY_ALL_ACCESS)
85    winreg.SetValueEx(key, name, 0, winreg.REG_EXPAND_SZ, value)
86    winreg.CloseKey(key)
87    win32gui.SendMessage(
88        win32con.HWND_BROADCAST, win32con.WM_SETTINGCHANGE, 0, 'Environment')
89    return value
90
91  def Remove(paths, value):
92    while value in paths:
93      paths.remove(value)
94
95  def PrependEnv(name, values):
96    paths = GetEnv(name).split(';')
97    for value in values:
98      if value in paths:
99        Remove(paths, value)
100      paths.insert(0, value)
101    SetEnv(name, ';'.join(paths))
102
103  PrependEnv('Path', [bin_path])
104
105  _TraceAction("""\
106The following directory has been added to your PATH.
107  {bin_path}
108
109Create a new command shell for the changes to take effect.
110""".format(bin_path=bin_path))
111
112
113_INJECT_SH = """
114{comment}
115if [ -f '{rc_path}' ]; then . '{rc_path}'; fi
116"""
117
118
119_INJECT_FISH = """
120{comment}
121if [ -f '{rc_path}' ]; . '{rc_path}'; end
122"""
123
124
125def _GetRcContents(comment, rc_path, rc_contents, shell, pattern=None):
126  """Generates the RC file contents with new comment and `source rc_path` lines.
127
128  Args:
129    comment: The shell comment string that precedes the source line.
130    rc_path: The path of the rc file to source.
131    rc_contents: The current contents.
132    shell: The shell base name, specific to this module.
133    pattern: A regex pattern that matches comment, None for exact match on
134      comment.
135
136  Returns:
137    The comment and `source rc_path` lines to be inserted into a shell rc file.
138  """
139  if not pattern:
140    pattern = re.escape(comment)
141  # This pattern handles all three variants that we have injected in user RC
142  # files. All have the same sentinel comment line followed by:
143  #   1. a single 'source ...' line
144  #   2. a 3 line if-fi (a bug because this pattern was previously incorrect)
145  #   3. finally a single if-fi line.
146  # If you touch this code ONLY INJECT ONE LINE AFTER THE SENTINEL COMMENT LINE.
147  #
148  # At some point we can drop the alternate patterns and only search for the
149  # sentinel comment line and assume the next line is ours too (that was the
150  # original intent before the 3-line form was added).
151  subre = re.compile('\n' + pattern + '\n('
152                     "source '.*'"
153                     '|'
154                     'if .*; then\n  source .*\nfi'
155                     '|'
156                     'if .*; then (\\.|source) .*; fi'
157                     '|'
158                     'if .*; (\\.|source) .*; end'
159                     '|'
160                     'if .*; if type source .*; end'
161                     ')\n', re.MULTILINE)
162  # script checks that the rc_path currently exists before sourcing the file
163  inject = _INJECT_FISH if shell == 'fish' else _INJECT_SH
164  line = inject.format(comment=comment, rc_path=rc_path)
165  filtered_contents = subre.sub('', rc_contents)
166  rc_contents = '{filtered_contents}{line}'.format(
167      filtered_contents=filtered_contents, line=line)
168  return rc_contents
169
170
171class _RcUpdater(object):
172  """Updates the RC file completion and PATH code injection."""
173
174  def __init__(self, completion_update, path_update, shell, rc_path, sdk_root):
175    self.completion_update = completion_update
176    self.path_update = path_update
177    self.rc_path = rc_path
178    compatible_shell = _COMPATIBLE_INC_SHELL.get(shell, shell)
179    self.completion = os.path.join(
180        sdk_root, 'completion.{shell}.inc'.format(shell=compatible_shell))
181    self.path = os.path.join(
182        sdk_root, 'path.{shell}.inc'.format(shell=compatible_shell))
183    self.shell = shell
184
185  def _CompletionExists(self):
186    return os.path.exists(self.completion)
187
188  def Update(self):
189    """Creates or updates the RC file."""
190    if self.rc_path:
191
192      # Check whether RC file is a file and store its contents.
193      if os.path.isfile(self.rc_path):
194        rc_contents = files.ReadFileContents(self.rc_path)
195        original_rc_contents = rc_contents
196      elif os.path.exists(self.rc_path):
197        _TraceAction(
198            '[{rc_path}] exists and is not a file, so it cannot be updated.'
199            .format(rc_path=self.rc_path))
200        return
201      else:
202        rc_contents = ''
203        original_rc_contents = ''
204
205      if self.path_update:
206        rc_contents = _GetRcContents(
207            '# The next line updates PATH for the Google Cloud SDK.',
208            self.path, rc_contents, self.shell)
209
210      # gcloud doesn't (yet?) support completion for Fish, so check whether the
211      # completion file exists
212      if self.completion_update and self._CompletionExists():
213        rc_contents = _GetRcContents(
214            '# The next line enables shell command completion for gcloud.',
215            self.completion, rc_contents, self.shell,
216            pattern=('# The next line enables [a-z][a-z]*'
217                     ' command completion for gcloud.'))
218
219      if rc_contents == original_rc_contents:
220        _TraceAction('No changes necessary for [{rc}].'.format(rc=self.rc_path))
221        return
222
223      if os.path.exists(self.rc_path):
224        rc_backup = self.rc_path + '.backup'
225        _TraceAction('Backing up [{rc}] to [{backup}].'.format(
226            rc=self.rc_path, backup=rc_backup))
227        shutil.copyfile(self.rc_path, rc_backup)
228
229      # Update rc file, creating it if it does not exist.
230      rc_dir = os.path.dirname(self.rc_path)
231      try:
232        files.MakeDir(rc_dir)
233      except (files.Error, IOError, OSError):
234        _TraceAction(
235            'Could not create directories for [{rc_path}], so it '
236            'cannot be updated.'.format(rc_path=self.rc_path))
237        return
238
239      try:
240        files.WriteFileContents(self.rc_path, rc_contents)
241      except (files.Error, IOError, OSError):
242        _TraceAction(
243            'Could not update [{rc_path}]. Ensure you have write access to '
244            'this location.'.format(rc_path=self.rc_path))
245        return
246
247      _TraceAction('[{rc_path}] has been updated.'.format(rc_path=self.rc_path))
248      _TraceAction(console_io.FormatRequiredUserAction(
249          'Start a new shell for the changes to take effect.'))
250
251    screen_reader = properties.VALUES.accessibility.screen_reader.GetBool()
252    prefix = '' if screen_reader else '==> '
253
254    if not self.completion_update and self._CompletionExists():
255      _TraceAction(prefix +
256                   'Source [{rc}] in your profile to enable shell command '
257                   'completion for gcloud.'.format(rc=self.completion))
258
259    if not self.path_update:
260      _TraceAction(prefix +
261                   'Source [{rc}] in your profile to add the Google Cloud SDK '
262                   'command line tools to your $PATH.'.format(rc=self.path))
263
264
265def _GetPreferredShell(path, default=_DEFAULT_SHELL):
266  """Returns the preferred shell name based on the base file name in path.
267
268  Args:
269    path: str, The file path to check.
270    default: str, The default value to return if a preferred name cannot be
271      determined.
272
273  Returns:
274    The preferred user shell name or default if none can be determined.
275  """
276  if not path:
277    return default
278  name = os.path.basename(path)
279  for shell in _SUPPORTED_SHELLS:
280    if shell in six.text_type(name):
281      return shell
282  return default
283
284
285def _GetShellRcFileName(shell, host_os):
286  """Returns the RC file name for shell and host_os.
287
288  Args:
289    shell: str, The shell base name.
290    host_os: str, The host os identification string.
291
292  Returns:
293    The shell RC file name, '.bashrc' by default.
294  """
295  if shell == 'ksh':
296    return encoding.GetEncodedValue(os.environ, 'ENV', None) or '.kshrc'
297  elif shell == 'fish':
298    return os.path.join('.config', 'fish', 'config.fish')
299  elif shell != 'bash':
300    return '.{shell}rc'.format(shell=shell)
301  elif host_os == platforms.OperatingSystem.LINUX:
302    return '.bashrc'
303  elif host_os == platforms.OperatingSystem.MACOSX:
304    return '.bash_profile'
305  elif host_os == platforms.OperatingSystem.MSYS:
306    return '.profile'
307  return '.bashrc'
308
309
310def _GetAndUpdateRcPath(completion_update, path_update, rc_path, host_os):
311  """Returns an rc path based on the default rc path or user input.
312
313  Gets default rc path based on environment. If prompts are enabled,
314  allows user to update to preferred file path. Otherwise, prints a warning
315  that the default rc path will be updated.
316
317  Args:
318    completion_update: bool, Whether or not to do command completion.
319    path_update: bool, Whether or not to update PATH.
320    rc_path: str, the rc path given by the user, from --rc-path arg.
321    host_os: str, The host os identification string.
322
323  Returns:
324    str, A path to the rc file to update.
325  """
326  # If we aren't updating the RC file for either completions or PATH, there's
327  # no point.
328  if not (completion_update or path_update):
329    return None
330  if rc_path:
331    return rc_path
332  # A first guess at user preferred shell.
333  preferred_shell = _GetPreferredShell(
334      encoding.GetEncodedValue(os.environ, 'SHELL', '/bin/sh'))
335  default_rc_path = os.path.join(
336      files.GetHomeDir(), _GetShellRcFileName(preferred_shell, host_os))
337  # If in quiet mode, we'll use default path.
338  if not console_io.CanPrompt():
339    _TraceAction('You specified that you wanted to update your rc file. The '
340                 'default file will be updated: [{rc_path}]'
341                 .format(rc_path=default_rc_path))
342    return default_rc_path
343  rc_path_update = console_io.PromptResponse((
344      'The Google Cloud SDK installer will now prompt you to update an rc '
345      'file to bring the Google Cloud CLIs into your environment.\n\n'
346      'Enter a path to an rc file to update, or leave blank to use '
347      '[{rc_path}]:  ').format(rc_path=default_rc_path))
348  return (files.ExpandHomeDir(rc_path_update) if rc_path_update
349          else default_rc_path)
350
351
352def _GetRcUpdater(completion_update, path_update, rc_path, sdk_root, host_os):
353  """Returns an _RcUpdater object for the preferred user shell.
354
355  Args:
356    completion_update: bool, Whether or not to do command completion.
357    path_update: bool, Whether or not to update PATH.
358    rc_path: str, The path to the rc file to update. If None, ask.
359    sdk_root: str, The path to the Cloud SDK root.
360    host_os: str, The host os identification string.
361
362  Returns:
363    An _RcUpdater() object for the preferred user shell.
364  """
365  rc_path = _GetAndUpdateRcPath(completion_update, path_update, rc_path,
366                                host_os)
367  # Check the rc_path for a better hint at the user preferred shell.
368  preferred_shell = _GetPreferredShell(
369      rc_path,
370      default=_GetPreferredShell(
371          encoding.GetEncodedValue(os.environ, 'SHELL', '/bin/sh')))
372  return _RcUpdater(
373      completion_update, path_update, preferred_shell, rc_path, sdk_root)
374
375
376_PATH_PROMPT = 'update your $PATH'
377_COMPLETION_PROMPT = 'enable shell command completion'
378
379
380def _PromptToUpdate(path_update, completion_update):
381  """Prompt the user to update path or command completion if unspecified.
382
383  Args:
384    path_update: bool, Value of the --update-path arg.
385    completion_update: bool, Value of the --command-completion arg.
386
387  Returns:
388    (path_update, completion_update) (bool, bool) Whether to update path and
389        enable completion, respectively, after prompting the user.
390  """
391  # If both were specified, no need to prompt.
392  if path_update is not None and completion_update is not None:
393    return path_update, completion_update
394
395  # Ask the user only one question to see if they want to do any unspecified
396  # updates.
397  actions = []
398  if path_update is None:
399    actions.append(_PATH_PROMPT)
400  if completion_update is None:
401    actions.append(_COMPLETION_PROMPT)
402  prompt = '\nModify profile to {}?'.format(' and '.join(actions))
403  response = console_io.PromptContinue(prompt)
404
405  # Update unspecified values to equal user response.
406  path_update = response if path_update is None else path_update
407  completion_update = (response if completion_update is None
408                       else completion_update)
409
410  return path_update, completion_update
411
412
413def UpdateRC(completion_update, path_update, rc_path, bin_path, sdk_root):
414  """Update the system path to include bin_path.
415
416  Args:
417    completion_update: bool, Whether or not to do command completion. From
418      --command-completion arg during install. If None, ask.
419    path_update: bool, Whether or not to update PATH. From --path-update arg
420      during install. If None, ask.
421    rc_path: str, The path to the rc file to update. From --rc-path during
422      install. If None, ask.
423    bin_path: str, The absolute path to the directory that will contain
424      Cloud SDK binaries.
425    sdk_root: str, The path to the Cloud SDK root.
426  """
427  host_os = platforms.OperatingSystem.Current()
428  if host_os == platforms.OperatingSystem.WINDOWS:
429    if path_update is None:
430      path_update = console_io.PromptContinue(
431          prompt_string='Update %PATH% to include Cloud SDK binaries?')
432    if path_update:
433      _UpdatePathForWindows(bin_path)
434    return
435
436  if console_io.CanPrompt():
437    path_update, completion_update = _PromptToUpdate(path_update,
438                                                     completion_update)
439  elif rc_path and (path_update is None and completion_update is None):
440    # In quiet mode, if the user gave a path to the RC and didn't specify what
441    # updates are desired, assume both.
442    path_update = True
443    completion_update = True
444    _TraceAction('Profile will be modified to {} and {}.'
445                 .format(_PATH_PROMPT, _COMPLETION_PROMPT))
446
447  _GetRcUpdater(
448      completion_update, path_update, rc_path, sdk_root, host_os).Update()
449