~crunch.io/ubuntu/precise/awscli/unstable

« back to all changes in this revision

Viewing changes to awscli/customizations/opsworks.py

  • Committer: Package Import Robot
  • Author(s): TANIGUCHI Takaki
  • Date: 2015-01-13 23:09:28 UTC
  • mfrom: (3.1.5 sid)
  • Revision ID: package-import@ubuntu.com-20150113230928-uc9exkzqb17ogig0
Tags: 1.7.0-1
New upstream release

Show diffs side-by-side

added added

removed removed

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