1# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License"). You
4# may not use this file except in compliance with the License. A copy of
5# the License is located at
6#
7#     http://aws.amazon.com/apache2.0/
8#
9# or in the "license" file accompanying this file. This file is
10# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11# ANY KIND, either express or implied. See the License for the specific
12# language governing permissions and limitations under the License.
13import datetime
14import json
15import logging
16import os
17import platform
18import re
19import shlex
20import socket
21import subprocess
22import tempfile
23import textwrap
24
25from botocore.exceptions import ClientError
26
27from awscli.compat import shlex_quote, urlopen, ensure_text_type
28from awscli.customizations.commands import BasicCommand
29from awscli.customizations.utils import create_client_from_parsed_globals
30
31
32LOG = logging.getLogger(__name__)
33
34IAM_USER_POLICY_NAME = "OpsWorks-Instance"
35IAM_USER_POLICY_TIMEOUT = datetime.timedelta(minutes=15)
36IAM_PATH = '/AWS/OpsWorks/'
37IAM_POLICY_ARN = 'arn:aws:iam::aws:policy/AWSOpsWorksInstanceRegistration'
38
39HOSTNAME_RE = re.compile(r"^(?!-)[a-z0-9-]{1,63}(?<!-)$", re.I)
40INSTANCE_ID_RE = re.compile(r"^i-[0-9a-f]+$")
41IP_ADDRESS_RE = re.compile(r"^\d+\.\d+\.\d+\.\d+$")
42
43IDENTITY_URL = \
44    "http://169.254.169.254/latest/dynamic/instance-identity/document"
45
46REMOTE_SCRIPT = """
47set -e
48umask 007
49AGENT_TMP_DIR=$(mktemp -d /tmp/opsworks-agent-installer.XXXXXXXXXXXXXXXX)
50curl --retry 5 -L %(agent_installer_url)s | tar xz -C $AGENT_TMP_DIR
51cat >$AGENT_TMP_DIR/opsworks-agent-installer/preconfig <<EOF
52%(preconfig)s
53EOF
54exec sudo /bin/sh -c "\
55OPSWORKS_ASSETS_DOWNLOAD_BUCKET=%(assets_download_bucket)s \
56$AGENT_TMP_DIR/opsworks-agent-installer/boot-registration; \
57rm -rf $AGENT_TMP_DIR"
58""".lstrip()
59
60
61def initialize(cli):
62    cli.register('building-command-table.opsworks', inject_commands)
63
64
65def inject_commands(command_table, session, **kwargs):
66    command_table['register'] = OpsWorksRegister(session)
67
68
69class OpsWorksRegister(BasicCommand):
70    NAME = "register"
71    DESCRIPTION = textwrap.dedent("""
72        Registers an EC2 instance or machine with AWS OpsWorks.
73
74        Registering a machine using this command will install the AWS OpsWorks
75        agent on the target machine and register it with an existing OpsWorks
76        stack.
77    """).strip()
78
79    ARG_TABLE = [
80        {'name': 'stack-id', 'required': True,
81         'help_text': """A stack ID. The instance will be registered with the
82                         given stack."""},
83        {'name': 'infrastructure-class', 'required': True,
84         'choices': ['ec2', 'on-premises'],
85         'help_text': """Specifies whether to register an EC2 instance (`ec2`)
86                         or an on-premises instance (`on-premises`)."""},
87        {'name': 'override-hostname', 'dest': 'hostname',
88         'help_text': """The instance hostname. If not provided, the current
89                         hostname of the machine will be used."""},
90        {'name': 'override-private-ip', 'dest': 'private_ip',
91         'help_text': """An IP address. If you set this parameter, the given IP
92                         address will be used as the private IP address within
93                         OpsWorks.  Otherwise the private IP address will be
94                         determined automatically. Not to be used with EC2
95                         instances."""},
96        {'name': 'override-public-ip', 'dest': 'public_ip',
97         'help_text': """An IP address. If you set this parameter, the given IP
98                         address will be used as the public IP address within
99                         OpsWorks.  Otherwise the public IP address will be
100                         determined automatically. Not to be used with EC2
101                         instances."""},
102        {'name': 'override-ssh', 'dest': 'ssh',
103         'help_text': """If you set this parameter, the given command will be
104                         used to connect to the machine."""},
105        {'name': 'ssh-username', 'dest': 'username',
106         'help_text': """If provided, this username will be used to connect to
107                         the host."""},
108        {'name': 'ssh-private-key', 'dest': 'private_key',
109         'help_text': """If provided, the given private key file will be used
110                         to connect to the machine."""},
111        {'name': 'local', 'action': 'store_true',
112         'help_text': """If given, instead of a remote machine, the local
113                         machine will be imported. Cannot be used together
114                         with `target`."""},
115        {'name': 'use-instance-profile', 'action': 'store_true',
116         'help_text': """Use the instance profile instead of creating an IAM
117                         user."""},
118        {'name': 'target', 'positional_arg': True, 'nargs': '?',
119         'synopsis': '[<target>]',
120         'help_text': """Either the EC2 instance ID or the hostname of the
121                         instance or machine to be registered with OpsWorks.
122                         Cannot be used together with `--local`."""},
123    ]
124
125    def __init__(self, session):
126        super(OpsWorksRegister, self).__init__(session)
127        self._stack = None
128        self._ec2_instance = None
129        self._prov_params = None
130        self._use_address = None
131        self._use_hostname = None
132        self._name_for_iam = None
133        self.access_key = None
134
135    def _create_clients(self, args, parsed_globals):
136        self.iam = self._session.create_client('iam')
137        self.opsworks = create_client_from_parsed_globals(
138            self._session, 'opsworks', parsed_globals)
139
140    def _run_main(self, args, parsed_globals):
141        self._create_clients(args, parsed_globals)
142
143        self.prevalidate_arguments(args)
144        self.retrieve_stack(args)
145        self.validate_arguments(args)
146        self.determine_details(args)
147        self.create_iam_entities(args)
148        self.setup_target_machine(args)
149
150    def prevalidate_arguments(self, args):
151        """
152        Validates command line arguments before doing anything else.
153        """
154        if not args.target and not args.local:
155            raise ValueError("One of target or --local is required.")
156        elif args.target and args.local:
157            raise ValueError(
158                "Arguments target and --local are mutually exclusive.")
159
160        if args.local and platform.system() != 'Linux':
161            raise ValueError(
162                "Non-Linux instances are not supported by AWS OpsWorks.")
163
164        if args.ssh and (args.username or args.private_key):
165            raise ValueError(
166                "Argument --override-ssh cannot be used together with "
167                "--ssh-username or --ssh-private-key.")
168
169        if args.infrastructure_class == 'ec2':
170            if args.private_ip:
171                raise ValueError(
172                    "--override-private-ip is not supported for EC2.")
173            if args.public_ip:
174                raise ValueError(
175                    "--override-public-ip is not supported for EC2.")
176
177        if args.infrastructure_class == 'on-premises' and \
178                args.use_instance_profile:
179            raise ValueError(
180                "--use-instance-profile is only supported for EC2.")
181
182        if args.hostname:
183            if not HOSTNAME_RE.match(args.hostname):
184                raise ValueError(
185                    "Invalid hostname: '%s'. Hostnames must consist of "
186                    "letters, digits and dashes only and must not start or "
187                    "end with a dash." % args.hostname)
188
189    def retrieve_stack(self, args):
190        """
191        Retrieves the stack from the API, thereby ensures that it exists.
192
193        Provides `self._stack`, `self._prov_params`, `self._use_address`, and
194        `self._ec2_instance`.
195        """
196
197        LOG.debug("Retrieving stack and provisioning parameters")
198        self._stack = self.opsworks.describe_stacks(
199            StackIds=[args.stack_id]
200        )['Stacks'][0]
201        self._prov_params = \
202            self.opsworks.describe_stack_provisioning_parameters(
203                StackId=self._stack['StackId']
204            )
205
206        if args.infrastructure_class == 'ec2' and not args.local:
207            LOG.debug("Retrieving EC2 instance information")
208            ec2 = self._session.create_client(
209                'ec2', region_name=self._stack['Region'])
210
211            # `desc_args` are arguments for the describe_instances call,
212            # whereas `conditions` is a list of lambdas for further filtering
213            # on the results of the call.
214            desc_args = {'Filters': []}
215            conditions = []
216
217            # make sure that the platforms (EC2/VPC) and VPC IDs of the stack
218            # and the instance match
219            if 'VpcId' in self._stack:
220                desc_args['Filters'].append(
221                    {'Name': 'vpc-id', 'Values': [self._stack['VpcId']]}
222                )
223            else:
224                # Cannot search for non-VPC instances directly, thus filter
225                # afterwards
226                conditions.append(lambda instance: 'VpcId' not in instance)
227
228            # target may be an instance ID, an IP address, or a name
229            if INSTANCE_ID_RE.match(args.target):
230                desc_args['InstanceIds'] = [args.target]
231            elif IP_ADDRESS_RE.match(args.target):
232                # Cannot search for either private or public IP at the same
233                # time, thus filter afterwards
234                conditions.append(
235                    lambda instance:
236                        instance.get('PrivateIpAddress') == args.target or
237                        instance.get('PublicIpAddress') == args.target)
238                # also use the given address to connect
239                self._use_address = args.target
240            else:
241                # names are tags
242                desc_args['Filters'].append(
243                    {'Name': 'tag:Name', 'Values': [args.target]}
244                )
245
246            # find all matching instances
247            instances = [
248                i
249                for r in ec2.describe_instances(**desc_args)['Reservations']
250                for i in r['Instances']
251                if all(c(i) for c in conditions)
252            ]
253
254            if not instances:
255                raise ValueError(
256                    "Did not find any instance matching %s." % args.target)
257            elif len(instances) > 1:
258                raise ValueError(
259                    "Found multiple instances matching %s: %s." % (
260                        args.target,
261                        ", ".join(i['InstanceId'] for i in instances)))
262
263            self._ec2_instance = instances[0]
264
265    def validate_arguments(self, args):
266        """
267        Validates command line arguments using the retrieved information.
268        """
269
270        if args.hostname:
271            instances = self.opsworks.describe_instances(
272                StackId=self._stack['StackId']
273            )['Instances']
274            if any(args.hostname.lower() == instance['Hostname']
275                   for instance in instances):
276                raise ValueError(
277                    "Invalid hostname: '%s'. Hostnames must be unique within "
278                    "a stack." % args.hostname)
279
280        if args.infrastructure_class == 'ec2' and args.local:
281            # make sure the regions match
282            region = json.loads(
283                ensure_text_type(urlopen(IDENTITY_URL).read()))['region']
284            if region != self._stack['Region']:
285                raise ValueError(
286                    "The stack's and the instance's region must match.")
287
288    def determine_details(self, args):
289        """
290        Determine details (like the address to connect to and the hostname to
291        use) from the given arguments and the retrieved data.
292
293        Provides `self._use_address` (if not provided already),
294        `self._use_hostname` and `self._name_for_iam`.
295        """
296
297        # determine the address to connect to
298        if not self._use_address:
299            if args.local:
300                pass
301            elif args.infrastructure_class == 'ec2':
302                if 'PublicIpAddress' in self._ec2_instance:
303                    self._use_address = self._ec2_instance['PublicIpAddress']
304                elif 'PrivateIpAddress' in self._ec2_instance:
305                    LOG.warning(
306                        "Instance does not have a public IP address. Trying "
307                        "to use the private address to connect.")
308                    self._use_address = self._ec2_instance['PrivateIpAddress']
309                else:
310                    # Should never happen
311                    raise ValueError(
312                        "The instance does not seem to have an IP address.")
313            elif args.infrastructure_class == 'on-premises':
314                self._use_address = args.target
315
316        # determine the names to use
317        if args.hostname:
318            self._use_hostname = args.hostname
319            self._name_for_iam = args.hostname
320        elif args.local:
321            self._use_hostname = None
322            self._name_for_iam = socket.gethostname()
323        else:
324            self._use_hostname = None
325            self._name_for_iam = args.target
326
327    def create_iam_entities(self, args):
328        """
329        Creates an IAM group, user and corresponding credentials.
330
331        Provides `self.access_key`.
332        """
333
334        if args.use_instance_profile:
335            LOG.debug("Skipping IAM entity creation")
336            self.access_key = None
337            return
338
339        LOG.debug("Creating the IAM group if necessary")
340        group_name = "OpsWorks-%s" % clean_for_iam(self._stack['StackId'])
341        try:
342            self.iam.create_group(GroupName=group_name, Path=IAM_PATH)
343            LOG.debug("Created IAM group %s", group_name)
344        except ClientError as e:
345            if e.response.get('Error', {}).get('Code') == 'EntityAlreadyExists':
346                LOG.debug("IAM group %s exists, continuing", group_name)
347                # group already exists, good
348                pass
349            else:
350                raise
351
352        # create the IAM user, trying alternatives if it already exists
353        LOG.debug("Creating an IAM user")
354        base_username = "OpsWorks-%s-%s" % (
355            shorten_name(clean_for_iam(self._stack['Name']), 25),
356            shorten_name(clean_for_iam(self._name_for_iam), 25)
357        )
358        for try_ in range(20):
359            username = base_username + ("+%s" % try_ if try_ else "")
360            try:
361                self.iam.create_user(UserName=username, Path=IAM_PATH)
362            except ClientError as e:
363                if e.response.get('Error', {}).get('Code') == 'EntityAlreadyExists':
364                    LOG.debug(
365                        "IAM user %s already exists, trying another name",
366                        username
367                    )
368                    # user already exists, try the next one
369                    pass
370                else:
371                    raise
372            else:
373                LOG.debug("Created IAM user %s", username)
374                break
375        else:
376            raise ValueError("Couldn't find an unused IAM user name.")
377
378        LOG.debug("Adding the user to the group and attaching a policy")
379        self.iam.add_user_to_group(GroupName=group_name, UserName=username)
380
381        try:
382            self.iam.attach_user_policy(
383                PolicyArn=IAM_POLICY_ARN,
384                UserName=username
385            )
386        except ClientError as e:
387            if e.response.get('Error', {}).get('Code') == 'AccessDenied':
388                LOG.debug(
389                    "Unauthorized to attach policy %s to user %s. Trying "
390                    "to put user policy",
391                    IAM_POLICY_ARN,
392                    username
393                )
394                self.iam.put_user_policy(
395                    PolicyName=IAM_USER_POLICY_NAME,
396                    PolicyDocument=self._iam_policy_document(
397                        self._stack['Arn'], IAM_USER_POLICY_TIMEOUT),
398                    UserName=username
399                )
400                LOG.debug(
401                    "Put policy %s to user %s",
402                    IAM_USER_POLICY_NAME,
403                    username
404                )
405            else:
406                raise
407        else:
408            LOG.debug(
409                "Attached policy %s to user %s",
410                IAM_POLICY_ARN,
411                username
412            )
413
414        LOG.debug("Creating an access key")
415        self.access_key = self.iam.create_access_key(
416            UserName=username
417        )['AccessKey']
418
419    def setup_target_machine(self, args):
420        """
421        Setups the target machine by copying over the credentials and starting
422        the installation process.
423        """
424
425        remote_script = REMOTE_SCRIPT % {
426            'agent_installer_url':
427                self._prov_params['AgentInstallerUrl'],
428            'preconfig':
429                self._to_ruby_yaml(self._pre_config_document(args)),
430            'assets_download_bucket':
431                self._prov_params['Parameters']['assets_download_bucket']
432        }
433
434        if args.local:
435            LOG.debug("Running the installer locally")
436            subprocess.check_call(["/bin/sh", "-c", remote_script])
437        else:
438            LOG.debug("Connecting to the target machine to run the installer.")
439            self.ssh(args, remote_script)
440
441    def ssh(self, args, remote_script):
442        """
443        Runs a (sh) script on a remote machine via SSH.
444        """
445
446        if platform.system() == 'Windows':
447            try:
448                script_file = tempfile.NamedTemporaryFile("wt", delete=False)
449                script_file.write(remote_script)
450                script_file.close()
451                if args.ssh:
452                    call = args.ssh
453                else:
454                    call = 'plink'
455                    if args.username:
456                        call += ' -l "%s"' % args.username
457                    if args.private_key:
458                        call += ' -i "%s"' % args.private_key
459                    call += ' "%s"' % self._use_address
460                    call += ' -m'
461                call += ' "%s"' % script_file.name
462
463                subprocess.check_call(call, shell=True)
464            finally:
465                os.remove(script_file.name)
466        else:
467            if args.ssh:
468                call = shlex.split(str(args.ssh))
469            else:
470                call = ['ssh', '-tt']
471                if args.username:
472                    call.extend(['-l', args.username])
473                if args.private_key:
474                    call.extend(['-i', args.private_key])
475                call.append(self._use_address)
476
477            remote_call = ["/bin/sh", "-c", remote_script]
478            call.append(" ".join(shlex_quote(word) for word in remote_call))
479            subprocess.check_call(call)
480
481    def _pre_config_document(self, args):
482        parameters = dict(
483            stack_id=self._stack['StackId'],
484            **self._prov_params["Parameters"]
485        )
486        if self.access_key:
487            parameters['access_key_id'] = self.access_key['AccessKeyId']
488            parameters['secret_access_key'] = \
489                self.access_key['SecretAccessKey']
490        if self._use_hostname:
491            parameters['hostname'] = self._use_hostname
492        if args.private_ip:
493            parameters['private_ip'] = args.private_ip
494        if args.public_ip:
495            parameters['public_ip'] = args.public_ip
496        parameters['import'] = args.infrastructure_class == 'ec2'
497        LOG.debug("Using pre-config: %r", parameters)
498        return parameters
499
500    @staticmethod
501    def _iam_policy_document(arn, timeout=None):
502        statement = {
503            "Action": "opsworks:RegisterInstance",
504            "Effect": "Allow",
505            "Resource": arn,
506        }
507        if timeout is not None:
508            valid_until = datetime.datetime.utcnow() + timeout
509            statement["Condition"] = {
510                "DateLessThan": {
511                    "aws:CurrentTime":
512                        valid_until.strftime("%Y-%m-%dT%H:%M:%SZ")
513                }
514            }
515        policy_document = {
516            "Statement": [statement],
517            "Version": "2012-10-17"
518        }
519        return json.dumps(policy_document)
520
521    @staticmethod
522    def _to_ruby_yaml(parameters):
523        return "\n".join(":%s: %s" % (k, json.dumps(v))
524                         for k, v in sorted(parameters.items()))
525
526
527def clean_for_iam(name):
528    """
529    Cleans a name to fit IAM's naming requirements.
530    """
531
532    return re.sub(r'[^A-Za-z0-9+=,.@_-]+', '-', name)
533
534
535def shorten_name(name, max_length):
536    """
537    Shortens a name to the given number of characters.
538    """
539
540    if len(name) <= max_length:
541        return name
542    q, r = divmod(max_length - 3, 2)
543    return name[:q + r] + "..." + name[-q:]
544