github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/acceptancetests/assess_network_spaces.py (about) 1 #!/usr/bin/env python 2 """Assess network spaces for supported providers (currently only EC2)""" 3 4 import argparse 5 import logging 6 import sys 7 import json 8 import yaml 9 import subprocess 10 import re 11 import ipaddress 12 import boto3 13 14 from jujupy.exceptions import ( 15 ProvisioningError 16 ) 17 from deploy_stack import ( 18 BootstrapManager 19 ) 20 from utility import ( 21 add_basic_testing_arguments, 22 configure_logging, 23 ) 24 25 __metaclass__ = type 26 27 log = logging.getLogger("assess_network_spaces") 28 29 30 class AssessNetworkSpaces: 31 32 def assess_network_spaces(self, client, series=None): 33 """Assesses network spaces 34 35 :param client: The juju client in use 36 :param series: Ubuntu series to deploy 37 """ 38 self.setup_testing_environment(client, series) 39 log.info('Starting spaces tests.') 40 self.testing_iterations(client) 41 # if we get here, tests succeeded 42 log.info('SUCESS') 43 return 44 45 def testing_iterations(self, client): 46 """Verify that spaces are set up proper and functioning 47 48 :param client: Juju client object with machines and spaces 49 """ 50 alltests = [ 51 self.assert_machines_in_correct_spaces, 52 self.assert_machine_connectivity, 53 self.assert_internet_connection, 54 # Do this one last so the failed container doesn't 55 # interfere with the other tests. 56 self.assert_add_container_with_wrong_space_errs, 57 ] 58 59 fail_messages = [] 60 for test in alltests: 61 try: 62 test(client) 63 except TestFailure as e: 64 fail_messages.append(e.message) 65 log.info('FAILED: ' + e.message + '\n') 66 67 log.info('Tests complete.') 68 if fail_messages: 69 raise TestFailure('\n'.join(fail_messages)) 70 71 def setup_testing_environment(self, client, series=None): 72 """Sets up the testing environment 73 74 :param client: The juju client in use 75 """ 76 log.info("Setting up test environment.") 77 self.assign_spaces(client) 78 # add machines for spaces testing 79 self.deploy_spaces_machines(client, series) 80 81 def assign_spaces(self, client): 82 """Assigns spaces to subnets 83 Name the spaces sequentially: space1, space2, space3, etc. 84 We require at least 3 spaces. 85 86 :param client: Juju client object with controller 87 """ 88 log.info('Assigning network spaces on {}.'.format(client.env.provider)) 89 subnets = yaml.safe_load( 90 client.get_juju_output('list-subnets', '--format=yaml')) 91 if not subnets: 92 raise SubnetsNotReady( 93 'No subnets defined in {}'.format(client.env.provider)) 94 subnet_count = 0 95 for subnet in non_infan_subnets(subnets)['subnets'].keys(): 96 subnet_count += 1 97 client.juju('add-space', ('space{}'.format(subnet_count), subnet)) 98 if subnet_count < 3: 99 raise SubnetsNotReady( 100 '3 subnets required for spaces assignment. ' 101 '{} found.'.format(subnet_count)) 102 103 def assert_machines_in_correct_spaces(self, client): 104 """Check all the machines to verify they are in the expected spaces 105 We should have 4 machines in 3 spaces 106 0 and 1 in space1 107 2 in space2 108 3 in space3 109 110 :param client: Juju client object with machines and spaces 111 """ 112 log.info('Assessing machines are in the correct spaces.') 113 machines = yaml.safe_load( 114 client.get_juju_output( 115 'list-machines', '--format=yaml'))['machines'] 116 for machine in machines.keys(): 117 log.info('Checking network space for Machine {}'.format(machine)) 118 if machine == '0': 119 expected_space = 'space1' 120 else: 121 expected_space = 'space{}'.format(machine) 122 ip = get_machine_ip_in_space(client, machine, expected_space) 123 if not ip: 124 raise TestFailure( 125 'Machine {machine} has NO IPs in ' 126 '{space}'.format( 127 machine=machine, 128 space=expected_space)) 129 log.info('PASSED') 130 131 def assert_machine_connectivity(self, client): 132 """Check to make sure machines in the same space can ping 133 and that machines in different spaces cannot. 134 Machines 0 and 1 are in space1. Ping should succeed. 135 Machines 2 and 3 are in space2 and space3. Ping should succeed. 136 We don't currently have access control between spaces. 137 In the future, pinging between different spaces may be 138 restrictable. 139 140 :param client: Juju client object with machines and spaces 141 """ 142 log.info('Assessing interconnectivity between machines.') 143 # try 0 to 1 144 log.info('Testing ping from Machine 0 to Machine 1 (same space)') 145 ip_to_ping = get_machine_ip_in_space(client, '1', 'space1') 146 if not machine_can_ping_ip(client, '0', ip_to_ping): 147 raise TestFailure('Ping from 0 to 1 Failed.') 148 # try 2 to 3 149 log.info('Testing ping from Machine 2 to Machine 3 (diff spaces)') 150 ip_to_ping = get_machine_ip_in_space(client, '3', 'space3') 151 if not machine_can_ping_ip(client, '2', ip_to_ping): 152 raise TestFailure('Ping from 2 to 3 Failed.') 153 log.info('PASSED') 154 155 def assert_add_container_with_wrong_space_errs(self, client): 156 """If we attempt to add a container with a space constraint to a 157 machine that already has a space, if the spaces don't match, it 158 will fail. 159 160 :param client: Juju client object with machines and spaces 161 """ 162 log.info('Assessing adding container with wrong space fails.') 163 # add container on machine 2 with space1 164 try: 165 client.juju( 166 'add-machine', ('lxd:2', '--constraints', 'spaces=space1')) 167 client.wait_for_started() 168 machine = client.show_machine('2')['machines'][0] 169 container = machine['containers']['2/lxd/0'] 170 if container['juju-status']['current'] == 'started': 171 raise TestFailure( 172 'Encountered no conflict when launching a container ' 173 'on a machine with a different spaces constraint.') 174 except ProvisioningError: 175 log.info('Container correctly failed to provision.') 176 finally: 177 # clean up container 178 try: 179 # this doesn't seem to wait for removal 180 client.wait_for(client.remove_machine('2/lxd/0', force=True)) 181 except Exception: 182 pass 183 log.info('PASSED') 184 185 def assert_internet_connection(self, client): 186 """Test that targets can ping their default route. 187 188 :param client: Juju client 189 """ 190 log.info('Assessing internet connection.') 191 for unit in client.get_status().iter_machines(containers=False): 192 log.info("Assessing internet connection for " 193 "machine: {}".format(unit[0])) 194 try: 195 routes = client.run(['ip route show'], machines=[unit[0]]) 196 except subprocess.CalledProcessError: 197 raise TestFailure( 198 'Could not connect to address for unit: {0}, ' 199 'unable to find default route.'.format(unit[0])) 200 default_route = re.search(r'(default via )+([\d\.]+)\s+', 201 json.dumps(routes[0])) 202 if not default_route: 203 raise TestFailure( 204 'Default route not found for {}'.format(unit[0])) 205 log.info('PASSED') 206 207 def deploy_spaces_machines(self, client, series=None): 208 """Add machines to test spaces. 209 First two machines in the same space, the rest in subsequent spaces. 210 211 :param client: Juju client object with bootstrapped controller 212 :param series: Ubuntu series to deploy 213 """ 214 log.info("Adding 4 machines") 215 for space in [1, 1, 2, 3]: 216 client.juju( 217 'add-machine', ( 218 '--series={}'.format(series), 219 '--constraints', 'spaces=space{}'.format(space))) 220 client.wait_for_started() 221 222 223 class SubnetsNotReady(Exception): 224 pass 225 226 227 class TestFailure(Exception): 228 pass 229 230 231 def non_infan_subnets(subnets): 232 """Returns all subnets that don't have INFAN in the provider-id 233 Subnets with INFAN in the provider-id may be inherited from underlay 234 and therefore cannot be assigned to a space. 235 236 :param subnets: A dict of subnets or spaces as returned by 237 juju list-subnets or juju list-spaces 238 239 Example dict output from juju list-subnets: 240 "subnets": { 241 "172.31.0.0/20": { 242 "provider-id": "subnet-38f9d07e", 243 "provider-network-id": "vpc-1f40b47a", 244 "space": "", 245 "status": "in-use", 246 "type": "ipv4", 247 "zones": [ 248 "us-east-1a" 249 ] 250 } 251 } 252 Example dict output from juju list-spaces: 253 "spaces": { 254 "space1": { 255 "172.31.16.0/20": { 256 "provider-id": "subnet-13a6aa67", 257 "status": "in-use", 258 "type": "ipv4", 259 "zones": [ 260 "us-east=1d" 261 ] 262 } 263 } 264 } 265 """ 266 newsubnets = {} 267 if 'subnets' in subnets: 268 newsubnets['subnets'] = {} 269 for subnet, details in subnets['subnets'].iteritems(): 270 if 'INFAN' not in details['provider-id']: 271 newsubnets['subnets'][subnet] = details 272 if 'spaces' in subnets: 273 newsubnets['spaces'] = {} 274 for space, details in subnets['spaces'].iteritems(): 275 for subnet, subnet_details in details.iteritems(): 276 if 'INFAN' not in subnet_details['provider-id']: 277 newsubnets['spaces'].setdefault(space, {}) 278 newsubnets['spaces'][space][subnet] = subnet_details 279 return newsubnets 280 281 282 def get_machine_ip_in_space(client, machine, space): 283 """Given a machine id and a space name, will return an IP that 284 the machine has in the given space. 285 286 :param client: juju client object with machines and spaces 287 :param machine: string. ID of machine to check. 288 :param space: string. Name of space to look for. 289 :return ip: string. IP address of machine in requested space. 290 """ 291 machines = yaml.safe_load( 292 client.get_juju_output( 293 'list-machines', '--format=yaml'))['machines'] 294 spaces = non_infan_subnets( 295 yaml.safe_load( 296 client.get_juju_output( 297 'list-spaces', '--format=yaml'))) 298 subnet = spaces['spaces'][space].keys()[0] 299 for ip in machines[machine]['ip-addresses']: 300 if ip_in_cidr(ip, subnet): 301 return ip 302 303 304 def machine_can_ping_ip(client, machine, ip): 305 """SSH to the machine and attempt to ping the given IP. 306 307 :param client: juju client object 308 :param machine: machine to connect to 309 :param ip: IP address to ping 310 :returns: success of ping 311 """ 312 rc, _ = client.juju( 313 'ssh', ('--proxy', machine, 'ping -c1 -q ' + ip), check=False) 314 return rc == 0 315 316 317 def ip_in_cidr(address, cidr): 318 """Returns true if the ip address given is within the range defined 319 by the cidr subnet. 320 321 :param address: A valid IPv4 address (string) 322 :param cidr: A valid subnet in CIDR notation (string) 323 """ 324 return (ipaddress.ip_address(address.decode('utf-8')) 325 in ipaddress.ip_network(cidr.decode('utf-8'))) 326 327 328 def parse_args(argv): 329 """Parse all arguments.""" 330 parser = argparse.ArgumentParser(description="Test Network Spaces") 331 add_basic_testing_arguments(parser) 332 parser.set_defaults(series='bionic') 333 return parser.parse_args(argv) 334 335 336 def get_spaces_object(client): 337 """Returns the appropriate Spaces object based on the client provider 338 339 :param client: A juju client object 340 """ 341 if client.env.provider == 'ec2': 342 return SpacesAWS() 343 else: 344 log.info('Spaces not supported with current provider ' 345 '({}).'.format(client.env.provider)) 346 347 348 class Spaces: 349 350 def pre_bootstrap(self, client): 351 pass 352 353 def cleanup(self, client): 354 pass 355 356 357 class SpacesAWS(Spaces): 358 359 def pre_bootstrap(self, client): 360 """AWS specific function for setting up the VPC environment before 361 doing the bootstrap 362 363 :param client: juju client object 364 """ 365 366 if client.env.provider != 'ec2': 367 log.info('Skipping tests. Requires AWS EC2.') 368 return(False) 369 370 log.info('Setting up VPC in AWS region {}'.format( 371 client.env.get_region())) 372 creds = client.env.get_cloud_credentials() 373 ec2 = boto3.resource( 374 'ec2', 375 region_name=client.env.get_region(), 376 aws_access_key_id=creds['access-key'], 377 aws_secret_access_key=creds['secret-key']) 378 # set up vpc 379 vpc = ec2.create_vpc(CidrBlock='10.0.0.0/16') 380 self.vpcid = vpc.id 381 # get the first availability zone 382 zones = ec2.meta.client.describe_availability_zones() 383 firstzone = zones['AvailabilityZones'][0]['ZoneName'] 384 # create 3 subnets 385 for x in range(0, 3): 386 subnet = ec2.create_subnet( 387 CidrBlock='10.0.{}.0/24'.format(x), 388 AvailabilityZone=firstzone, 389 VpcId=vpc.id) 390 ec2.meta.client.modify_subnet_attribute( 391 MapPublicIpOnLaunch={'Value': True}, 392 SubnetId=subnet.id) 393 # add an internet gateway 394 gateway = ec2.create_internet_gateway() 395 gateway.attach_to_vpc(VpcId=vpc.id) 396 # get the main routing table 397 routetable = None 398 for rt in vpc.route_tables.all(): 399 for attrib in rt.associations_attribute: 400 if attrib['Main']: 401 routetable = rt 402 break 403 # set default route 404 routetable.create_route( 405 DestinationCidrBlock='0.0.0.0/0', 406 GatewayId=gateway.id) 407 # finally, update the juju client environment with the vpcid 408 client.env.update_config({'vpc-id': vpc.id}) 409 return(True) 410 411 def cleanup(self, client): 412 """Remove VPC from AWS 413 414 :param client: juju client 415 """ 416 if not self.vpcid: 417 return 418 if client.env.provider != 'ec2': 419 return 420 log.info('Removing VPC ({vpcid}) from AWS region {region}'.format( 421 region=client.env.get_region(), 422 vpcid=self.vpcid)) 423 creds = client.env.get_cloud_credentials() 424 ec2 = boto3.resource( 425 'ec2', 426 region_name=client.env.get_region(), 427 aws_access_key_id=creds['access-key'], 428 aws_secret_access_key=creds['secret-key']) 429 ec2client = ec2.meta.client 430 vpc = ec2.Vpc(self.vpcid) 431 # detach and delete all gateways 432 for gw in vpc.internet_gateways.all(): 433 vpc.detach_internet_gateway(InternetGatewayId=gw.id) 434 gw.delete() 435 # delete all route table associations 436 for rt in vpc.route_tables.all(): 437 for rta in rt.associations: 438 if not rta.main: 439 rta.delete() 440 main = False 441 for attrib in rt.associations_attribute: 442 if attrib['Main']: 443 main = True 444 if not main: 445 rt.delete() 446 # delete any instances 447 for subnet in vpc.subnets.all(): 448 for instance in subnet.instances.all(): 449 instance.terminate() 450 # delete our endpoints 451 for ep in ec2client.describe_vpc_endpoints( 452 Filters=[{ 453 'Name': 'vpc-id', 454 'Values': [self.vpcid] 455 }])['VpcEndpoints']: 456 ec2client.delete_vpc_endpoints( 457 VpcEndpointIds=[ep['VpcEndpointId']]) 458 # delete our security groups 459 for sg in vpc.security_groups.all(): 460 if sg.group_name != 'default': 461 sg.delete() 462 # delete any vpc peering connections 463 for vpcpeer in ec2client.describe_vpc_peering_connections( 464 Filters=[{ 465 'Name': 'requester-vpc-info.vpc-id', 466 'Values': [self.vpcid] 467 }])['VpcPeeringConnections']: 468 ec2.VpcPeeringConnection( 469 vpcpeer['VpcPeeringConnectionId']).delete() 470 # delete non-default network acls 471 for netacl in vpc.network_acls.all(): 472 if not netacl.is_default: 473 netacl.delete() 474 # delete network interfaces and subnets 475 for subnet in vpc.subnets.all(): 476 for interface in subnet.network_interfaces.all(): 477 interface.delete() 478 subnet.delete() 479 # finally, delete the vpc 480 ec2client.delete_vpc(VpcId=self.vpcid) 481 482 483 def main(argv=None): 484 args = parse_args(argv) 485 configure_logging(args.verbose) 486 487 bs_manager = BootstrapManager.from_args(args) 488 # The bs_manager.client env's region doesn't normally get updated 489 # until we've bootstrapped. Let's force an early update. 490 bs_manager.client.env.set_region(bs_manager.region) 491 spaces = get_spaces_object(bs_manager.client) 492 if not spaces.pre_bootstrap(bs_manager.client): 493 return 0 494 try: 495 with bs_manager.booted_context(args.upload_tools): 496 test = AssessNetworkSpaces() 497 test.assess_network_spaces(bs_manager.client, args.series) 498 finally: 499 spaces.cleanup(bs_manager.client) 500 return 0 501 502 503 if __name__ == '__main__': 504 sys.exit(main())