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())