blob: 209d6d20e4dfd07879a53ea5798db9db186171ff [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"
35_COMPUTE_ENGINE_SERVICE = "compute.googleapis.com"
36_GOOGLE_CLOUD_STORAGE_SERVICE = "storage-component.googleapis.com"
37_GOOGLE_APIS = [
38 _GOOGLE_CLOUD_STORAGE_SERVICE, _ANDROID_BUILD_SERVICE,
39 _COMPUTE_ENGINE_SERVICE
40]
41_BUILD_SERVICE_ACCOUNT = "android-build-prod@system.gserviceaccount.com"
herbertxued69dc512019-05-30 15:37:15 +080042_BILLING_ENABLE_MSG = "billingEnabled: true"
herbertxue34776bb2018-07-03 21:57:48 +080043_DEFAULT_SSH_FOLDER = os.path.expanduser("~/.ssh")
44_DEFAULT_SSH_KEY = "acloud_rsa"
45_DEFAULT_SSH_PRIVATE_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
46 _DEFAULT_SSH_KEY)
47_DEFAULT_SSH_PUBLIC_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
48 _DEFAULT_SSH_KEY + ".pub")
herbertxued69dc512019-05-30 15:37:15 +080049_GCLOUD_COMPONENT_ALPHA = "alpha"
herbertxueefb02a82018-10-08 12:02:54 +080050# Bucket naming parameters
51_BUCKET_HEADER = "gs://"
52_BUCKET_LENGTH_LIMIT = 63
53_DEFAULT_BUCKET_HEADER = "acloud"
54_DEFAULT_BUCKET_REGION = "US"
55_INVALID_BUCKET_NAME_END_CHARS = "_-"
56_PROJECT_SEPARATOR = ":"
herbertxue34776bb2018-07-03 21:57:48 +080057# Regular expression to get project/zone/bucket information.
58_BUCKET_RE = re.compile(r"^gs://(?P<bucket>.+)/")
59_BUCKET_REGION_RE = re.compile(r"^Location constraint:(?P<region>.+)")
60_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
181class GcpTaskRunner(base_task_runner.BaseTaskRunner):
182 """Runner to setup google cloud user information."""
183
184 WELCOME_MESSAGE_TITLE = "Setup google cloud user information"
185 WELCOME_MESSAGE = (
186 "This step will walk you through gcloud SDK installation."
187 "Then configure gcloud user information."
188 "Finally enable some gcloud API services.")
189
190 def __init__(self, config_path):
191 """Initialize parameters.
192
193 Load config file to get current values.
194
195 Args:
196 config_path: String, acloud config path.
197 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700198 # pylint: disable=invalid-name
herbertxue34776bb2018-07-03 21:57:48 +0800199 config_mgr = config.AcloudConfigManager(config_path)
200 cfg = config_mgr.Load()
201 self.config_path = config_mgr.user_config_path
herbertxue34776bb2018-07-03 21:57:48 +0800202 self.project = cfg.project
203 self.zone = cfg.zone
204 self.storage_bucket_name = cfg.storage_bucket_name
205 self.ssh_private_key_path = cfg.ssh_private_key_path
206 self.ssh_public_key_path = cfg.ssh_public_key_path
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700207 self.stable_host_image_name = cfg.stable_host_image_name
Kevin Cheng223acee2019-03-18 10:25:06 -0700208 self.client_id = cfg.client_id
209 self.client_secret = cfg.client_secret
210 self.service_account_name = cfg.service_account_name
211 self.service_account_private_key_path = cfg.service_account_private_key_path
212 self.service_account_json_private_key_path = cfg.service_account_json_private_key_path
herbertxue34776bb2018-07-03 21:57:48 +0800213
Kevin Cheng58b7e792018-10-05 02:26:53 -0700214 def ShouldRun(self):
215 """Check if we actually need to run GCP setup.
216
217 We'll only do the gcp setup if certain fields in the cfg are empty.
218
219 Returns:
220 True if reqired config fields are empty, False otherwise.
221 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700222 # We need to ensure the config has the proper auth-related fields set,
223 # so config requires just 1 of the following:
224 # 1. client id/secret
225 # 2. service account name/private key path
226 # 3. service account json private key path
227 if ((not self.client_id or not self.client_secret)
228 and (not self.service_account_name or not self.service_account_private_key_path)
229 and not self.service_account_json_private_key_path):
230 return True
231
232 # If a project isn't set, then we need to run setup.
233 return not self.project
Kevin Cheng58b7e792018-10-05 02:26:53 -0700234
herbertxue34776bb2018-07-03 21:57:48 +0800235 def _Run(self):
236 """Run GCP setup task."""
237 self._SetupGcloudInfo()
238 SetupSSHKeys(self.config_path, self.ssh_private_key_path,
239 self.ssh_public_key_path)
240
241 def _SetupGcloudInfo(self):
242 """Setup Gcloud user information.
243 1. Setup Gcloud SDK tools.
244 2. Setup Gcloud project.
245 a. Setup Gcloud project and zone.
246 b. Setup Client ID and Client secret.
247 c. Setup Google Cloud Storage bucket.
248 3. Enable Gcloud API services.
249 """
250 google_sdk_init = google_sdk.GoogleSDK()
251 try:
252 google_sdk_runner = GoogleSDKBins(google_sdk_init.GetSDKBinPath())
herbertxued69dc512019-05-30 15:37:15 +0800253 google_sdk_init.InstallGcloudComponent(google_sdk_runner,
254 _GCLOUD_COMPONENT_ALPHA)
herbertxue34776bb2018-07-03 21:57:48 +0800255 self._SetupProject(google_sdk_runner)
256 self._EnableGcloudServices(google_sdk_runner)
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700257 self._CreateStableHostImage()
herbertxue34776bb2018-07-03 21:57:48 +0800258 finally:
259 google_sdk_init.CleanUp()
260
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700261 def _CreateStableHostImage(self):
262 """Create the stable host image."""
263 # Write default stable_host_image_name with dummy value.
264 # TODO(113091773): An additional step to create the host image.
265 if not self.stable_host_image_name:
266 UpdateConfigFile(self.config_path, "stable_host_image_name", "")
267
268
herbertxue34776bb2018-07-03 21:57:48 +0800269 def _NeedProjectSetup(self):
270 """Confirm project setup should run or not.
271
272 If the project settings (project name and zone) are blank (either one),
273 we'll run the project setup flow. If they are set, we'll check with
274 the user if they want to update them.
275
276 Returns:
277 Boolean: True if we need to setup the project, False otherwise.
278 """
279 user_question = (
280 "Your default Project/Zone settings are:\n"
281 "project:[%s]\n"
282 "zone:[%s]\n"
Sam Chiu705b9012019-01-19 12:11:35 +0800283 "Would you like to update them?[y/N]: \n") % (self.project, self.zone)
herbertxue34776bb2018-07-03 21:57:48 +0800284
285 if not self.project or not self.zone:
286 logger.info("Project or zone is empty. Start to run setup process.")
287 return True
288 return utils.GetUserAnswerYes(user_question)
289
290 def _NeedClientIDSetup(self, project_changed):
291 """Confirm client setup should run or not.
292
293 If project changed, client ID must also have to change.
294 So tool will force to run setup function.
295 If client ID or client secret is empty, tool force to run setup function.
296 If project didn't change and config hold user client ID/secret, tool
297 would skip client ID setup.
298
299 Args:
300 project_changed: Boolean, True for project changed.
301
302 Returns:
303 Boolean: True for run setup function.
304 """
305 if project_changed:
306 logger.info("Your project changed. Start to run setup process.")
307 return True
308 elif not self.client_id or not self.client_secret:
309 logger.info("Client ID or client secret is empty. Start to run setup process.")
310 return True
311 logger.info("Project was unchanged and client ID didn't need to changed.")
312 return False
313
314 def _SetupProject(self, gcloud_runner):
315 """Setup gcloud project information.
316
317 Setup project and zone.
318 Setup client ID and client secret.
herbertxued69dc512019-05-30 15:37:15 +0800319 Make sure billing account enabled in project.
herbertxue34776bb2018-07-03 21:57:48 +0800320 Setup Google Cloud Storage bucket.
321
322 Args:
323 gcloud_runner: A GcloudRunner class to run "gcloud" command.
324 """
325 project_changed = False
326 if self._NeedProjectSetup():
327 project_changed = self._UpdateProject(gcloud_runner)
328 if self._NeedClientIDSetup(project_changed):
329 self._SetupClientIDSecret()
herbertxued69dc512019-05-30 15:37:15 +0800330 self._CheckBillingEnable(gcloud_runner)
herbertxue34776bb2018-07-03 21:57:48 +0800331 self._SetupStorageBucket(gcloud_runner)
332
333 def _UpdateProject(self, gcloud_runner):
334 """Setup gcloud project name and zone name and check project changed.
335
336 Run "gcloud init" to handle gcloud project setup.
337 Then "gcloud list" to get user settings information include "project" & "zone".
338 Record project_changed for next setup steps.
339
340 Args:
341 gcloud_runner: A GcloudRunner class to run "gcloud" command.
342
343 Returns:
344 project_changed: True for project settings changed.
345 """
346 project_changed = False
347 gcloud_runner.RunGcloud(["init"])
348 gcp_config_list_out = gcloud_runner.RunGcloud(["config", "list"])
349 for line in gcp_config_list_out.splitlines():
350 project_match = _PROJECT_RE.match(line)
351 if project_match:
352 project = project_match.group("project")
353 project_changed = (self.project != project)
354 self.project = project
355 continue
356 zone_match = _ZONE_RE.match(line)
357 if zone_match:
358 self.zone = zone_match.group("zone")
359 continue
360 UpdateConfigFile(self.config_path, "project", self.project)
361 UpdateConfigFile(self.config_path, "zone", self.zone)
362 return project_changed
363
364 def _SetupClientIDSecret(self):
365 """Setup Client ID / Client Secret in config file.
366
367 User can use input new values for Client ID and Client Secret.
368 """
369 print("Please generate a new client ID/secret by following the instructions here:")
370 print("https://support.google.com/cloud/answer/6158849?hl=en")
371 # TODO: Create markdown readme instructions since the link isn't too helpful.
372 self.client_id = None
373 self.client_secret = None
374 while _InputIsEmpty(self.client_id):
375 self.client_id = str(raw_input("Enter Client ID: ").strip())
376 while _InputIsEmpty(self.client_secret):
377 self.client_secret = str(raw_input("Enter Client Secret: ").strip())
378 UpdateConfigFile(self.config_path, "client_id", self.client_id)
379 UpdateConfigFile(self.config_path, "client_secret", self.client_secret)
380
herbertxued69dc512019-05-30 15:37:15 +0800381 def _CheckBillingEnable(self, gcloud_runner):
382 """Check billing enabled in gcp project.
383
384 The billing info get by gcloud alpha command. Here is one example:
385 $ gcloud alpha billing projects describe project_name
386 billingAccountName: billingAccounts/011BXX-A30XXX-9XXXX
387 billingEnabled: true
388 name: projects/project_name/billingInfo
389 projectId: project_name
390
391 Args:
392 gcloud_runner: A GcloudRunner class to run "gcloud" command.
393
394 Raises:
395 NoBillingError: gcp project doesn't enable billing account.
396 """
397 billing_info = gcloud_runner.RunGcloud(
398 ["alpha", "billing", "projects", "describe", self.project])
399 if _BILLING_ENABLE_MSG not in billing_info:
400 raise errors.NoBillingError(
401 "Please set billing account to project(%s) by following the "
402 "instructions here: "
403 "https://cloud.google.com/billing/docs/how-to/modify-project"
404 % self.project)
405
herbertxue34776bb2018-07-03 21:57:48 +0800406 def _SetupStorageBucket(self, gcloud_runner):
407 """Setup storage_bucket_name in config file.
408
409 We handle the following cases:
410 1. Bucket set in the config && bucket is valid.
411 - Configure the bucket.
412 2. Bucket set in the config && bucket is invalid.
413 - Create a default acloud bucket and configure it
414 3. Bucket is not set in the config.
415 - Create a default acloud bucket and configure it.
416
417 Args:
418 gcloud_runner: A GcloudRunner class to run "gsutil" command.
419 """
420 if (not self.storage_bucket_name
421 or not self._BucketIsValid(self.storage_bucket_name, gcloud_runner)):
422 self.storage_bucket_name = self._CreateDefaultBucket(gcloud_runner)
423 self._ConfigureBucket(gcloud_runner)
424 UpdateConfigFile(self.config_path, "storage_bucket_name",
425 self.storage_bucket_name)
426 logger.info("Storage bucket name set to [%s]", self.storage_bucket_name)
427
428 def _ConfigureBucket(self, gcloud_runner):
429 """Setup write access right for Android Build service account.
430
431 To avoid confuse user, we don't show messages for processing messages.
432 e.g. "No changes to gs://acloud-bucket/"
433
434 Args:
435 gcloud_runner: A GcloudRunner class to run "gsutil" command.
436 """
437 gcloud_runner.RunGsutil([
438 "acl", "ch", "-u",
439 "%s:W" % (_BUILD_SERVICE_ACCOUNT),
440 "%s" % (_BUCKET_HEADER + self.storage_bucket_name)
441 ], stderr=subprocess.STDOUT)
442
443 def _BucketIsValid(self, bucket_name, gcloud_runner):
444 """Check bucket is valid or not.
445
446 If bucket exists and region is in default region,
447 then this bucket is valid.
448
449 Args:
450 bucket_name: String, name of storage bucket.
451 gcloud_runner: A GcloudRunner class to run "gsutil" command.
452
453 Returns:
454 Boolean: True if bucket is valid, otherwise False.
455 """
456 return (self._BucketExists(bucket_name, gcloud_runner) and
457 self._BucketInDefaultRegion(bucket_name, gcloud_runner))
458
459 def _CreateDefaultBucket(self, gcloud_runner):
460 """Setup bucket to default bucket name.
461
462 Default bucket name is "acloud-{project}".
463 If default bucket exist and its region is not "US",
464 then default bucket name is changed as "acloud-{project}-us"
465 If default bucket didn't exist, tool will create it.
466
467 Args:
468 gcloud_runner: A GcloudRunner class to run "gsutil" command.
469
470 Returns:
471 String: string of bucket name.
472 """
herbertxueefb02a82018-10-08 12:02:54 +0800473 bucket_name = self._GenerateBucketName(self.project)
herbertxue34776bb2018-07-03 21:57:48 +0800474 if (self._BucketExists(bucket_name, gcloud_runner) and
475 not self._BucketInDefaultRegion(bucket_name, gcloud_runner)):
476 bucket_name += ("-" + _DEFAULT_BUCKET_REGION.lower())
477 if not self._BucketExists(bucket_name, gcloud_runner):
478 self._CreateBucket(bucket_name, gcloud_runner)
479 return bucket_name
480
481 @staticmethod
herbertxueefb02a82018-10-08 12:02:54 +0800482 def _GenerateBucketName(project_name):
483 """Generate GCS bucket name that meets the naming guidelines.
484
485 Naming guidelines: https://cloud.google.com/storage/docs/naming
486 1. Filter out organization name.
487 2. Filter out illegal characters.
488 3. Length limit.
489 4. Name must end with a number or letter.
490
491 Args:
492 project_name: String, name of project.
493
494 Returns:
495 String: GCS bucket name compliant with naming guidelines.
496 """
497 # Sanitize the project name by filtering out the org name (e.g.
498 # AOSP:fake_project -> fake_project)
499 if _PROJECT_SEPARATOR in project_name:
500 _, project_name = project_name.split(_PROJECT_SEPARATOR)
501
502 bucket_name = "%s-%s" % (_DEFAULT_BUCKET_HEADER, project_name)
503
504 # Rule 1: A bucket name can contain lowercase alphanumeric characters,
505 # hyphens, and underscores.
506 bucket_name = re.sub("[^a-zA-Z_/-]+", "", bucket_name).lower()
507
508 # Rule 2: Bucket names must limit to 63 characters.
509 if len(bucket_name) > _BUCKET_LENGTH_LIMIT:
510 bucket_name = bucket_name[:_BUCKET_LENGTH_LIMIT]
511
512 # Rule 3: Bucket names must end with a letter, strip out any ending
513 # "-" or "_" at the end of the name.
514 bucket_name = bucket_name.rstrip(_INVALID_BUCKET_NAME_END_CHARS)
515
516 return bucket_name
517
518 @staticmethod
herbertxue34776bb2018-07-03 21:57:48 +0800519 def _BucketExists(bucket_name, gcloud_runner):
520 """Confirm bucket exist in project or not.
521
522 Args:
523 bucket_name: String, name of storage bucket.
524 gcloud_runner: A GcloudRunner class to run "gsutil" command.
525
526 Returns:
527 Boolean: True for bucket exist in project.
528 """
529 output = gcloud_runner.RunGsutil(["list"])
530 for output_line in output.splitlines():
531 match = _BUCKET_RE.match(output_line)
532 if match.group("bucket") == bucket_name:
533 return True
534 return False
535
536 @staticmethod
537 def _BucketInDefaultRegion(bucket_name, gcloud_runner):
538 """Confirm bucket region settings is "US" or not.
539
540 Args:
541 bucket_name: String, name of storage bucket.
542 gcloud_runner: A GcloudRunner class to run "gsutil" command.
543
544 Returns:
545 Boolean: True for bucket region is in default region.
546
547 Raises:
548 errors.SetupError: For parsing bucket region information error.
549 """
550 output = gcloud_runner.RunGsutil(
551 ["ls", "-L", "-b", "%s" % (_BUCKET_HEADER + bucket_name)])
552 for region_line in output.splitlines():
553 region_match = _BUCKET_REGION_RE.match(region_line.strip())
554 if region_match:
555 region = region_match.group("region").strip()
556 logger.info("Bucket[%s] is in %s (checking for %s)", bucket_name,
557 region, _DEFAULT_BUCKET_REGION)
558 if region == _DEFAULT_BUCKET_REGION:
559 return True
560 return False
561 raise errors.ParseBucketRegionError("Could not determine bucket region.")
562
563 @staticmethod
564 def _CreateBucket(bucket_name, gcloud_runner):
565 """Create new storage bucket in project.
566
567 Args:
568 bucket_name: String, name of storage bucket.
569 gcloud_runner: A GcloudRunner class to run "gsutil" command.
570 """
571 gcloud_runner.RunGsutil(["mb", "%s" % (_BUCKET_HEADER + bucket_name)])
572 logger.info("Create bucket [%s].", bucket_name)
573
574 @staticmethod
575 def _EnableGcloudServices(gcloud_runner):
576 """Enable 3 Gcloud API services.
577
578 1. Android build service
579 2. Compute engine service
580 3. Google cloud storage service
581 To avoid confuse user, we don't show messages for services processing
582 messages. e.g. "Waiting for async operation operations ...."
583
584 Args:
585 gcloud_runner: A GcloudRunner class to run "gcloud" command.
586 """
587 for service in _GOOGLE_APIS:
588 gcloud_runner.RunGcloud(["services", "enable", service],
589 stderr=subprocess.STDOUT)