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