blob: 14619c4be2c8872f4a9779a788cd801c641d689c [file] [log] [blame]
herbertxue34776bb2018-07-03 21:57:48 +08001#!/usr/bin/env python
2#
3# Copyright 2018 - The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16"""Gcloud setup runner."""
17
18from __future__ import print_function
19import logging
20import os
21import re
22import subprocess
23
chojoyce7479ce32019-10-29 12:05:44 +080024import six
25
herbertxue34776bb2018-07-03 21:57:48 +080026from acloud import errors
27from acloud.internal.lib import utils
28from acloud.public import config
29from acloud.setup import base_task_runner
30from acloud.setup import google_sdk
31
herbertxue1512f8a2019-06-27 13:56:23 +080032
33logger = logging.getLogger(__name__)
34
herbertxue34776bb2018-07-03 21:57:48 +080035# APIs that need to be enabled for GCP project.
36_ANDROID_BUILD_SERVICE = "androidbuildinternal.googleapis.com"
herbertxue776cf922019-05-20 17:51:13 +080037_ANDROID_BUILD_MSG = (
38 "This service (%s) help to download images from Android Build. If it isn't "
39 "enabled, acloud only supports local images to create AVD."
40 % _ANDROID_BUILD_SERVICE)
herbertxue34776bb2018-07-03 21:57:48 +080041_COMPUTE_ENGINE_SERVICE = "compute.googleapis.com"
herbertxue776cf922019-05-20 17:51:13 +080042_COMPUTE_ENGINE_MSG = (
43 "This service (%s) help to create instance in google cloud platform. If it "
44 "isn't enabled, acloud can't work anymore." % _COMPUTE_ENGINE_SERVICE)
herbertxue776cf922019-05-20 17:51:13 +080045_OPEN_SERVICE_FAILED_MSG = (
46 "\n[Open Service Failed]\n"
47 "Service name: %(service_name)s\n"
48 "%(service_msg)s\n")
49
herbertxue34776bb2018-07-03 21:57:48 +080050_BUILD_SERVICE_ACCOUNT = "android-build-prod@system.gserviceaccount.com"
herbertxued69dc512019-05-30 15:37:15 +080051_BILLING_ENABLE_MSG = "billingEnabled: true"
herbertxue34776bb2018-07-03 21:57:48 +080052_DEFAULT_SSH_FOLDER = os.path.expanduser("~/.ssh")
53_DEFAULT_SSH_KEY = "acloud_rsa"
54_DEFAULT_SSH_PRIVATE_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
55 _DEFAULT_SSH_KEY)
56_DEFAULT_SSH_PUBLIC_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
57 _DEFAULT_SSH_KEY + ".pub")
herbertxued69dc512019-05-30 15:37:15 +080058_GCLOUD_COMPONENT_ALPHA = "alpha"
herbertxuea6a953a2019-06-25 18:29:00 +080059# Regular expression to get project/zone information.
herbertxue34776bb2018-07-03 21:57:48 +080060_PROJECT_RE = re.compile(r"^project = (?P<project>.+)")
61_ZONE_RE = re.compile(r"^zone = (?P<zone>.+)")
62
herbertxue34776bb2018-07-03 21:57:48 +080063
64def UpdateConfigFile(config_path, item, value):
65 """Update config data.
66
67 Case A: config file contain this item.
68 In config, "project = A_project". New value is B_project
69 Set config "project = B_project".
70 Case B: config file didn't contain this item.
71 New value is B_project.
72 Setup config as "project = B_project".
73
74 Args:
75 config_path: String, acloud config path.
76 item: String, item name in config file. EX: project, zone
77 value: String, value of item in config file.
78
79 TODO(111574698): Refactor this to minimize writes to the config file.
80 TODO(111574698): Use proto method to update config.
81 """
82 write_lines = []
83 find_item = False
84 write_line = item + ": \"" + value + "\"\n"
85 if os.path.isfile(config_path):
86 with open(config_path, "r") as cfg_file:
87 for read_line in cfg_file.readlines():
88 if read_line.startswith(item + ":"):
89 find_item = True
90 write_lines.append(write_line)
91 else:
92 write_lines.append(read_line)
93 if not find_item:
94 write_lines.append(write_line)
95 with open(config_path, "w") as cfg_file:
96 cfg_file.writelines(write_lines)
97
98
99def SetupSSHKeys(config_path, private_key_path, public_key_path):
100 """Setup the pair of the ssh key for acloud.config.
101
102 User can use the default path: "~/.ssh/acloud_rsa".
103
104 Args:
105 config_path: String, acloud config path.
106 private_key_path: Path to the private key file.
107 e.g. ~/.ssh/acloud_rsa
108 public_key_path: Path to the public key file.
109 e.g. ~/.ssh/acloud_rsa.pub
110 """
111 private_key_path = os.path.expanduser(private_key_path)
112 if (private_key_path == "" or public_key_path == ""
113 or private_key_path == _DEFAULT_SSH_PRIVATE_KEY):
114 utils.CreateSshKeyPairIfNotExist(_DEFAULT_SSH_PRIVATE_KEY,
115 _DEFAULT_SSH_PUBLIC_KEY)
116 UpdateConfigFile(config_path, "ssh_private_key_path",
117 _DEFAULT_SSH_PRIVATE_KEY)
118 UpdateConfigFile(config_path, "ssh_public_key_path",
119 _DEFAULT_SSH_PUBLIC_KEY)
120
121
122def _InputIsEmpty(input_string):
123 """Check input string is empty.
124
125 Tool requests user to input client ID & client secret.
126 This basic check can detect user input is empty.
127
128 Args:
129 input_string: String, user input string.
130
131 Returns:
132 Boolean: True if input is empty, False otherwise.
133 """
134 if input_string is None:
135 return True
136 if input_string == "":
137 print("Please enter a non-empty value.")
138 return True
139 return False
140
141
142class GoogleSDKBins(object):
143 """Class to run tools in the Google SDK."""
144
145 def __init__(self, google_sdk_folder):
146 """GoogleSDKBins initialize.
147
148 Args:
149 google_sdk_folder: String, google sdk path.
150 """
151 self.gcloud_command_path = os.path.join(google_sdk_folder, "gcloud")
152 self.gsutil_command_path = os.path.join(google_sdk_folder, "gsutil")
153
154 def RunGcloud(self, cmd, **kwargs):
155 """Run gcloud command.
156
157 Args:
158 cmd: String list, command strings.
159 Ex: [config], then this function call "gcloud config".
160 **kwargs: dictionary of keyword based args to pass to func.
161
162 Returns:
163 String, return message after execute gcloud command.
164 """
165 return subprocess.check_output([self.gcloud_command_path] + cmd, **kwargs)
166
167 def RunGsutil(self, cmd, **kwargs):
168 """Run gsutil command.
169
170 Args:
171 cmd : String list, command strings.
172 Ex: [list], then this function call "gsutil list".
173 **kwargs: dictionary of keyword based args to pass to func.
174
175 Returns:
176 String, return message after execute gsutil command.
177 """
178 return subprocess.check_output([self.gsutil_command_path] + cmd, **kwargs)
179
180
herbertxue776cf922019-05-20 17:51:13 +0800181class GoogleAPIService(object):
182 """Class to enable api service in the gcp project."""
183
184 def __init__(self, service_name, error_msg, required=False):
185 """GoogleAPIService initialize.
186
187 Args:
188 service_name: String, name of api service.
189 error_msg: String, show messages if api service enable failed.
190 required: Boolean, True for service must be enabled for acloud.
191 """
192 self._name = service_name
193 self._error_msg = error_msg
194 self._required = required
195
196 def EnableService(self, gcloud_runner):
197 """Enable api service.
198
199 Args:
200 gcloud_runner: A GcloudRunner class to run "gcloud" command.
201 """
202 try:
203 gcloud_runner.RunGcloud(["services", "enable", self._name],
204 stderr=subprocess.STDOUT)
205 except subprocess.CalledProcessError as error:
206 self.ShowFailMessages(error.output)
207
208 def ShowFailMessages(self, error):
209 """Show fail messages.
210
211 Show the fail messages to hint users the impact if the api service
212 isn't enabled.
213
214 Args:
215 error: String of error message when opening api service failed.
216 """
217 msg_color = (utils.TextColors.FAIL if self._required else
218 utils.TextColors.WARNING)
219 utils.PrintColorString(
220 error + _OPEN_SERVICE_FAILED_MSG % {
221 "service_name": self._name,
222 "service_msg": self._error_msg}
223 , msg_color)
224
225 @property
226 def name(self):
227 """Return name."""
228 return self._name
229
230
herbertxue34776bb2018-07-03 21:57:48 +0800231class GcpTaskRunner(base_task_runner.BaseTaskRunner):
232 """Runner to setup google cloud user information."""
233
234 WELCOME_MESSAGE_TITLE = "Setup google cloud user information"
235 WELCOME_MESSAGE = (
236 "This step will walk you through gcloud SDK installation."
237 "Then configure gcloud user information."
238 "Finally enable some gcloud API services.")
239
240 def __init__(self, config_path):
241 """Initialize parameters.
242
243 Load config file to get current values.
244
245 Args:
246 config_path: String, acloud config path.
247 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700248 # pylint: disable=invalid-name
herbertxue34776bb2018-07-03 21:57:48 +0800249 config_mgr = config.AcloudConfigManager(config_path)
250 cfg = config_mgr.Load()
251 self.config_path = config_mgr.user_config_path
herbertxue34776bb2018-07-03 21:57:48 +0800252 self.project = cfg.project
253 self.zone = cfg.zone
herbertxue34776bb2018-07-03 21:57:48 +0800254 self.ssh_private_key_path = cfg.ssh_private_key_path
255 self.ssh_public_key_path = cfg.ssh_public_key_path
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700256 self.stable_host_image_name = cfg.stable_host_image_name
Kevin Cheng223acee2019-03-18 10:25:06 -0700257 self.client_id = cfg.client_id
258 self.client_secret = cfg.client_secret
259 self.service_account_name = cfg.service_account_name
260 self.service_account_private_key_path = cfg.service_account_private_key_path
261 self.service_account_json_private_key_path = cfg.service_account_json_private_key_path
herbertxue34776bb2018-07-03 21:57:48 +0800262
Kevin Cheng58b7e792018-10-05 02:26:53 -0700263 def ShouldRun(self):
264 """Check if we actually need to run GCP setup.
265
266 We'll only do the gcp setup if certain fields in the cfg are empty.
267
268 Returns:
269 True if reqired config fields are empty, False otherwise.
270 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700271 # We need to ensure the config has the proper auth-related fields set,
272 # so config requires just 1 of the following:
273 # 1. client id/secret
274 # 2. service account name/private key path
275 # 3. service account json private key path
276 if ((not self.client_id or not self.client_secret)
277 and (not self.service_account_name or not self.service_account_private_key_path)
278 and not self.service_account_json_private_key_path):
279 return True
280
281 # If a project isn't set, then we need to run setup.
282 return not self.project
Kevin Cheng58b7e792018-10-05 02:26:53 -0700283
herbertxue34776bb2018-07-03 21:57:48 +0800284 def _Run(self):
285 """Run GCP setup task."""
286 self._SetupGcloudInfo()
287 SetupSSHKeys(self.config_path, self.ssh_private_key_path,
288 self.ssh_public_key_path)
289
290 def _SetupGcloudInfo(self):
291 """Setup Gcloud user information.
292 1. Setup Gcloud SDK tools.
293 2. Setup Gcloud project.
294 a. Setup Gcloud project and zone.
295 b. Setup Client ID and Client secret.
296 c. Setup Google Cloud Storage bucket.
297 3. Enable Gcloud API services.
298 """
299 google_sdk_init = google_sdk.GoogleSDK()
300 try:
301 google_sdk_runner = GoogleSDKBins(google_sdk_init.GetSDKBinPath())
herbertxued69dc512019-05-30 15:37:15 +0800302 google_sdk_init.InstallGcloudComponent(google_sdk_runner,
303 _GCLOUD_COMPONENT_ALPHA)
herbertxue34776bb2018-07-03 21:57:48 +0800304 self._SetupProject(google_sdk_runner)
305 self._EnableGcloudServices(google_sdk_runner)
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700306 self._CreateStableHostImage()
herbertxue34776bb2018-07-03 21:57:48 +0800307 finally:
308 google_sdk_init.CleanUp()
309
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700310 def _CreateStableHostImage(self):
311 """Create the stable host image."""
312 # Write default stable_host_image_name with dummy value.
313 # TODO(113091773): An additional step to create the host image.
314 if not self.stable_host_image_name:
315 UpdateConfigFile(self.config_path, "stable_host_image_name", "")
316
317
herbertxue34776bb2018-07-03 21:57:48 +0800318 def _NeedProjectSetup(self):
319 """Confirm project setup should run or not.
320
321 If the project settings (project name and zone) are blank (either one),
322 we'll run the project setup flow. If they are set, we'll check with
323 the user if they want to update them.
324
325 Returns:
326 Boolean: True if we need to setup the project, False otherwise.
327 """
328 user_question = (
329 "Your default Project/Zone settings are:\n"
330 "project:[%s]\n"
331 "zone:[%s]\n"
Sam Chiu705b9012019-01-19 12:11:35 +0800332 "Would you like to update them?[y/N]: \n") % (self.project, self.zone)
herbertxue34776bb2018-07-03 21:57:48 +0800333
334 if not self.project or not self.zone:
335 logger.info("Project or zone is empty. Start to run setup process.")
336 return True
337 return utils.GetUserAnswerYes(user_question)
338
339 def _NeedClientIDSetup(self, project_changed):
340 """Confirm client setup should run or not.
341
342 If project changed, client ID must also have to change.
343 So tool will force to run setup function.
344 If client ID or client secret is empty, tool force to run setup function.
345 If project didn't change and config hold user client ID/secret, tool
346 would skip client ID setup.
347
348 Args:
349 project_changed: Boolean, True for project changed.
350
351 Returns:
352 Boolean: True for run setup function.
353 """
354 if project_changed:
355 logger.info("Your project changed. Start to run setup process.")
356 return True
357 elif not self.client_id or not self.client_secret:
358 logger.info("Client ID or client secret is empty. Start to run setup process.")
359 return True
360 logger.info("Project was unchanged and client ID didn't need to changed.")
361 return False
362
363 def _SetupProject(self, gcloud_runner):
364 """Setup gcloud project information.
365
366 Setup project and zone.
367 Setup client ID and client secret.
herbertxued69dc512019-05-30 15:37:15 +0800368 Make sure billing account enabled in project.
herbertxue34776bb2018-07-03 21:57:48 +0800369
370 Args:
371 gcloud_runner: A GcloudRunner class to run "gcloud" command.
372 """
373 project_changed = False
374 if self._NeedProjectSetup():
375 project_changed = self._UpdateProject(gcloud_runner)
376 if self._NeedClientIDSetup(project_changed):
377 self._SetupClientIDSecret()
herbertxued69dc512019-05-30 15:37:15 +0800378 self._CheckBillingEnable(gcloud_runner)
herbertxue34776bb2018-07-03 21:57:48 +0800379
380 def _UpdateProject(self, gcloud_runner):
381 """Setup gcloud project name and zone name and check project changed.
382
383 Run "gcloud init" to handle gcloud project setup.
384 Then "gcloud list" to get user settings information include "project" & "zone".
385 Record project_changed for next setup steps.
386
387 Args:
388 gcloud_runner: A GcloudRunner class to run "gcloud" command.
389
390 Returns:
391 project_changed: True for project settings changed.
392 """
393 project_changed = False
394 gcloud_runner.RunGcloud(["init"])
395 gcp_config_list_out = gcloud_runner.RunGcloud(["config", "list"])
396 for line in gcp_config_list_out.splitlines():
397 project_match = _PROJECT_RE.match(line)
398 if project_match:
399 project = project_match.group("project")
400 project_changed = (self.project != project)
401 self.project = project
402 continue
403 zone_match = _ZONE_RE.match(line)
404 if zone_match:
405 self.zone = zone_match.group("zone")
406 continue
407 UpdateConfigFile(self.config_path, "project", self.project)
408 UpdateConfigFile(self.config_path, "zone", self.zone)
409 return project_changed
410
411 def _SetupClientIDSecret(self):
412 """Setup Client ID / Client Secret in config file.
413
414 User can use input new values for Client ID and Client Secret.
415 """
416 print("Please generate a new client ID/secret by following the instructions here:")
417 print("https://support.google.com/cloud/answer/6158849?hl=en")
418 # TODO: Create markdown readme instructions since the link isn't too helpful.
419 self.client_id = None
420 self.client_secret = None
421 while _InputIsEmpty(self.client_id):
chojoyce7479ce32019-10-29 12:05:44 +0800422 self.client_id = str(six.moves.input("Enter Client ID: ").strip())
herbertxue34776bb2018-07-03 21:57:48 +0800423 while _InputIsEmpty(self.client_secret):
chojoyce7479ce32019-10-29 12:05:44 +0800424 self.client_secret = str(six.moves.input("Enter Client Secret: ").strip())
herbertxue34776bb2018-07-03 21:57:48 +0800425 UpdateConfigFile(self.config_path, "client_id", self.client_id)
426 UpdateConfigFile(self.config_path, "client_secret", self.client_secret)
427
herbertxued69dc512019-05-30 15:37:15 +0800428 def _CheckBillingEnable(self, gcloud_runner):
429 """Check billing enabled in gcp project.
430
431 The billing info get by gcloud alpha command. Here is one example:
432 $ gcloud alpha billing projects describe project_name
433 billingAccountName: billingAccounts/011BXX-A30XXX-9XXXX
434 billingEnabled: true
435 name: projects/project_name/billingInfo
436 projectId: project_name
437
438 Args:
439 gcloud_runner: A GcloudRunner class to run "gcloud" command.
440
441 Raises:
442 NoBillingError: gcp project doesn't enable billing account.
443 """
444 billing_info = gcloud_runner.RunGcloud(
445 ["alpha", "billing", "projects", "describe", self.project])
446 if _BILLING_ENABLE_MSG not in billing_info:
447 raise errors.NoBillingError(
448 "Please set billing account to project(%s) by following the "
449 "instructions here: "
450 "https://cloud.google.com/billing/docs/how-to/modify-project"
451 % self.project)
452
herbertxue34776bb2018-07-03 21:57:48 +0800453 @staticmethod
454 def _EnableGcloudServices(gcloud_runner):
455 """Enable 3 Gcloud API services.
456
457 1. Android build service
458 2. Compute engine service
herbertxue34776bb2018-07-03 21:57:48 +0800459 To avoid confuse user, we don't show messages for services processing
460 messages. e.g. "Waiting for async operation operations ...."
461
462 Args:
463 gcloud_runner: A GcloudRunner class to run "gcloud" command.
464 """
herbertxue776cf922019-05-20 17:51:13 +0800465 google_apis = [
herbertxue776cf922019-05-20 17:51:13 +0800466 GoogleAPIService(_ANDROID_BUILD_SERVICE, _ANDROID_BUILD_MSG),
467 GoogleAPIService(_COMPUTE_ENGINE_SERVICE, _COMPUTE_ENGINE_MSG, required=True)
468 ]
469 enabled_services = gcloud_runner.RunGcloud(
470 ["services", "list", "--enabled", "--format", "value(NAME)"],
471 stderr=subprocess.STDOUT).splitlines()
472
473 for service in google_apis:
474 if service.name not in enabled_services:
475 service.EnableService(gcloud_runner)