blob: 0a1b2813675ccd13b4b3c9fa424500abdc448531 [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
30# APIs that need to be enabled for GCP project.
31_ANDROID_BUILD_SERVICE = "androidbuildinternal.googleapis.com"
32_COMPUTE_ENGINE_SERVICE = "compute.googleapis.com"
33_GOOGLE_CLOUD_STORAGE_SERVICE = "storage-component.googleapis.com"
34_GOOGLE_APIS = [
35 _GOOGLE_CLOUD_STORAGE_SERVICE, _ANDROID_BUILD_SERVICE,
36 _COMPUTE_ENGINE_SERVICE
37]
38_BUILD_SERVICE_ACCOUNT = "android-build-prod@system.gserviceaccount.com"
herbertxue34776bb2018-07-03 21:57:48 +080039_DEFAULT_SSH_FOLDER = os.path.expanduser("~/.ssh")
40_DEFAULT_SSH_KEY = "acloud_rsa"
41_DEFAULT_SSH_PRIVATE_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
42 _DEFAULT_SSH_KEY)
43_DEFAULT_SSH_PUBLIC_KEY = os.path.join(_DEFAULT_SSH_FOLDER,
44 _DEFAULT_SSH_KEY + ".pub")
herbertxueefb02a82018-10-08 12:02:54 +080045# Bucket naming parameters
46_BUCKET_HEADER = "gs://"
47_BUCKET_LENGTH_LIMIT = 63
48_DEFAULT_BUCKET_HEADER = "acloud"
49_DEFAULT_BUCKET_REGION = "US"
50_INVALID_BUCKET_NAME_END_CHARS = "_-"
51_PROJECT_SEPARATOR = ":"
herbertxue34776bb2018-07-03 21:57:48 +080052# Regular expression to get project/zone/bucket information.
53_BUCKET_RE = re.compile(r"^gs://(?P<bucket>.+)/")
54_BUCKET_REGION_RE = re.compile(r"^Location constraint:(?P<region>.+)")
55_PROJECT_RE = re.compile(r"^project = (?P<project>.+)")
56_ZONE_RE = re.compile(r"^zone = (?P<zone>.+)")
57
58logger = logging.getLogger(__name__)
59
60
61def UpdateConfigFile(config_path, item, value):
62 """Update config data.
63
64 Case A: config file contain this item.
65 In config, "project = A_project". New value is B_project
66 Set config "project = B_project".
67 Case B: config file didn't contain this item.
68 New value is B_project.
69 Setup config as "project = B_project".
70
71 Args:
72 config_path: String, acloud config path.
73 item: String, item name in config file. EX: project, zone
74 value: String, value of item in config file.
75
76 TODO(111574698): Refactor this to minimize writes to the config file.
77 TODO(111574698): Use proto method to update config.
78 """
79 write_lines = []
80 find_item = False
81 write_line = item + ": \"" + value + "\"\n"
82 if os.path.isfile(config_path):
83 with open(config_path, "r") as cfg_file:
84 for read_line in cfg_file.readlines():
85 if read_line.startswith(item + ":"):
86 find_item = True
87 write_lines.append(write_line)
88 else:
89 write_lines.append(read_line)
90 if not find_item:
91 write_lines.append(write_line)
92 with open(config_path, "w") as cfg_file:
93 cfg_file.writelines(write_lines)
94
95
96def SetupSSHKeys(config_path, private_key_path, public_key_path):
97 """Setup the pair of the ssh key for acloud.config.
98
99 User can use the default path: "~/.ssh/acloud_rsa".
100
101 Args:
102 config_path: String, acloud config path.
103 private_key_path: Path to the private key file.
104 e.g. ~/.ssh/acloud_rsa
105 public_key_path: Path to the public key file.
106 e.g. ~/.ssh/acloud_rsa.pub
107 """
108 private_key_path = os.path.expanduser(private_key_path)
109 if (private_key_path == "" or public_key_path == ""
110 or private_key_path == _DEFAULT_SSH_PRIVATE_KEY):
111 utils.CreateSshKeyPairIfNotExist(_DEFAULT_SSH_PRIVATE_KEY,
112 _DEFAULT_SSH_PUBLIC_KEY)
113 UpdateConfigFile(config_path, "ssh_private_key_path",
114 _DEFAULT_SSH_PRIVATE_KEY)
115 UpdateConfigFile(config_path, "ssh_public_key_path",
116 _DEFAULT_SSH_PUBLIC_KEY)
117
118
119def _InputIsEmpty(input_string):
120 """Check input string is empty.
121
122 Tool requests user to input client ID & client secret.
123 This basic check can detect user input is empty.
124
125 Args:
126 input_string: String, user input string.
127
128 Returns:
129 Boolean: True if input is empty, False otherwise.
130 """
131 if input_string is None:
132 return True
133 if input_string == "":
134 print("Please enter a non-empty value.")
135 return True
136 return False
137
138
139class GoogleSDKBins(object):
140 """Class to run tools in the Google SDK."""
141
142 def __init__(self, google_sdk_folder):
143 """GoogleSDKBins initialize.
144
145 Args:
146 google_sdk_folder: String, google sdk path.
147 """
148 self.gcloud_command_path = os.path.join(google_sdk_folder, "gcloud")
149 self.gsutil_command_path = os.path.join(google_sdk_folder, "gsutil")
150
151 def RunGcloud(self, cmd, **kwargs):
152 """Run gcloud command.
153
154 Args:
155 cmd: String list, command strings.
156 Ex: [config], then this function call "gcloud config".
157 **kwargs: dictionary of keyword based args to pass to func.
158
159 Returns:
160 String, return message after execute gcloud command.
161 """
162 return subprocess.check_output([self.gcloud_command_path] + cmd, **kwargs)
163
164 def RunGsutil(self, cmd, **kwargs):
165 """Run gsutil command.
166
167 Args:
168 cmd : String list, command strings.
169 Ex: [list], then this function call "gsutil list".
170 **kwargs: dictionary of keyword based args to pass to func.
171
172 Returns:
173 String, return message after execute gsutil command.
174 """
175 return subprocess.check_output([self.gsutil_command_path] + cmd, **kwargs)
176
177
178class GcpTaskRunner(base_task_runner.BaseTaskRunner):
179 """Runner to setup google cloud user information."""
180
181 WELCOME_MESSAGE_TITLE = "Setup google cloud user information"
182 WELCOME_MESSAGE = (
183 "This step will walk you through gcloud SDK installation."
184 "Then configure gcloud user information."
185 "Finally enable some gcloud API services.")
186
187 def __init__(self, config_path):
188 """Initialize parameters.
189
190 Load config file to get current values.
191
192 Args:
193 config_path: String, acloud config path.
194 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700195 # pylint: disable=invalid-name
herbertxue34776bb2018-07-03 21:57:48 +0800196 config_mgr = config.AcloudConfigManager(config_path)
197 cfg = config_mgr.Load()
198 self.config_path = config_mgr.user_config_path
herbertxue34776bb2018-07-03 21:57:48 +0800199 self.project = cfg.project
200 self.zone = cfg.zone
201 self.storage_bucket_name = cfg.storage_bucket_name
202 self.ssh_private_key_path = cfg.ssh_private_key_path
203 self.ssh_public_key_path = cfg.ssh_public_key_path
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700204 self.stable_host_image_name = cfg.stable_host_image_name
Kevin Cheng223acee2019-03-18 10:25:06 -0700205 self.client_id = cfg.client_id
206 self.client_secret = cfg.client_secret
207 self.service_account_name = cfg.service_account_name
208 self.service_account_private_key_path = cfg.service_account_private_key_path
209 self.service_account_json_private_key_path = cfg.service_account_json_private_key_path
herbertxue34776bb2018-07-03 21:57:48 +0800210
Kevin Cheng58b7e792018-10-05 02:26:53 -0700211 def ShouldRun(self):
212 """Check if we actually need to run GCP setup.
213
214 We'll only do the gcp setup if certain fields in the cfg are empty.
215
216 Returns:
217 True if reqired config fields are empty, False otherwise.
218 """
Kevin Cheng223acee2019-03-18 10:25:06 -0700219 # We need to ensure the config has the proper auth-related fields set,
220 # so config requires just 1 of the following:
221 # 1. client id/secret
222 # 2. service account name/private key path
223 # 3. service account json private key path
224 if ((not self.client_id or not self.client_secret)
225 and (not self.service_account_name or not self.service_account_private_key_path)
226 and not self.service_account_json_private_key_path):
227 return True
228
229 # If a project isn't set, then we need to run setup.
230 return not self.project
Kevin Cheng58b7e792018-10-05 02:26:53 -0700231
herbertxue34776bb2018-07-03 21:57:48 +0800232 def _Run(self):
233 """Run GCP setup task."""
234 self._SetupGcloudInfo()
235 SetupSSHKeys(self.config_path, self.ssh_private_key_path,
236 self.ssh_public_key_path)
237
238 def _SetupGcloudInfo(self):
239 """Setup Gcloud user information.
240 1. Setup Gcloud SDK tools.
241 2. Setup Gcloud project.
242 a. Setup Gcloud project and zone.
243 b. Setup Client ID and Client secret.
244 c. Setup Google Cloud Storage bucket.
245 3. Enable Gcloud API services.
246 """
247 google_sdk_init = google_sdk.GoogleSDK()
248 try:
249 google_sdk_runner = GoogleSDKBins(google_sdk_init.GetSDKBinPath())
250 self._SetupProject(google_sdk_runner)
251 self._EnableGcloudServices(google_sdk_runner)
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700252 self._CreateStableHostImage()
herbertxue34776bb2018-07-03 21:57:48 +0800253 finally:
254 google_sdk_init.CleanUp()
255
Kevin Chengcc6bf0d2018-10-10 14:18:47 -0700256 def _CreateStableHostImage(self):
257 """Create the stable host image."""
258 # Write default stable_host_image_name with dummy value.
259 # TODO(113091773): An additional step to create the host image.
260 if not self.stable_host_image_name:
261 UpdateConfigFile(self.config_path, "stable_host_image_name", "")
262
263
herbertxue34776bb2018-07-03 21:57:48 +0800264 def _NeedProjectSetup(self):
265 """Confirm project setup should run or not.
266
267 If the project settings (project name and zone) are blank (either one),
268 we'll run the project setup flow. If they are set, we'll check with
269 the user if they want to update them.
270
271 Returns:
272 Boolean: True if we need to setup the project, False otherwise.
273 """
274 user_question = (
275 "Your default Project/Zone settings are:\n"
276 "project:[%s]\n"
277 "zone:[%s]\n"
Sam Chiu705b9012019-01-19 12:11:35 +0800278 "Would you like to update them?[y/N]: \n") % (self.project, self.zone)
herbertxue34776bb2018-07-03 21:57:48 +0800279
280 if not self.project or not self.zone:
281 logger.info("Project or zone is empty. Start to run setup process.")
282 return True
283 return utils.GetUserAnswerYes(user_question)
284
285 def _NeedClientIDSetup(self, project_changed):
286 """Confirm client setup should run or not.
287
288 If project changed, client ID must also have to change.
289 So tool will force to run setup function.
290 If client ID or client secret is empty, tool force to run setup function.
291 If project didn't change and config hold user client ID/secret, tool
292 would skip client ID setup.
293
294 Args:
295 project_changed: Boolean, True for project changed.
296
297 Returns:
298 Boolean: True for run setup function.
299 """
300 if project_changed:
301 logger.info("Your project changed. Start to run setup process.")
302 return True
303 elif not self.client_id or not self.client_secret:
304 logger.info("Client ID or client secret is empty. Start to run setup process.")
305 return True
306 logger.info("Project was unchanged and client ID didn't need to changed.")
307 return False
308
309 def _SetupProject(self, gcloud_runner):
310 """Setup gcloud project information.
311
312 Setup project and zone.
313 Setup client ID and client secret.
314 Setup Google Cloud Storage bucket.
315
316 Args:
317 gcloud_runner: A GcloudRunner class to run "gcloud" command.
318 """
319 project_changed = False
320 if self._NeedProjectSetup():
321 project_changed = self._UpdateProject(gcloud_runner)
322 if self._NeedClientIDSetup(project_changed):
323 self._SetupClientIDSecret()
324 self._SetupStorageBucket(gcloud_runner)
325
326 def _UpdateProject(self, gcloud_runner):
327 """Setup gcloud project name and zone name and check project changed.
328
329 Run "gcloud init" to handle gcloud project setup.
330 Then "gcloud list" to get user settings information include "project" & "zone".
331 Record project_changed for next setup steps.
332
333 Args:
334 gcloud_runner: A GcloudRunner class to run "gcloud" command.
335
336 Returns:
337 project_changed: True for project settings changed.
338 """
339 project_changed = False
340 gcloud_runner.RunGcloud(["init"])
341 gcp_config_list_out = gcloud_runner.RunGcloud(["config", "list"])
342 for line in gcp_config_list_out.splitlines():
343 project_match = _PROJECT_RE.match(line)
344 if project_match:
345 project = project_match.group("project")
346 project_changed = (self.project != project)
347 self.project = project
348 continue
349 zone_match = _ZONE_RE.match(line)
350 if zone_match:
351 self.zone = zone_match.group("zone")
352 continue
353 UpdateConfigFile(self.config_path, "project", self.project)
354 UpdateConfigFile(self.config_path, "zone", self.zone)
355 return project_changed
356
357 def _SetupClientIDSecret(self):
358 """Setup Client ID / Client Secret in config file.
359
360 User can use input new values for Client ID and Client Secret.
361 """
362 print("Please generate a new client ID/secret by following the instructions here:")
363 print("https://support.google.com/cloud/answer/6158849?hl=en")
364 # TODO: Create markdown readme instructions since the link isn't too helpful.
365 self.client_id = None
366 self.client_secret = None
367 while _InputIsEmpty(self.client_id):
368 self.client_id = str(raw_input("Enter Client ID: ").strip())
369 while _InputIsEmpty(self.client_secret):
370 self.client_secret = str(raw_input("Enter Client Secret: ").strip())
371 UpdateConfigFile(self.config_path, "client_id", self.client_id)
372 UpdateConfigFile(self.config_path, "client_secret", self.client_secret)
373
374 def _SetupStorageBucket(self, gcloud_runner):
375 """Setup storage_bucket_name in config file.
376
377 We handle the following cases:
378 1. Bucket set in the config && bucket is valid.
379 - Configure the bucket.
380 2. Bucket set in the config && bucket is invalid.
381 - Create a default acloud bucket and configure it
382 3. Bucket is not set in the config.
383 - Create a default acloud bucket and configure it.
384
385 Args:
386 gcloud_runner: A GcloudRunner class to run "gsutil" command.
387 """
388 if (not self.storage_bucket_name
389 or not self._BucketIsValid(self.storage_bucket_name, gcloud_runner)):
390 self.storage_bucket_name = self._CreateDefaultBucket(gcloud_runner)
391 self._ConfigureBucket(gcloud_runner)
392 UpdateConfigFile(self.config_path, "storage_bucket_name",
393 self.storage_bucket_name)
394 logger.info("Storage bucket name set to [%s]", self.storage_bucket_name)
395
396 def _ConfigureBucket(self, gcloud_runner):
397 """Setup write access right for Android Build service account.
398
399 To avoid confuse user, we don't show messages for processing messages.
400 e.g. "No changes to gs://acloud-bucket/"
401
402 Args:
403 gcloud_runner: A GcloudRunner class to run "gsutil" command.
404 """
405 gcloud_runner.RunGsutil([
406 "acl", "ch", "-u",
407 "%s:W" % (_BUILD_SERVICE_ACCOUNT),
408 "%s" % (_BUCKET_HEADER + self.storage_bucket_name)
409 ], stderr=subprocess.STDOUT)
410
411 def _BucketIsValid(self, bucket_name, gcloud_runner):
412 """Check bucket is valid or not.
413
414 If bucket exists and region is in default region,
415 then this bucket is valid.
416
417 Args:
418 bucket_name: String, name of storage bucket.
419 gcloud_runner: A GcloudRunner class to run "gsutil" command.
420
421 Returns:
422 Boolean: True if bucket is valid, otherwise False.
423 """
424 return (self._BucketExists(bucket_name, gcloud_runner) and
425 self._BucketInDefaultRegion(bucket_name, gcloud_runner))
426
427 def _CreateDefaultBucket(self, gcloud_runner):
428 """Setup bucket to default bucket name.
429
430 Default bucket name is "acloud-{project}".
431 If default bucket exist and its region is not "US",
432 then default bucket name is changed as "acloud-{project}-us"
433 If default bucket didn't exist, tool will create it.
434
435 Args:
436 gcloud_runner: A GcloudRunner class to run "gsutil" command.
437
438 Returns:
439 String: string of bucket name.
440 """
herbertxueefb02a82018-10-08 12:02:54 +0800441 bucket_name = self._GenerateBucketName(self.project)
herbertxue34776bb2018-07-03 21:57:48 +0800442 if (self._BucketExists(bucket_name, gcloud_runner) and
443 not self._BucketInDefaultRegion(bucket_name, gcloud_runner)):
444 bucket_name += ("-" + _DEFAULT_BUCKET_REGION.lower())
445 if not self._BucketExists(bucket_name, gcloud_runner):
446 self._CreateBucket(bucket_name, gcloud_runner)
447 return bucket_name
448
449 @staticmethod
herbertxueefb02a82018-10-08 12:02:54 +0800450 def _GenerateBucketName(project_name):
451 """Generate GCS bucket name that meets the naming guidelines.
452
453 Naming guidelines: https://cloud.google.com/storage/docs/naming
454 1. Filter out organization name.
455 2. Filter out illegal characters.
456 3. Length limit.
457 4. Name must end with a number or letter.
458
459 Args:
460 project_name: String, name of project.
461
462 Returns:
463 String: GCS bucket name compliant with naming guidelines.
464 """
465 # Sanitize the project name by filtering out the org name (e.g.
466 # AOSP:fake_project -> fake_project)
467 if _PROJECT_SEPARATOR in project_name:
468 _, project_name = project_name.split(_PROJECT_SEPARATOR)
469
470 bucket_name = "%s-%s" % (_DEFAULT_BUCKET_HEADER, project_name)
471
472 # Rule 1: A bucket name can contain lowercase alphanumeric characters,
473 # hyphens, and underscores.
474 bucket_name = re.sub("[^a-zA-Z_/-]+", "", bucket_name).lower()
475
476 # Rule 2: Bucket names must limit to 63 characters.
477 if len(bucket_name) > _BUCKET_LENGTH_LIMIT:
478 bucket_name = bucket_name[:_BUCKET_LENGTH_LIMIT]
479
480 # Rule 3: Bucket names must end with a letter, strip out any ending
481 # "-" or "_" at the end of the name.
482 bucket_name = bucket_name.rstrip(_INVALID_BUCKET_NAME_END_CHARS)
483
484 return bucket_name
485
486 @staticmethod
herbertxue34776bb2018-07-03 21:57:48 +0800487 def _BucketExists(bucket_name, gcloud_runner):
488 """Confirm bucket exist in project or not.
489
490 Args:
491 bucket_name: String, name of storage bucket.
492 gcloud_runner: A GcloudRunner class to run "gsutil" command.
493
494 Returns:
495 Boolean: True for bucket exist in project.
496 """
497 output = gcloud_runner.RunGsutil(["list"])
498 for output_line in output.splitlines():
499 match = _BUCKET_RE.match(output_line)
500 if match.group("bucket") == bucket_name:
501 return True
502 return False
503
504 @staticmethod
505 def _BucketInDefaultRegion(bucket_name, gcloud_runner):
506 """Confirm bucket region settings is "US" or not.
507
508 Args:
509 bucket_name: String, name of storage bucket.
510 gcloud_runner: A GcloudRunner class to run "gsutil" command.
511
512 Returns:
513 Boolean: True for bucket region is in default region.
514
515 Raises:
516 errors.SetupError: For parsing bucket region information error.
517 """
518 output = gcloud_runner.RunGsutil(
519 ["ls", "-L", "-b", "%s" % (_BUCKET_HEADER + bucket_name)])
520 for region_line in output.splitlines():
521 region_match = _BUCKET_REGION_RE.match(region_line.strip())
522 if region_match:
523 region = region_match.group("region").strip()
524 logger.info("Bucket[%s] is in %s (checking for %s)", bucket_name,
525 region, _DEFAULT_BUCKET_REGION)
526 if region == _DEFAULT_BUCKET_REGION:
527 return True
528 return False
529 raise errors.ParseBucketRegionError("Could not determine bucket region.")
530
531 @staticmethod
532 def _CreateBucket(bucket_name, gcloud_runner):
533 """Create new storage bucket in project.
534
535 Args:
536 bucket_name: String, name of storage bucket.
537 gcloud_runner: A GcloudRunner class to run "gsutil" command.
538 """
539 gcloud_runner.RunGsutil(["mb", "%s" % (_BUCKET_HEADER + bucket_name)])
540 logger.info("Create bucket [%s].", bucket_name)
541
542 @staticmethod
543 def _EnableGcloudServices(gcloud_runner):
544 """Enable 3 Gcloud API services.
545
546 1. Android build service
547 2. Compute engine service
548 3. Google cloud storage service
549 To avoid confuse user, we don't show messages for services processing
550 messages. e.g. "Waiting for async operation operations ...."
551
552 Args:
553 gcloud_runner: A GcloudRunner class to run "gcloud" command.
554 """
555 for service in _GOOGLE_APIS:
556 gcloud_runner.RunGcloud(["services", "enable", service],
557 stderr=subprocess.STDOUT)