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