blob: 3cc22b9f1234b74d6521d7528912f0a090497c21 [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
24from acloud import errors
25from acloud.internal.lib import utils
26from acloud.public import config
27from acloud.setup import base_task_runner
28from acloud.setup import google_sdk
29
herbertxue1512f8a2019-06-27 13:56:23 +080030
31logger = logging.getLogger(__name__)
32
herbertxue34776bb2018-07-03 21:57:48 +080033# APIs that need to be enabled for GCP project.
34_ANDROID_BUILD_SERVICE = "androidbuildinternal.googleapis.com"
herbertxue776cf922019-05-20 17:51:13 +080035_ANDROID_BUILD_MSG = (
36 "This service (%s) help to download images from Android Build. If it isn't "
37 "enabled, acloud only supports local images to create AVD."
38 % _ANDROID_BUILD_SERVICE)
herbertxue34776bb2018-07-03 21:57:48 +080039_COMPUTE_ENGINE_SERVICE = "compute.googleapis.com"
herbertxue776cf922019-05-20 17:51:13 +080040_COMPUTE_ENGINE_MSG = (
41 "This service (%s) help to create instance in google cloud platform. If it "
42 "isn't enabled, acloud can't work anymore." % _COMPUTE_ENGINE_SERVICE)
herbertxue776cf922019-05-20 17:51:13 +080043_OPEN_SERVICE_FAILED_MSG = (
44 "\n[Open Service Failed]\n"
45 "Service name: %(service_name)s\n"
46 "%(service_msg)s\n")
47
herbertxue34776bb2018-07-03 21:57:48 +080048_BUILD_SERVICE_ACCOUNT = "android-build-prod@system.gserviceaccount.com"
herbertxued69dc512019-05-30 15:37:15 +080049_BILLING_ENABLE_MSG = "billingEnabled: true"
herbertxue34776bb2018-07-03 21:57:48 +080050_DEFAULT_SSH_FOLDER = os.path.expanduser("~/.ssh")
51_DEFAULT_SSH_KEY = "acloud_rsa"
52_DEFAULT_SSH_PRIVATE_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
53 _DEFAULT_SSH_KEY)
54_DEFAULT_SSH_PUBLIC_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
55 _DEFAULT_SSH_KEY + ".pub")
herbertxued69dc512019-05-30 15:37:15 +080056_GCLOUD_COMPONENT_ALPHA = "alpha"
herbertxuea6a953a2019-06-25 18:29:00 +080057# Regular expression to get project/zone information.
herbertxue34776bb2018-07-03 21:57:48 +080058_PROJECT_RE = re.compile(r"^project = (?P<project>.+)")
59_ZONE_RE = re.compile(r"^zone = (?P<zone>.+)")
60
herbertxue34776bb2018-07-03 21:57:48 +080061
62def UpdateConfigFile(config_path, item, value):
63 """Update config data.
64
65 Case A: config file contain this item.
66 In config, "project = A_project". New value is B_project
67 Set config "project = B_project".
68 Case B: config file didn't contain this item.
69 New value is B_project.
70 Setup config as "project = B_project".
71
72 Args:
73 config_path: String, acloud config path.
74 item: String, item name in config file. EX: project, zone
75 value: String, value of item in config file.
76
77 TODO(111574698): Refactor this to minimize writes to the config file.
78 TODO(111574698): Use proto method to update config.
79 """
80 write_lines = []
81 find_item = False
82 write_line = item + ": \"" + value + "\"\n"
83 if os.path.isfile(config_path):
84 with open(config_path, "r") as cfg_file:
85 for read_line in cfg_file.readlines():
86 if read_line.startswith(item + ":"):
87 find_item = True
88 write_lines.append(write_line)
89 else:
90 write_lines.append(read_line)
91 if not find_item:
92 write_lines.append(write_line)
93 with open(config_path, "w") as cfg_file:
94 cfg_file.writelines(write_lines)
95
96
97def SetupSSHKeys(config_path, private_key_path, public_key_path):
98 """Setup the pair of the ssh key for acloud.config.
99
100 User can use the default path: "~/.ssh/acloud_rsa".
101
102 Args:
103 config_path: String, acloud config path.
104 private_key_path: Path to the private key file.
105 e.g. ~/.ssh/acloud_rsa
106 public_key_path: Path to the public key file.
107 e.g. ~/.ssh/acloud_rsa.pub
108 """
109 private_key_path = os.path.expanduser(private_key_path)
110 if (private_key_path == "" or public_key_path == ""
111 or private_key_path == _DEFAULT_SSH_PRIVATE_KEY):
112 utils.CreateSshKeyPairIfNotExist(_DEFAULT_SSH_PRIVATE_KEY,
113 _DEFAULT_SSH_PUBLIC_KEY)
114 UpdateConfigFile(config_path, "ssh_private_key_path",
115 _DEFAULT_SSH_PRIVATE_KEY)
116 UpdateConfigFile(config_path, "ssh_public_key_path",
117 _DEFAULT_SSH_PUBLIC_KEY)
118
119
120def _InputIsEmpty(input_string):
121 """Check input string is empty.
122
123 Tool requests user to input client ID & client secret.
124 This basic check can detect user input is empty.
125
126 Args:
127 input_string: String, user input string.
128
129 Returns:
130 Boolean: True if input is empty, False otherwise.
131 """
132 if input_string is None:
133 return True
134 if input_string == "":
135 print("Please enter a non-empty value.")
136 return True
137 return False
138
139
140class GoogleSDKBins(object):
141 """Class to run tools in the Google SDK."""
142
143 def __init__(self, google_sdk_folder):
144 """GoogleSDKBins initialize.
145
146 Args:
147 google_sdk_folder: String, google sdk path.
148 """
149 self.gcloud_command_path = os.path.join(google_sdk_folder, "gcloud")
150 self.gsutil_command_path = os.path.join(google_sdk_folder, "gsutil")
151
152 def RunGcloud(self, cmd, **kwargs):
153 """Run gcloud command.
154
155 Args:
156 cmd: String list, command strings.
157 Ex: [config], then this function call "gcloud config".
158 **kwargs: dictionary of keyword based args to pass to func.
159
160 Returns:
161 String, return message after execute gcloud command.
162 """
163 return subprocess.check_output([self.gcloud_command_path] + cmd, **kwargs)
164
165 def RunGsutil(self, cmd, **kwargs):
166 """Run gsutil command.
167
168 Args:
169 cmd : String list, command strings.
170 Ex: [list], then this function call "gsutil list".
171 **kwargs: dictionary of keyword based args to pass to func.
172
173 Returns:
174 String, return message after execute gsutil command.
175 """
176 return subprocess.check_output([self.gsutil_command_path] + cmd, **kwargs)
177
178
herbertxue776cf922019-05-20 17:51:13 +0800179class GoogleAPIService(object):
180 """Class to enable api service in the gcp project."""
181
182 def __init__(self, service_name, error_msg, required=False):
183 """GoogleAPIService initialize.
184
185 Args:
186 service_name: String, name of api service.
187 error_msg: String, show messages if api service enable failed.
188 required: Boolean, True for service must be enabled for acloud.
189 """
190 self._name = service_name
191 self._error_msg = error_msg
192 self._required = required
193
194 def EnableService(self, gcloud_runner):
195 """Enable api service.
196
197 Args:
198 gcloud_runner: A GcloudRunner class to run "gcloud" command.
199 """
200 try:
201 gcloud_runner.RunGcloud(["services", "enable", self._name],
202 stderr=subprocess.STDOUT)
203 except subprocess.CalledProcessError as error:
204 self.ShowFailMessages(error.output)
205
206 def ShowFailMessages(self, error):
207 """Show fail messages.
208
209 Show the fail messages to hint users the impact if the api service
210 isn't enabled.
211
212 Args:
213 error: String of error message when opening api service failed.
214 """
215 msg_color = (utils.TextColors.FAIL if self._required else
216 utils.TextColors.WARNING)
217 utils.PrintColorString(
218 error + _OPEN_SERVICE_FAILED_MSG % {
219 "service_name": self._name,
220 "service_msg": self._error_msg}
221 , msg_color)
222
223 @property
224 def name(self):
225 """Return name."""
226 return self._name
227
228
herbertxue34776bb2018-07-03 21:57:48 +0800229class GcpTaskRunner(base_task_runner.BaseTaskRunner):
230 """Runner to setup google cloud user information."""
231
232 WELCOME_MESSAGE_TITLE = "Setup google cloud user information"
233 WELCOME_MESSAGE = (
234 "This step will walk you through gcloud SDK installation."
235 "Then configure gcloud user information."
236 "Finally enable some gcloud API services.")
237
238 def __init__(self, config_path):
239 """Initialize parameters.
240
241 Load config file to get current values.
242
243 Args:
244 config_path: String, acloud config path.
245 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700246 # pylint: disable=invalid-name
herbertxue34776bb2018-07-03 21:57:48 +0800247 config_mgr = config.AcloudConfigManager(config_path)
248 cfg = config_mgr.Load()
249 self.config_path = config_mgr.user_config_path
herbertxue34776bb2018-07-03 21:57:48 +0800250 self.project = cfg.project
251 self.zone = cfg.zone
herbertxue34776bb2018-07-03 21:57:48 +0800252 self.ssh_private_key_path = cfg.ssh_private_key_path
253 self.ssh_public_key_path = cfg.ssh_public_key_path
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700254 self.stable_host_image_name = cfg.stable_host_image_name
Kevin Cheng223acee2019-03-18 10:25:06 -0700255 self.client_id = cfg.client_id
256 self.client_secret = cfg.client_secret
257 self.service_account_name = cfg.service_account_name
258 self.service_account_private_key_path = cfg.service_account_private_key_path
259 self.service_account_json_private_key_path = cfg.service_account_json_private_key_path
herbertxue34776bb2018-07-03 21:57:48 +0800260
Kevin Cheng58b7e792018-10-05 02:26:53 -0700261 def ShouldRun(self):
262 """Check if we actually need to run GCP setup.
263
264 We'll only do the gcp setup if certain fields in the cfg are empty.
265
266 Returns:
267 True if reqired config fields are empty, False otherwise.
268 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700269 # We need to ensure the config has the proper auth-related fields set,
270 # so config requires just 1 of the following:
271 # 1. client id/secret
272 # 2. service account name/private key path
273 # 3. service account json private key path
274 if ((not self.client_id or not self.client_secret)
275 and (not self.service_account_name or not self.service_account_private_key_path)
276 and not self.service_account_json_private_key_path):
277 return True
278
279 # If a project isn't set, then we need to run setup.
280 return not self.project
Kevin Cheng58b7e792018-10-05 02:26:53 -0700281
herbertxue34776bb2018-07-03 21:57:48 +0800282 def _Run(self):
283 """Run GCP setup task."""
284 self._SetupGcloudInfo()
285 SetupSSHKeys(self.config_path, self.ssh_private_key_path,
286 self.ssh_public_key_path)
287
288 def _SetupGcloudInfo(self):
289 """Setup Gcloud user information.
290 1. Setup Gcloud SDK tools.
291 2. Setup Gcloud project.
292 a. Setup Gcloud project and zone.
293 b. Setup Client ID and Client secret.
294 c. Setup Google Cloud Storage bucket.
295 3. Enable Gcloud API services.
296 """
297 google_sdk_init = google_sdk.GoogleSDK()
298 try:
299 google_sdk_runner = GoogleSDKBins(google_sdk_init.GetSDKBinPath())
herbertxued69dc512019-05-30 15:37:15 +0800300 google_sdk_init.InstallGcloudComponent(google_sdk_runner,
301 _GCLOUD_COMPONENT_ALPHA)
herbertxue34776bb2018-07-03 21:57:48 +0800302 self._SetupProject(google_sdk_runner)
303 self._EnableGcloudServices(google_sdk_runner)
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700304 self._CreateStableHostImage()
herbertxue34776bb2018-07-03 21:57:48 +0800305 finally:
306 google_sdk_init.CleanUp()
307
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700308 def _CreateStableHostImage(self):
309 """Create the stable host image."""
310 # Write default stable_host_image_name with dummy value.
311 # TODO(113091773): An additional step to create the host image.
312 if not self.stable_host_image_name:
313 UpdateConfigFile(self.config_path, "stable_host_image_name", "")
314
315
herbertxue34776bb2018-07-03 21:57:48 +0800316 def _NeedProjectSetup(self):
317 """Confirm project setup should run or not.
318
319 If the project settings (project name and zone) are blank (either one),
320 we'll run the project setup flow. If they are set, we'll check with
321 the user if they want to update them.
322
323 Returns:
324 Boolean: True if we need to setup the project, False otherwise.
325 """
326 user_question = (
327 "Your default Project/Zone settings are:\n"
328 "project:[%s]\n"
329 "zone:[%s]\n"
Sam Chiu705b9012019-01-19 12:11:35 +0800330 "Would you like to update them?[y/N]: \n") % (self.project, self.zone)
herbertxue34776bb2018-07-03 21:57:48 +0800331
332 if not self.project or not self.zone:
333 logger.info("Project or zone is empty. Start to run setup process.")
334 return True
335 return utils.GetUserAnswerYes(user_question)
336
337 def _NeedClientIDSetup(self, project_changed):
338 """Confirm client setup should run or not.
339
340 If project changed, client ID must also have to change.
341 So tool will force to run setup function.
342 If client ID or client secret is empty, tool force to run setup function.
343 If project didn't change and config hold user client ID/secret, tool
344 would skip client ID setup.
345
346 Args:
347 project_changed: Boolean, True for project changed.
348
349 Returns:
350 Boolean: True for run setup function.
351 """
352 if project_changed:
353 logger.info("Your project changed. Start to run setup process.")
354 return True
355 elif not self.client_id or not self.client_secret:
356 logger.info("Client ID or client secret is empty. Start to run setup process.")
357 return True
358 logger.info("Project was unchanged and client ID didn't need to changed.")
359 return False
360
361 def _SetupProject(self, gcloud_runner):
362 """Setup gcloud project information.
363
364 Setup project and zone.
365 Setup client ID and client secret.
herbertxued69dc512019-05-30 15:37:15 +0800366 Make sure billing account enabled in project.
herbertxue34776bb2018-07-03 21:57:48 +0800367
368 Args:
369 gcloud_runner: A GcloudRunner class to run "gcloud" command.
370 """
371 project_changed = False
372 if self._NeedProjectSetup():
373 project_changed = self._UpdateProject(gcloud_runner)
374 if self._NeedClientIDSetup(project_changed):
375 self._SetupClientIDSecret()
herbertxued69dc512019-05-30 15:37:15 +0800376 self._CheckBillingEnable(gcloud_runner)
herbertxue34776bb2018-07-03 21:57:48 +0800377
378 def _UpdateProject(self, gcloud_runner):
379 """Setup gcloud project name and zone name and check project changed.
380
381 Run "gcloud init" to handle gcloud project setup.
382 Then "gcloud list" to get user settings information include "project" & "zone".
383 Record project_changed for next setup steps.
384
385 Args:
386 gcloud_runner: A GcloudRunner class to run "gcloud" command.
387
388 Returns:
389 project_changed: True for project settings changed.
390 """
391 project_changed = False
392 gcloud_runner.RunGcloud(["init"])
393 gcp_config_list_out = gcloud_runner.RunGcloud(["config", "list"])
394 for line in gcp_config_list_out.splitlines():
395 project_match = _PROJECT_RE.match(line)
396 if project_match:
397 project = project_match.group("project")
398 project_changed = (self.project != project)
399 self.project = project
400 continue
401 zone_match = _ZONE_RE.match(line)
402 if zone_match:
403 self.zone = zone_match.group("zone")
404 continue
405 UpdateConfigFile(self.config_path, "project", self.project)
406 UpdateConfigFile(self.config_path, "zone", self.zone)
407 return project_changed
408
409 def _SetupClientIDSecret(self):
410 """Setup Client ID / Client Secret in config file.
411
412 User can use input new values for Client ID and Client Secret.
413 """
414 print("Please generate a new client ID/secret by following the instructions here:")
415 print("https://support.google.com/cloud/answer/6158849?hl=en")
416 # TODO: Create markdown readme instructions since the link isn't too helpful.
417 self.client_id = None
418 self.client_secret = None
419 while _InputIsEmpty(self.client_id):
420 self.client_id = str(raw_input("Enter Client ID: ").strip())
421 while _InputIsEmpty(self.client_secret):
422 self.client_secret = str(raw_input("Enter Client Secret: ").strip())
423 UpdateConfigFile(self.config_path, "client_id", self.client_id)
424 UpdateConfigFile(self.config_path, "client_secret", self.client_secret)
425
herbertxued69dc512019-05-30 15:37:15 +0800426 def _CheckBillingEnable(self, gcloud_runner):
427 """Check billing enabled in gcp project.
428
429 The billing info get by gcloud alpha command. Here is one example:
430 $ gcloud alpha billing projects describe project_name
431 billingAccountName: billingAccounts/011BXX-A30XXX-9XXXX
432 billingEnabled: true
433 name: projects/project_name/billingInfo
434 projectId: project_name
435
436 Args:
437 gcloud_runner: A GcloudRunner class to run "gcloud" command.
438
439 Raises:
440 NoBillingError: gcp project doesn't enable billing account.
441 """
442 billing_info = gcloud_runner.RunGcloud(
443 ["alpha", "billing", "projects", "describe", self.project])
444 if _BILLING_ENABLE_MSG not in billing_info:
445 raise errors.NoBillingError(
446 "Please set billing account to project(%s) by following the "
447 "instructions here: "
448 "https://cloud.google.com/billing/docs/how-to/modify-project"
449 % self.project)
450
herbertxue34776bb2018-07-03 21:57:48 +0800451 @staticmethod
452 def _EnableGcloudServices(gcloud_runner):
453 """Enable 3 Gcloud API services.
454
455 1. Android build service
456 2. Compute engine service
herbertxue34776bb2018-07-03 21:57:48 +0800457 To avoid confuse user, we don't show messages for services processing
458 messages. e.g. "Waiting for async operation operations ...."
459
460 Args:
461 gcloud_runner: A GcloudRunner class to run "gcloud" command.
462 """
herbertxue776cf922019-05-20 17:51:13 +0800463 google_apis = [
herbertxue776cf922019-05-20 17:51:13 +0800464 GoogleAPIService(_ANDROID_BUILD_SERVICE, _ANDROID_BUILD_MSG),
465 GoogleAPIService(_COMPUTE_ENGINE_SERVICE, _COMPUTE_ENGINE_MSG, required=True)
466 ]
467 enabled_services = gcloud_runner.RunGcloud(
468 ["services", "list", "--enabled", "--format", "value(NAME)"],
469 stderr=subprocess.STDOUT).splitlines()
470
471 for service in google_apis:
472 if service.name not in enabled_services:
473 service.EnableService(gcloud_runner)