blob: f50b2c0cf9f407c4916e5b83a33d529bba83faf6 [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
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)
239 finally:
240 google_sdk_init.CleanUp()
241
242 def _NeedProjectSetup(self):
243 """Confirm project setup should run or not.
244
245 If the project settings (project name and zone) are blank (either one),
246 we'll run the project setup flow. If they are set, we'll check with
247 the user if they want to update them.
248
249 Returns:
250 Boolean: True if we need to setup the project, False otherwise.
251 """
252 user_question = (
253 "Your default Project/Zone settings are:\n"
254 "project:[%s]\n"
255 "zone:[%s]\n"
256 "Would you like to update them? [y/n]\n") % (self.project, self.zone)
257
258 if not self.project or not self.zone:
259 logger.info("Project or zone is empty. Start to run setup process.")
260 return True
261 return utils.GetUserAnswerYes(user_question)
262
263 def _NeedClientIDSetup(self, project_changed):
264 """Confirm client setup should run or not.
265
266 If project changed, client ID must also have to change.
267 So tool will force to run setup function.
268 If client ID or client secret is empty, tool force to run setup function.
269 If project didn't change and config hold user client ID/secret, tool
270 would skip client ID setup.
271
272 Args:
273 project_changed: Boolean, True for project changed.
274
275 Returns:
276 Boolean: True for run setup function.
277 """
278 if project_changed:
279 logger.info("Your project changed. Start to run setup process.")
280 return True
281 elif not self.client_id or not self.client_secret:
282 logger.info("Client ID or client secret is empty. Start to run setup process.")
283 return True
284 logger.info("Project was unchanged and client ID didn't need to changed.")
285 return False
286
287 def _SetupProject(self, gcloud_runner):
288 """Setup gcloud project information.
289
290 Setup project and zone.
291 Setup client ID and client secret.
292 Setup Google Cloud Storage bucket.
293
294 Args:
295 gcloud_runner: A GcloudRunner class to run "gcloud" command.
296 """
297 project_changed = False
298 if self._NeedProjectSetup():
299 project_changed = self._UpdateProject(gcloud_runner)
300 if self._NeedClientIDSetup(project_changed):
301 self._SetupClientIDSecret()
302 self._SetupStorageBucket(gcloud_runner)
303
304 def _UpdateProject(self, gcloud_runner):
305 """Setup gcloud project name and zone name and check project changed.
306
307 Run "gcloud init" to handle gcloud project setup.
308 Then "gcloud list" to get user settings information include "project" & "zone".
309 Record project_changed for next setup steps.
310
311 Args:
312 gcloud_runner: A GcloudRunner class to run "gcloud" command.
313
314 Returns:
315 project_changed: True for project settings changed.
316 """
317 project_changed = False
318 gcloud_runner.RunGcloud(["init"])
319 gcp_config_list_out = gcloud_runner.RunGcloud(["config", "list"])
320 for line in gcp_config_list_out.splitlines():
321 project_match = _PROJECT_RE.match(line)
322 if project_match:
323 project = project_match.group("project")
324 project_changed = (self.project != project)
325 self.project = project
326 continue
327 zone_match = _ZONE_RE.match(line)
328 if zone_match:
329 self.zone = zone_match.group("zone")
330 continue
331 UpdateConfigFile(self.config_path, "project", self.project)
332 UpdateConfigFile(self.config_path, "zone", self.zone)
333 return project_changed
334
335 def _SetupClientIDSecret(self):
336 """Setup Client ID / Client Secret in config file.
337
338 User can use input new values for Client ID and Client Secret.
339 """
340 print("Please generate a new client ID/secret by following the instructions here:")
341 print("https://support.google.com/cloud/answer/6158849?hl=en")
342 # TODO: Create markdown readme instructions since the link isn't too helpful.
343 self.client_id = None
344 self.client_secret = None
345 while _InputIsEmpty(self.client_id):
346 self.client_id = str(raw_input("Enter Client ID: ").strip())
347 while _InputIsEmpty(self.client_secret):
348 self.client_secret = str(raw_input("Enter Client Secret: ").strip())
349 UpdateConfigFile(self.config_path, "client_id", self.client_id)
350 UpdateConfigFile(self.config_path, "client_secret", self.client_secret)
351
352 def _SetupStorageBucket(self, gcloud_runner):
353 """Setup storage_bucket_name in config file.
354
355 We handle the following cases:
356 1. Bucket set in the config && bucket is valid.
357 - Configure the bucket.
358 2. Bucket set in the config && bucket is invalid.
359 - Create a default acloud bucket and configure it
360 3. Bucket is not set in the config.
361 - Create a default acloud bucket and configure it.
362
363 Args:
364 gcloud_runner: A GcloudRunner class to run "gsutil" command.
365 """
366 if (not self.storage_bucket_name
367 or not self._BucketIsValid(self.storage_bucket_name, gcloud_runner)):
368 self.storage_bucket_name = self._CreateDefaultBucket(gcloud_runner)
369 self._ConfigureBucket(gcloud_runner)
370 UpdateConfigFile(self.config_path, "storage_bucket_name",
371 self.storage_bucket_name)
372 logger.info("Storage bucket name set to [%s]", self.storage_bucket_name)
373
374 def _ConfigureBucket(self, gcloud_runner):
375 """Setup write access right for Android Build service account.
376
377 To avoid confuse user, we don't show messages for processing messages.
378 e.g. "No changes to gs://acloud-bucket/"
379
380 Args:
381 gcloud_runner: A GcloudRunner class to run "gsutil" command.
382 """
383 gcloud_runner.RunGsutil([
384 "acl", "ch", "-u",
385 "%s:W" % (_BUILD_SERVICE_ACCOUNT),
386 "%s" % (_BUCKET_HEADER + self.storage_bucket_name)
387 ], stderr=subprocess.STDOUT)
388
389 def _BucketIsValid(self, bucket_name, gcloud_runner):
390 """Check bucket is valid or not.
391
392 If bucket exists and region is in default region,
393 then this bucket is valid.
394
395 Args:
396 bucket_name: String, name of storage bucket.
397 gcloud_runner: A GcloudRunner class to run "gsutil" command.
398
399 Returns:
400 Boolean: True if bucket is valid, otherwise False.
401 """
402 return (self._BucketExists(bucket_name, gcloud_runner) and
403 self._BucketInDefaultRegion(bucket_name, gcloud_runner))
404
405 def _CreateDefaultBucket(self, gcloud_runner):
406 """Setup bucket to default bucket name.
407
408 Default bucket name is "acloud-{project}".
409 If default bucket exist and its region is not "US",
410 then default bucket name is changed as "acloud-{project}-us"
411 If default bucket didn't exist, tool will create it.
412
413 Args:
414 gcloud_runner: A GcloudRunner class to run "gsutil" command.
415
416 Returns:
417 String: string of bucket name.
418 """
419 bucket_name = "%s-%s" % (_DEFAULT_BUCKET_HEADER, self.project)
420 if (self._BucketExists(bucket_name, gcloud_runner) and
421 not self._BucketInDefaultRegion(bucket_name, gcloud_runner)):
422 bucket_name += ("-" + _DEFAULT_BUCKET_REGION.lower())
423 if not self._BucketExists(bucket_name, gcloud_runner):
424 self._CreateBucket(bucket_name, gcloud_runner)
425 return bucket_name
426
427 @staticmethod
428 def _BucketExists(bucket_name, gcloud_runner):
429 """Confirm bucket exist in project or not.
430
431 Args:
432 bucket_name: String, name of storage bucket.
433 gcloud_runner: A GcloudRunner class to run "gsutil" command.
434
435 Returns:
436 Boolean: True for bucket exist in project.
437 """
438 output = gcloud_runner.RunGsutil(["list"])
439 for output_line in output.splitlines():
440 match = _BUCKET_RE.match(output_line)
441 if match.group("bucket") == bucket_name:
442 return True
443 return False
444
445 @staticmethod
446 def _BucketInDefaultRegion(bucket_name, gcloud_runner):
447 """Confirm bucket region settings is "US" or not.
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 for bucket region is in default region.
455
456 Raises:
457 errors.SetupError: For parsing bucket region information error.
458 """
459 output = gcloud_runner.RunGsutil(
460 ["ls", "-L", "-b", "%s" % (_BUCKET_HEADER + bucket_name)])
461 for region_line in output.splitlines():
462 region_match = _BUCKET_REGION_RE.match(region_line.strip())
463 if region_match:
464 region = region_match.group("region").strip()
465 logger.info("Bucket[%s] is in %s (checking for %s)", bucket_name,
466 region, _DEFAULT_BUCKET_REGION)
467 if region == _DEFAULT_BUCKET_REGION:
468 return True
469 return False
470 raise errors.ParseBucketRegionError("Could not determine bucket region.")
471
472 @staticmethod
473 def _CreateBucket(bucket_name, gcloud_runner):
474 """Create new storage bucket in project.
475
476 Args:
477 bucket_name: String, name of storage bucket.
478 gcloud_runner: A GcloudRunner class to run "gsutil" command.
479 """
480 gcloud_runner.RunGsutil(["mb", "%s" % (_BUCKET_HEADER + bucket_name)])
481 logger.info("Create bucket [%s].", bucket_name)
482
483 @staticmethod
484 def _EnableGcloudServices(gcloud_runner):
485 """Enable 3 Gcloud API services.
486
487 1. Android build service
488 2. Compute engine service
489 3. Google cloud storage service
490 To avoid confuse user, we don't show messages for services processing
491 messages. e.g. "Waiting for async operation operations ...."
492
493 Args:
494 gcloud_runner: A GcloudRunner class to run "gcloud" command.
495 """
496 for service in _GOOGLE_APIS:
497 gcloud_runner.RunGcloud(["services", "enable", service],
498 stderr=subprocess.STDOUT)