blob: 7b9feb597372c95145ca24a8c0a452358b91d2f9 [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"
39_BUCKET_HEADER = "gs://"
40_DEFAULT_BUCKET_HEADER = "acloud"
41_DEFAULT_BUCKET_REGION = "US"
42_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")
48# Regular expression to get project/zone/bucket information.
49_BUCKET_RE = re.compile(r"^gs://(?P<bucket>.+)/")
50_BUCKET_REGION_RE = re.compile(r"^Location constraint:(?P<region>.+)")
51_PROJECT_RE = re.compile(r"^project = (?P<project>.+)")
52_ZONE_RE = re.compile(r"^zone = (?P<zone>.+)")
53
54logger = logging.getLogger(__name__)
55
56
57def UpdateConfigFile(config_path, item, value):
58 """Update config data.
59
60 Case A: config file contain this item.
61 In config, "project = A_project". New value is B_project
62 Set config "project = B_project".
63 Case B: config file didn't contain this item.
64 New value is B_project.
65 Setup config as "project = B_project".
66
67 Args:
68 config_path: String, acloud config path.
69 item: String, item name in config file. EX: project, zone
70 value: String, value of item in config file.
71
72 TODO(111574698): Refactor this to minimize writes to the config file.
73 TODO(111574698): Use proto method to update config.
74 """
75 write_lines = []
76 find_item = False
77 write_line = item + ": \"" + value + "\"\n"
78 if os.path.isfile(config_path):
79 with open(config_path, "r") as cfg_file:
80 for read_line in cfg_file.readlines():
81 if read_line.startswith(item + ":"):
82 find_item = True
83 write_lines.append(write_line)
84 else:
85 write_lines.append(read_line)
86 if not find_item:
87 write_lines.append(write_line)
88 with open(config_path, "w") as cfg_file:
89 cfg_file.writelines(write_lines)
90
91
92def SetupSSHKeys(config_path, private_key_path, public_key_path):
93 """Setup the pair of the ssh key for acloud.config.
94
95 User can use the default path: "~/.ssh/acloud_rsa".
96
97 Args:
98 config_path: String, acloud config path.
99 private_key_path: Path to the private key file.
100 e.g. ~/.ssh/acloud_rsa
101 public_key_path: Path to the public key file.
102 e.g. ~/.ssh/acloud_rsa.pub
103 """
104 private_key_path = os.path.expanduser(private_key_path)
105 if (private_key_path == "" or public_key_path == ""
106 or private_key_path == _DEFAULT_SSH_PRIVATE_KEY):
107 utils.CreateSshKeyPairIfNotExist(_DEFAULT_SSH_PRIVATE_KEY,
108 _DEFAULT_SSH_PUBLIC_KEY)
109 UpdateConfigFile(config_path, "ssh_private_key_path",
110 _DEFAULT_SSH_PRIVATE_KEY)
111 UpdateConfigFile(config_path, "ssh_public_key_path",
112 _DEFAULT_SSH_PUBLIC_KEY)
113
114
115def _InputIsEmpty(input_string):
116 """Check input string is empty.
117
118 Tool requests user to input client ID & client secret.
119 This basic check can detect user input is empty.
120
121 Args:
122 input_string: String, user input string.
123
124 Returns:
125 Boolean: True if input is empty, False otherwise.
126 """
127 if input_string is None:
128 return True
129 if input_string == "":
130 print("Please enter a non-empty value.")
131 return True
132 return False
133
134
135class GoogleSDKBins(object):
136 """Class to run tools in the Google SDK."""
137
138 def __init__(self, google_sdk_folder):
139 """GoogleSDKBins initialize.
140
141 Args:
142 google_sdk_folder: String, google sdk path.
143 """
144 self.gcloud_command_path = os.path.join(google_sdk_folder, "gcloud")
145 self.gsutil_command_path = os.path.join(google_sdk_folder, "gsutil")
146
147 def RunGcloud(self, cmd, **kwargs):
148 """Run gcloud command.
149
150 Args:
151 cmd: String list, command strings.
152 Ex: [config], then this function call "gcloud config".
153 **kwargs: dictionary of keyword based args to pass to func.
154
155 Returns:
156 String, return message after execute gcloud command.
157 """
158 return subprocess.check_output([self.gcloud_command_path] + cmd, **kwargs)
159
160 def RunGsutil(self, cmd, **kwargs):
161 """Run gsutil command.
162
163 Args:
164 cmd : String list, command strings.
165 Ex: [list], then this function call "gsutil list".
166 **kwargs: dictionary of keyword based args to pass to func.
167
168 Returns:
169 String, return message after execute gsutil command.
170 """
171 return subprocess.check_output([self.gsutil_command_path] + cmd, **kwargs)
172
173
174class GcpTaskRunner(base_task_runner.BaseTaskRunner):
175 """Runner to setup google cloud user information."""
176
177 WELCOME_MESSAGE_TITLE = "Setup google cloud user information"
178 WELCOME_MESSAGE = (
179 "This step will walk you through gcloud SDK installation."
180 "Then configure gcloud user information."
181 "Finally enable some gcloud API services.")
182
183 def __init__(self, config_path):
184 """Initialize parameters.
185
186 Load config file to get current values.
187
188 Args:
189 config_path: String, acloud config path.
190 """
191 config_mgr = config.AcloudConfigManager(config_path)
192 cfg = config_mgr.Load()
193 self.config_path = config_mgr.user_config_path
194 self.client_id = cfg.client_id
195 self.client_secret = cfg.client_secret
196 self.project = cfg.project
197 self.zone = cfg.zone
198 self.storage_bucket_name = cfg.storage_bucket_name
199 self.ssh_private_key_path = cfg.ssh_private_key_path
200 self.ssh_public_key_path = cfg.ssh_public_key_path
201
202 # Write default stable_host_image_name with dummy value.
203 # TODO(113091773): An additional step to create the host image.
204 if not cfg.stable_host_image_name:
205 UpdateConfigFile(self.config_path, "stable_host_image_name", "")
206
207 def _Run(self):
208 """Run GCP setup task."""
209 self._SetupGcloudInfo()
210 SetupSSHKeys(self.config_path, self.ssh_private_key_path,
211 self.ssh_public_key_path)
212
213 def _SetupGcloudInfo(self):
214 """Setup Gcloud user information.
215 1. Setup Gcloud SDK tools.
216 2. Setup Gcloud project.
217 a. Setup Gcloud project and zone.
218 b. Setup Client ID and Client secret.
219 c. Setup Google Cloud Storage bucket.
220 3. Enable Gcloud API services.
221 """
222 google_sdk_init = google_sdk.GoogleSDK()
223 try:
224 google_sdk_runner = GoogleSDKBins(google_sdk_init.GetSDKBinPath())
225 self._SetupProject(google_sdk_runner)
226 self._EnableGcloudServices(google_sdk_runner)
227 finally:
228 google_sdk_init.CleanUp()
229
230 def _NeedProjectSetup(self):
231 """Confirm project setup should run or not.
232
233 If the project settings (project name and zone) are blank (either one),
234 we'll run the project setup flow. If they are set, we'll check with
235 the user if they want to update them.
236
237 Returns:
238 Boolean: True if we need to setup the project, False otherwise.
239 """
240 user_question = (
241 "Your default Project/Zone settings are:\n"
242 "project:[%s]\n"
243 "zone:[%s]\n"
244 "Would you like to update them? [y/n]\n") % (self.project, self.zone)
245
246 if not self.project or not self.zone:
247 logger.info("Project or zone is empty. Start to run setup process.")
248 return True
249 return utils.GetUserAnswerYes(user_question)
250
251 def _NeedClientIDSetup(self, project_changed):
252 """Confirm client setup should run or not.
253
254 If project changed, client ID must also have to change.
255 So tool will force to run setup function.
256 If client ID or client secret is empty, tool force to run setup function.
257 If project didn't change and config hold user client ID/secret, tool
258 would skip client ID setup.
259
260 Args:
261 project_changed: Boolean, True for project changed.
262
263 Returns:
264 Boolean: True for run setup function.
265 """
266 if project_changed:
267 logger.info("Your project changed. Start to run setup process.")
268 return True
269 elif not self.client_id or not self.client_secret:
270 logger.info("Client ID or client secret is empty. Start to run setup process.")
271 return True
272 logger.info("Project was unchanged and client ID didn't need to changed.")
273 return False
274
275 def _SetupProject(self, gcloud_runner):
276 """Setup gcloud project information.
277
278 Setup project and zone.
279 Setup client ID and client secret.
280 Setup Google Cloud Storage bucket.
281
282 Args:
283 gcloud_runner: A GcloudRunner class to run "gcloud" command.
284 """
285 project_changed = False
286 if self._NeedProjectSetup():
287 project_changed = self._UpdateProject(gcloud_runner)
288 if self._NeedClientIDSetup(project_changed):
289 self._SetupClientIDSecret()
290 self._SetupStorageBucket(gcloud_runner)
291
292 def _UpdateProject(self, gcloud_runner):
293 """Setup gcloud project name and zone name and check project changed.
294
295 Run "gcloud init" to handle gcloud project setup.
296 Then "gcloud list" to get user settings information include "project" & "zone".
297 Record project_changed for next setup steps.
298
299 Args:
300 gcloud_runner: A GcloudRunner class to run "gcloud" command.
301
302 Returns:
303 project_changed: True for project settings changed.
304 """
305 project_changed = False
306 gcloud_runner.RunGcloud(["init"])
307 gcp_config_list_out = gcloud_runner.RunGcloud(["config", "list"])
308 for line in gcp_config_list_out.splitlines():
309 project_match = _PROJECT_RE.match(line)
310 if project_match:
311 project = project_match.group("project")
312 project_changed = (self.project != project)
313 self.project = project
314 continue
315 zone_match = _ZONE_RE.match(line)
316 if zone_match:
317 self.zone = zone_match.group("zone")
318 continue
319 UpdateConfigFile(self.config_path, "project", self.project)
320 UpdateConfigFile(self.config_path, "zone", self.zone)
321 return project_changed
322
323 def _SetupClientIDSecret(self):
324 """Setup Client ID / Client Secret in config file.
325
326 User can use input new values for Client ID and Client Secret.
327 """
328 print("Please generate a new client ID/secret by following the instructions here:")
329 print("https://support.google.com/cloud/answer/6158849?hl=en")
330 # TODO: Create markdown readme instructions since the link isn't too helpful.
331 self.client_id = None
332 self.client_secret = None
333 while _InputIsEmpty(self.client_id):
334 self.client_id = str(raw_input("Enter Client ID: ").strip())
335 while _InputIsEmpty(self.client_secret):
336 self.client_secret = str(raw_input("Enter Client Secret: ").strip())
337 UpdateConfigFile(self.config_path, "client_id", self.client_id)
338 UpdateConfigFile(self.config_path, "client_secret", self.client_secret)
339
340 def _SetupStorageBucket(self, gcloud_runner):
341 """Setup storage_bucket_name in config file.
342
343 We handle the following cases:
344 1. Bucket set in the config && bucket is valid.
345 - Configure the bucket.
346 2. Bucket set in the config && bucket is invalid.
347 - Create a default acloud bucket and configure it
348 3. Bucket is not set in the config.
349 - Create a default acloud bucket and configure it.
350
351 Args:
352 gcloud_runner: A GcloudRunner class to run "gsutil" command.
353 """
354 if (not self.storage_bucket_name
355 or not self._BucketIsValid(self.storage_bucket_name, gcloud_runner)):
356 self.storage_bucket_name = self._CreateDefaultBucket(gcloud_runner)
357 self._ConfigureBucket(gcloud_runner)
358 UpdateConfigFile(self.config_path, "storage_bucket_name",
359 self.storage_bucket_name)
360 logger.info("Storage bucket name set to [%s]", self.storage_bucket_name)
361
362 def _ConfigureBucket(self, gcloud_runner):
363 """Setup write access right for Android Build service account.
364
365 To avoid confuse user, we don't show messages for processing messages.
366 e.g. "No changes to gs://acloud-bucket/"
367
368 Args:
369 gcloud_runner: A GcloudRunner class to run "gsutil" command.
370 """
371 gcloud_runner.RunGsutil([
372 "acl", "ch", "-u",
373 "%s:W" % (_BUILD_SERVICE_ACCOUNT),
374 "%s" % (_BUCKET_HEADER + self.storage_bucket_name)
375 ], stderr=subprocess.STDOUT)
376
377 def _BucketIsValid(self, bucket_name, gcloud_runner):
378 """Check bucket is valid or not.
379
380 If bucket exists and region is in default region,
381 then this bucket is valid.
382
383 Args:
384 bucket_name: String, name of storage bucket.
385 gcloud_runner: A GcloudRunner class to run "gsutil" command.
386
387 Returns:
388 Boolean: True if bucket is valid, otherwise False.
389 """
390 return (self._BucketExists(bucket_name, gcloud_runner) and
391 self._BucketInDefaultRegion(bucket_name, gcloud_runner))
392
393 def _CreateDefaultBucket(self, gcloud_runner):
394 """Setup bucket to default bucket name.
395
396 Default bucket name is "acloud-{project}".
397 If default bucket exist and its region is not "US",
398 then default bucket name is changed as "acloud-{project}-us"
399 If default bucket didn't exist, tool will create it.
400
401 Args:
402 gcloud_runner: A GcloudRunner class to run "gsutil" command.
403
404 Returns:
405 String: string of bucket name.
406 """
407 bucket_name = "%s-%s" % (_DEFAULT_BUCKET_HEADER, self.project)
408 if (self._BucketExists(bucket_name, gcloud_runner) and
409 not self._BucketInDefaultRegion(bucket_name, gcloud_runner)):
410 bucket_name += ("-" + _DEFAULT_BUCKET_REGION.lower())
411 if not self._BucketExists(bucket_name, gcloud_runner):
412 self._CreateBucket(bucket_name, gcloud_runner)
413 return bucket_name
414
415 @staticmethod
416 def _BucketExists(bucket_name, gcloud_runner):
417 """Confirm bucket exist in project or not.
418
419 Args:
420 bucket_name: String, name of storage bucket.
421 gcloud_runner: A GcloudRunner class to run "gsutil" command.
422
423 Returns:
424 Boolean: True for bucket exist in project.
425 """
426 output = gcloud_runner.RunGsutil(["list"])
427 for output_line in output.splitlines():
428 match = _BUCKET_RE.match(output_line)
429 if match.group("bucket") == bucket_name:
430 return True
431 return False
432
433 @staticmethod
434 def _BucketInDefaultRegion(bucket_name, gcloud_runner):
435 """Confirm bucket region settings is "US" or not.
436
437 Args:
438 bucket_name: String, name of storage bucket.
439 gcloud_runner: A GcloudRunner class to run "gsutil" command.
440
441 Returns:
442 Boolean: True for bucket region is in default region.
443
444 Raises:
445 errors.SetupError: For parsing bucket region information error.
446 """
447 output = gcloud_runner.RunGsutil(
448 ["ls", "-L", "-b", "%s" % (_BUCKET_HEADER + bucket_name)])
449 for region_line in output.splitlines():
450 region_match = _BUCKET_REGION_RE.match(region_line.strip())
451 if region_match:
452 region = region_match.group("region").strip()
453 logger.info("Bucket[%s] is in %s (checking for %s)", bucket_name,
454 region, _DEFAULT_BUCKET_REGION)
455 if region == _DEFAULT_BUCKET_REGION:
456 return True
457 return False
458 raise errors.ParseBucketRegionError("Could not determine bucket region.")
459
460 @staticmethod
461 def _CreateBucket(bucket_name, gcloud_runner):
462 """Create new storage bucket in project.
463
464 Args:
465 bucket_name: String, name of storage bucket.
466 gcloud_runner: A GcloudRunner class to run "gsutil" command.
467 """
468 gcloud_runner.RunGsutil(["mb", "%s" % (_BUCKET_HEADER + bucket_name)])
469 logger.info("Create bucket [%s].", bucket_name)
470
471 @staticmethod
472 def _EnableGcloudServices(gcloud_runner):
473 """Enable 3 Gcloud API services.
474
475 1. Android build service
476 2. Compute engine service
477 3. Google cloud storage service
478 To avoid confuse user, we don't show messages for services processing
479 messages. e.g. "Waiting for async operation operations ...."
480
481 Args:
482 gcloud_runner: A GcloudRunner class to run "gcloud" command.
483 """
484 for service in _GOOGLE_APIS:
485 gcloud_runner.RunGcloud(["services", "enable", service],
486 stderr=subprocess.STDOUT)