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