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