1
# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
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
7
# http://aws.amazon.com/apache2.0/
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.
25
from awscli.compat import shlex_quote, urlopen
26
from awscli.customizations.commands import BasicCommand
27
from awscli.errorhandler import ClientError
30
LOG = logging.getLogger(__name__)
32
IAM_USER_POLICY_NAME = "OpsWorks-Instance"
33
IAM_USER_POLICY_TIMEOUT = datetime.timedelta(minutes=15)
34
IAM_PATH = '/AWS/OpsWorks/'
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+$")
41
"http://169.254.169.254/latest/dynamic/instance-identity/document"
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
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"
59
cli.register('building-command-table.opsworks', inject_commands)
62
def inject_commands(command_table, session, **kwargs):
63
command_table['register'] = OpsWorksRegister(session)
66
class OpsWorksRegister(BasicCommand):
68
DESCRIPTION = textwrap.dedent("""
69
Registers an EC2 instance or machine with AWS OpsWorks.
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
75
EXAMPLES = BasicCommand.FROM_FILE('opsworks/register.rst')
78
{'name': 'stack-id', 'required': True,
79
'help_text': """A stack ID. The instance will be registered with the
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
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
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
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
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`."""},
120
def __init__(self, session):
121
super(OpsWorksRegister, self).__init__(session)
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
129
def _create_clients(self, args, parsed_globals):
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)
139
def _run_main(self, args, parsed_globals):
140
self._create_clients(args, parsed_globals)
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)
149
def prevalidate_arguments(self, args):
151
Validates command line arguments before doing anything else.
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:
157
"Arguments target and --local are mutually exclusive.")
159
if args.local and sys.platform != 'linux2':
161
"Non-Linux instances are not supported by AWS OpsWorks.")
163
if args.ssh and (args.username or args.private_key):
165
"Argument --override-ssh cannot be used together with "
166
"--ssh-username or --ssh-private-key.")
168
if args.infrastructure_class == 'ec2':
171
"--override-private-ip is not supported for EC2.")
174
"--override-public-ip is not supported for EC2.")
177
if not HOSTNAME_RE.match(args.hostname):
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)
183
def retrieve_stack(self, args):
185
Retrieves the stack from the API, thereby ensures that it exists.
187
Provides `self._stack`, `self._prov_params`, `self._use_address`, and
188
`self._ec2_instance`.
191
LOG.debug("Retrieving stack and provisioning parameters")
192
self._stack = self.opsworks.describe_stacks(
193
StackIds=[args.stack_id]
195
self._prov_params = \
196
self.opsworks.describe_stack_provisioning_parameters(
197
StackId=self._stack['StackId']
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'])
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': []}
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']]}
218
# Cannot search for non-VPC instances directly, thus filter
220
conditions.append(lambda instance: 'VpcId' not in instance)
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
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
236
desc_args['Filters'].append(
237
{'Name': 'tag:Name', 'Values': [args.target]}
240
# find all matching instances
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)
250
"Did not find any instance matching %s." % args.target)
251
elif len(instances) > 1:
253
"Found multiple instances matching %s: %s." % (
255
", ".join(i['InstanceId'] for i in instances)))
257
self._ec2_instance = instances[0]
259
def validate_arguments(self, args):
261
Validates command line arguments using the retrieved information.
265
instances = self.opsworks.describe_instances(
266
StackId=self._stack['StackId']
268
if any(args.hostname.lower() == instance['Hostname']
269
for instance in instances):
271
"Invalid hostname: '%s'. Hostnames must be unique within "
272
"a stack." % args.hostname)
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']:
279
"The stack's and the instance's region must match.")
281
def determine_details(self, args):
283
Determine details (like the address to connect to and the hostname to
284
use) from the given arguments and the retrieved data.
286
Provides `self._use_address` (if not provided already),
287
`self._use_hostname` and `self._name_for_iam`.
290
# determine the address to connect to
291
if not self._use_address:
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:
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']
303
# Should never happen
305
"The instance does not seem to have an IP address.")
306
elif args.infrastructure_class == 'on-premises':
307
self._use_address = args.target
309
# determine the names to use
311
self._use_hostname = args.hostname
312
self._name_for_iam = args.hostname
314
self._use_hostname = None
315
self._name_for_iam = socket.gethostname()
317
self._use_hostname = None
318
self._name_for_iam = args.target
320
def create_iam_entities(self):
322
Creates an IAM group, user and corresponding credentials.
324
Provides `self.access_key`.
327
LOG.debug("Creating the IAM group if necessary")
328
group_name = "OpsWorks-%s" % clean_for_iam(self._stack['StackId'])
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
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)
346
for try_ in range(20):
347
username = base_username + ("+%s" % try_ if try_ else "")
349
self.iam.create_user(UserName=username, Path=IAM_PATH)
350
except ClientError as e:
351
if e.error_code == 'EntityAlreadyExists':
353
"IAM user %s already exists, trying another name",
356
# user already exists, try the next one
361
LOG.debug("Created IAM user %s", username)
364
raise ValueError("Couldn't find an unused IAM user name.")
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),
375
LOG.debug("Creating an access key")
376
self.access_key = self.iam.create_access_key(
380
def setup_target_machine(self, args):
382
Setups the target machine by copying over the credentials and starting
383
the installation process.
386
remote_script = REMOTE_SCRIPT % {
387
'agent_installer_url':
388
self._prov_params['AgentInstallerUrl'],
390
self._to_ruby_yaml(self._pre_config_document(args)),
391
'assets_download_bucket':
392
self._prov_params['Parameters']['assets_download_bucket']
396
LOG.debug("Running the installer locally")
397
subprocess.check_call(["/bin/sh", "-c", remote_script])
399
LOG.debug("Connecting to the target machine to run the installer.")
400
self.ssh(args, remote_script)
402
def ssh(self, args, remote_script):
404
Runs a (sh) script on a remote machine via SSH.
407
if sys.platform == 'win32':
409
script_file = tempfile.NamedTemporaryFile("wt", delete=False)
410
script_file.write(remote_script)
417
call += ' -l "%s"' % args.username
419
call += ' -i "%s"' % args.private_key
420
call += ' "%s"' % self._use_address
422
call += ' "%s"' % script_file.name
424
subprocess.check_call(call, shell=True)
426
os.remove(script_file.name)
429
call = shlex.split(str(args.ssh))
431
call = ['ssh', '-tt']
433
call.extend(['-l', args.username])
435
call.extend(['-i', args.private_key])
436
call.append(self._use_address)
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)
442
def _pre_config_document(self, args):
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"]
449
if self._use_hostname:
450
parameters['hostname'] = self._use_hostname
452
parameters['private_ip'] = args.private_ip
454
parameters['public_ip'] = args.public_ip
455
parameters['import'] = args.infrastructure_class == 'ec2'
456
LOG.debug("Using pre-config: %r", parameters)
460
def _iam_policy_document(arn, timeout=None):
462
"Action": "opsworks:RegisterInstance",
466
if timeout is not None:
467
valid_until = datetime.datetime.utcnow() + timeout
468
statement["Condition"] = {
471
valid_until.strftime("%Y-%m-%dT%H:%M:%SZ")
475
"Statement": [statement],
476
"Version": "2012-10-17"
478
return json.dumps(policy_document)
481
def _to_ruby_yaml(parameters):
482
return "\n".join(":%s: %s" % (k, json.dumps(v))
483
for k, v in sorted(parameters.items()))
486
def clean_for_iam(name):
488
Cleans a name to fit IAM's naming requirements.
491
return re.sub(r'[^A-Za-z0-9+=,.@_-]+', '-', name)
494
def shorten_name(name, max_length):
496
Shortens a name to the given number of characters.
499
if len(name) <= max_length:
501
q, r = divmod(max_length - 3, 2)
502
return name[:q + r] + "..." + name[-q:]