• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/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_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")
45# 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 = ":"
52# 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        # pylint: disable=invalid-name
196        config_mgr = config.AcloudConfigManager(config_path)
197        cfg = config_mgr.Load()
198        self.config_path = config_mgr.user_config_path
199        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
204        self.stable_host_image_name = cfg.stable_host_image_name
205        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
210
211    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        """
219        # 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
231
232    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)
252            self._CreateStableHostImage()
253        finally:
254            google_sdk_init.CleanUp()
255
256    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
264    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"
278            "Would you like to update them?[y/N]: \n") % (self.project, self.zone)
279
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        """
441        bucket_name = self._GenerateBucketName(self.project)
442        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
450    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
487    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)
558