github.com/juju/juju@v0.0.0-20240327075706-a90865de2538/acceptancetests/substrate.py (about)

     1  from contextlib import (
     2      contextmanager,
     3  )
     4  import json
     5  import logging
     6  import os
     7  import subprocess
     8  from time import sleep
     9  try:
    10      import urlparse
    11  except ImportError:
    12      import urllib.parse as urlparse
    13  from boto import ec2
    14  from boto.exception import EC2ResponseError
    15  
    16  from dateutil import parser as date_parser
    17  
    18  import gce
    19  import six
    20  from utility import (
    21      temp_dir,
    22      until_timeout,
    23  )
    24  import winazurearm
    25  
    26  
    27  __metaclass__ = type
    28  
    29  
    30  log = logging.getLogger("substrate")
    31  
    32  
    33  LIBVIRT_DOMAIN_RUNNING = 'running'
    34  LIBVIRT_DOMAIN_SHUT_OFF = 'shut off'
    35  
    36  
    37  class StillProvisioning(Exception):
    38      """Attempted to terminate instances still provisioning."""
    39  
    40      def __init__(self, instance_ids):
    41          super(StillProvisioning, self).__init__(
    42              'Still provisioning: {}'.format(', '.join(instance_ids)))
    43          self.instance_ids = instance_ids
    44  
    45  
    46  def translate_to_env(current_env):
    47      """Translate openstack settings to environment variables."""
    48      if current_env['type'] not in ('openstack', 'rackspace'):
    49          raise Exception('Not an openstack environment. (type: %s)' %
    50                          current_env['type'])
    51      # Region doesn't follow the mapping for other vars.
    52      new_environ = {'OS_REGION_NAME': current_env['region']}
    53      for key in ['username', 'password', 'tenant-name', 'auth-url']:
    54          new_environ['OS_' + key.upper().replace('-', '_')] = current_env[key]
    55      return new_environ
    56  
    57  
    58  def get_euca_env(current_env):
    59      """Translate openstack settings to environment variables."""
    60      # Region doesn't follow the mapping for other vars.
    61      new_environ = {
    62          'EC2_URL': 'https://%s.ec2.amazonaws.com' % current_env['region']}
    63      for key in ['access-key', 'secret-key']:
    64          env_key = key.upper().replace('-', '_')
    65          new_environ['EC2_' + env_key] = current_env[key]
    66          new_environ['AWS_' + env_key] = current_env[key]
    67      return new_environ
    68  
    69  
    70  def terminate_instances(env, instance_ids):
    71      if len(instance_ids) == 0:
    72          log.info("No instances to delete.")
    73          return
    74      provider_type = env.provider
    75      environ = dict(os.environ)
    76      if provider_type == 'ec2':
    77          environ.update(get_euca_env(env.make_config_copy()))
    78          command_args = ['euca-terminate-instances'] + instance_ids
    79      elif provider_type in ('openstack', 'rackspace'):
    80          environ.update(translate_to_env(env.make_config_copy()))
    81          command_args = ['nova', 'delete'] + instance_ids
    82      elif provider_type == 'maas':
    83          with maas_account_from_boot_config(env) as substrate:
    84              substrate.terminate_instances(instance_ids)
    85          return
    86      else:
    87          with make_substrate_manager(env) as substrate:
    88              if substrate is None:
    89                  raise ValueError(
    90                      "This test does not support the %s provider"
    91                      % provider_type)
    92              return substrate.terminate_instances(instance_ids)
    93      log.info("Deleting %s." % ', '.join(instance_ids))
    94      subprocess.check_call(command_args, env=environ)
    95  
    96  
    97  def attempt_terminate_instances(account, instance_ids):
    98      """Initiate terminate instance method of specific handler
    99  
   100      :param account: Substrate account object.
   101      :param instance_ids: List of instance_ids to terminate
   102      :return: List of instance_ids failed to terminate
   103      """
   104      uncleaned_instances = []
   105      for instance_id in instance_ids:
   106          try:
   107              # We are calling terminate instances for each instances
   108              # individually so as to catch any error.
   109              account.terminate_instances([instance_id])
   110          except Exception as e:
   111              # Using too broad exception here because terminate_instances method
   112              # is handlers specific
   113              uncleaned_instances.append((instance_id, repr(e)))
   114      return uncleaned_instances
   115  
   116  
   117  def contains_only_known_instances(known_instance_ids, possibly_known_ids):
   118      """Identify instance_id_list only contains ids we know about.
   119  
   120      :param known_instance_ids: The list of instance_ids (superset)
   121      :param possibly_known_ids: The list of instance_ids (subset)
   122      :return: True if known_instance_ids only contains
   123      possibly_known_ids
   124      """
   125      return set(possibly_known_ids).issubset(set(known_instance_ids))
   126  
   127  
   128  class AWSAccount:
   129      """Represent the credentials of an AWS account."""
   130  
   131      @classmethod
   132      @contextmanager
   133      def from_boot_config(cls, boot_config, region=None):
   134          """Create an AWSAccount from a JujuData object."""
   135          config = get_config(boot_config)
   136          euca_environ = get_euca_env(config)
   137          if region is None:
   138              region = config["region"]
   139          client = ec2.connect_to_region(
   140              region, aws_access_key_id=euca_environ['EC2_ACCESS_KEY'],
   141              aws_secret_access_key=euca_environ['EC2_SECRET_KEY'])
   142          # There is no point constructing a AWSAccount if client is None.
   143          # It can't do anything.
   144          if client is None:
   145              log.info(
   146                  'Failed to create ec2 client for region: {}.'.format(region))
   147              yield None
   148          else:
   149              yield cls(euca_environ, region, client)
   150  
   151      def __init__(self, euca_environ, region, client):
   152          self.euca_environ = euca_environ
   153          self.region = region
   154          self.client = client
   155  
   156      def iter_security_groups(self):
   157          """Iterate through security groups created by juju in this account.
   158  
   159          :return: an iterator of (group-id, group-name) tuples.
   160          """
   161          groups = self.client.get_all_security_groups(
   162              filters={'description': 'juju group'})
   163          for group in groups:
   164              yield group.id, group.name
   165  
   166      def iter_instance_security_groups(self, instance_ids=None):
   167          """List the security groups used by instances in this account.
   168  
   169          :param instance_ids: If supplied, list only security groups used by
   170              the specified instances.
   171          :return: an iterator of (group-id, group-name) tuples.
   172          """
   173          log.info('Listing security groups in use.')
   174          reservations = self.client.get_all_instances(instance_ids=instance_ids)
   175          for reservation in reservations:
   176              for instance in reservation.instances:
   177                  for group in instance.groups:
   178                      yield group.id, group.name
   179  
   180      def destroy_security_groups(self, groups):
   181          """Destroy the specified security groups.
   182  
   183          :return: a list of groups that could not be destroyed.
   184          """
   185          failures = []
   186          for group in groups:
   187              deleted = self.client.delete_security_group(name=group)
   188              if not deleted:
   189                  failures.append(group)
   190          return failures
   191  
   192      def delete_detached_interfaces(self, security_groups):
   193          """Delete detached network interfaces for supplied groups.
   194  
   195          :param security_groups: A collection of security_group ids.
   196          :return: A collection of security groups which still have interfaces in
   197              them.
   198          """
   199          interfaces = self.client.get_all_network_interfaces(
   200              filters={'status': 'available'})
   201          unclean = set()
   202          for interface in interfaces:
   203              for group in interface.groups:
   204                  if group.id in security_groups:
   205                      try:
   206                          interface.delete()
   207                      except EC2ResponseError as e:
   208                          err_code = six.ensure_text(e.error_code)
   209                          if err_code not in (
   210                                  'InvalidNetworkInterface.InUse',
   211                                  'InvalidNetworkInterfaceID.NotFound'):
   212                              raise
   213                          log.info(
   214                              'Failed to delete interface {!r}. {}'.format(
   215                                  interface.id, e.message))
   216                          unclean.update(g.id for g in interface.groups)
   217                      break
   218          return unclean
   219  
   220      def cleanup_security_groups(self, instances, secgroups):
   221          """Destroy any security groups used only by `instances`.
   222  
   223          :param instances: The list of instance_ids
   224          :param secgroups: dict of security groups
   225          :return: list of failed deleted security groups
   226          """
   227          failures = []
   228          for sg_id, sg_instances in secgroups:
   229              if contains_only_known_instances(instances, sg_instances):
   230                  try:
   231                      deleted = self.client.delete_security_group(name=sg_id)
   232                      if not deleted:
   233                          failures.append((sg_id, "Failed to delete"))
   234                  except EC2ResponseError as e:
   235                      err_code = six.ensure_text(e.error_code)
   236                      if err_code != 'InvalidGroup.NotFound':
   237                          failures.append((sg_id, repr(e)))
   238  
   239          return failures
   240  
   241      def get_security_groups(self, instances):
   242          """Get AWS configured security group
   243          If instances list is specified then get security groups mapped
   244          to those instances only.
   245  
   246          :param instances: list of instance names
   247          :return: list containing tuples; where each tuples contains security
   248          group id as first element and the list of instances mapped to that
   249          security group as second element. [(sg_id, [i_id, id2]),
   250           (sg_id2, [i_id1])]
   251          """
   252          group_ids = [sg[0] for sg in self.iter_instance_security_groups(
   253              instances)]
   254          all_groups = self.client.get_all_security_groups(
   255              group_ids=group_ids)
   256          secgroups = [(sg.id, [id for id in sg.instances()])
   257                       for sg in all_groups]
   258          return secgroups
   259  
   260      def terminate_instances(self, instance_ids):
   261          """Terminate the specified instances."""
   262          return self.client.terminate_instances(instance_ids=instance_ids)
   263  
   264      def ensure_cleanup(self, resource_details):
   265          """
   266          Do AWS specific clean-up activity.
   267          :param resource_details: The list of resource to be cleaned up
   268          :return: list of resources that were not cleaned up
   269          """
   270          uncleaned_resources = []
   271  
   272          if not resource_details:
   273              return uncleaned_resources
   274  
   275          # TODO(wallyworld) - the boto ec2 client fails to list security groups.
   276          # It seems to be passing group name instead of group id.
   277          # We are migrating away from these Python tests so ignore for now.
   278          # security_groups = self.get_security_groups(
   279          #    resource_details.get('instances', []))
   280  
   281          uncleaned_instances = attempt_terminate_instances(
   282              self, resource_details.get('instances', []))
   283  
   284          # uncleaned_security_groups = self.cleanup_security_groups(
   285          #    resource_details.get('instances', []), security_groups)
   286  
   287          if uncleaned_instances:
   288              uncleaned_resources.append(
   289                  {'resource': 'instances',
   290                   'errors': uncleaned_instances})
   291          # if uncleaned_security_groups:
   292          #    uncleaned_resources.append(
   293          #        {'resource': 'security groups',
   294          #         'errors': uncleaned_security_groups})
   295          return uncleaned_resources
   296  
   297  
   298  class OpenStackAccount:
   299      """Represent the credentials/region of an OpenStack account."""
   300  
   301      def __init__(self, username, password, tenant_name, auth_url, region_name):
   302          self._username = username
   303          self._password = password
   304          self._tenant_name = tenant_name
   305          self._auth_url = auth_url
   306          self._region_name = region_name
   307          self._client = None
   308  
   309      @classmethod
   310      @contextmanager
   311      def from_boot_config(cls, boot_config):
   312          """Create an OpenStackAccount from a JujuData object."""
   313          config = get_config(boot_config)
   314          yield cls(
   315              config['username'], config['password'], config['tenant-name'],
   316              config['auth-url'], config['region'])
   317  
   318      def get_client(self):
   319          """Return a novaclient Client for this account."""
   320          from novaclient import client
   321          return client.Client(
   322              '1.1', self._username, self._password, self._tenant_name,
   323              self._auth_url, region_name=self._region_name,
   324              service_type='compute', insecure=False)
   325  
   326      @property
   327      def client(self):
   328          """A novaclient Client for this account.  May come from cache."""
   329          if self._client is None:
   330              self._client = self.get_client()
   331          return self._client
   332  
   333      def iter_security_groups(self):
   334          """Iterate through security groups created by juju in this account.
   335  
   336          :return: an iterator of (group-id, group-name) tuples.
   337          """
   338          return ((g.id, g.name) for g in self.client.security_groups.list()
   339                  if g.description == 'juju group')
   340  
   341      def iter_instance_security_groups(self, instance_ids=None):
   342          """List the security groups used by instances in this account.
   343  
   344          :param instance_ids: If supplied, list only security groups used by
   345              the specified instances.
   346          :return: an iterator of (group-id, group-name) tuples.
   347          """
   348          group_names = set()
   349          for server in self.client.servers.list():
   350              if instance_ids is not None and server.id not in instance_ids:
   351                  continue
   352              # A server that errors before security groups are assigned will
   353              # have no security_groups attribute.
   354              groups = (getattr(server, 'security_groups', []))
   355              group_names.update(group['name'] for group in groups)
   356          return ((k, v) for k, v in self.iter_security_groups()
   357                  if v in group_names)
   358  
   359      def ensure_cleanup(self, resource_details):
   360          """
   361          Do OpenStack specific clean-up activity.
   362          :param resource_details: The list of resource to be cleaned up
   363          :return: list of resources that were not cleaned up
   364          """
   365          uncleaned_resource = []
   366          return uncleaned_resource
   367  
   368  
   369  def convert_to_azure_ids(client, instance_ids):
   370      """Return a list of ARM ids from a list juju machine instance-ids.
   371  
   372      The Juju 2 machine instance-id is not an ARM VM id, it is the non-unique
   373      machine name. For any juju controller, there are 2 or more machines named
   374      0. Using the client, the machine ids machine names can be found.
   375  
   376      See: https://bugs.launchpad.net/juju-core/+bug/1586089
   377  
   378      :param client: A ModelClient instance.
   379      :param instance_ids: a list of Juju machine instance-ids
   380      :return: A list of ARM VM instance ids.
   381      """
   382      with AzureARMAccount.from_boot_config(
   383              client.env) as substrate:
   384          return substrate.convert_to_azure_ids(client, instance_ids)
   385  
   386  
   387  class GCEAccount:
   388      """Represent an Google Compute Engine Account."""
   389  
   390      def __init__(self, client):
   391          """Constructor.
   392  
   393          :param client: An instance of apache libcloud GCEClient retrieved
   394              via gce.get_client.
   395          """
   396          self.client = client
   397  
   398      @classmethod
   399      @contextmanager
   400      def from_boot_config(cls, boot_config):
   401          """A context manager for a GCE account.
   402  
   403          This creates a temporary cert file from the private-key.
   404          """
   405          config = get_config(boot_config)
   406          with temp_dir() as cert_dir:
   407              cert_file = os.path.join(cert_dir, 'gce.pem')
   408              open(cert_file, 'w').write(config['private-key'])
   409              client = gce.get_client(
   410                  config['client-email'], cert_file,
   411                  config['project-id'])
   412              yield cls(client)
   413  
   414      def terminate_instances(self, instance_ids):
   415          """Terminate the specified instances."""
   416          for instance_id in instance_ids:
   417              # Pass old_age=0 to mean delete now.
   418              count = gce.delete_instances(self.client, instance_id, old_age=0)
   419              if count != 1:
   420                  raise Exception('Failed to delete {}: deleted {}'.format(
   421                      instance_id, count))
   422  
   423      def ensure_cleanup(self, resource_details):
   424          """
   425          Do GCE specific clean-up activity.
   426          :param resource_details: The list of resource to be cleaned up
   427          :return: list of resources that were not cleaned up
   428          """
   429          uncleaned_resource = []
   430          return uncleaned_resource
   431  
   432  
   433  class AzureARMAccount:
   434      """Represent an Azure ARM Account."""
   435  
   436      def __init__(self, arm_client):
   437          """Constructor.
   438  
   439          :param arm_client: An instance of winazurearm.ARMClient.
   440          """
   441          self.arm_client = arm_client
   442  
   443      @classmethod
   444      @contextmanager
   445      def from_boot_config(cls, boot_config):
   446          """A context manager for a Azure RM account.
   447  
   448          In the case of the Juju 1x, the ARM keys must be in the boot_config's
   449          config.  subscription_id is the same. The PEM for the SMS is ignored.
   450          """
   451          credentials = boot_config.get_cloud_credentials()
   452          # The tenant-id is required by Azure storage, but forbidden to be in
   453          # Juju credentials, so we get it from the bootstrap model options.  It
   454          # is suppressed when actually bootstrapping.  (See
   455          # ModelClient.make_model_config)
   456          tenant_id = boot_config.get_option('tenant-id')
   457          arm_client = winazurearm.ARMClient(
   458              credentials['subscription-id'], credentials['application-id'],
   459              credentials['application-password'], tenant_id)
   460          arm_client.init_services()
   461          yield cls(arm_client)
   462  
   463      def convert_to_azure_ids(self, client, instance_ids):
   464          if not instance_ids[0].startswith('machine'):
   465              log.info('Bug Lp 1586089 is fixed in {}.'.format(client.version))
   466              log.info('AzureARMAccount.convert_to_azure_ids can be deleted.')
   467              return instance_ids
   468  
   469          models = client.get_models()['models']
   470          # 2.2-rc1 introduced new model listing output name/short-name.
   471          model = [
   472              m for m in models
   473              if m.get('short-name', m['name']) == client.model_name][0]
   474          resource_group = 'juju-{}-model-{}'.format(
   475              model.get('short-name', model['name']), model['model-uuid'])
   476          # NOTE(achilleasa): resources was not used in this func
   477          # resources = winazurearm.list_resources(
   478          #    self.arm_client, glob=resource_group, recursive=True)
   479          vm_ids = []
   480          for machine_name in instance_ids:
   481              rgd, vm = winazurearm.find_vm_deployment(
   482                  resource_group, machine_name)
   483              vm_ids.append(vm.vm_id)
   484          return vm_ids
   485  
   486      def terminate_instances(self, instance_ids):
   487          """Terminate the specified instances."""
   488          for instance_id in instance_ids:
   489              winazurearm.delete_instance(
   490                  self.arm_client, instance_id, resource_group=None)
   491  
   492      def ensure_cleanup(self, resource_details):
   493          """
   494          Do AzureARM specific clean-up activity.
   495          :param resource_details: The list of resource to be cleaned up
   496          :return: list of resources that were not cleaned up
   497          """
   498          uncleaned_resource = []
   499          return uncleaned_resource
   500  
   501  
   502  class AzureAccount:
   503      """Represent an Azure Account."""
   504  
   505      def __init__(self, service_client):
   506          """Constructor.
   507  
   508          :param service_client: An instance of
   509              azure.servicemanagement.ServiceManagementService.
   510          """
   511          self.service_client = service_client
   512  
   513      @classmethod
   514      @contextmanager
   515      def from_boot_config(cls, boot_config):
   516          """A context manager for a AzureAccount.
   517  
   518          It writes the certificate to a temp file because the Azure client
   519          library requires it, then deletes the temp file when done.
   520          """
   521          from azure.servicemanagement import ServiceManagementService
   522          config = get_config(boot_config)
   523          with temp_dir() as cert_dir:
   524              cert_file = os.path.join(cert_dir, 'azure.pem')
   525              open(cert_file, 'w').write(config['management-certificate'])
   526              service_client = ServiceManagementService(
   527                  config['management-subscription-id'], cert_file)
   528              yield cls(service_client)
   529  
   530      @staticmethod
   531      def convert_instance_ids(instance_ids):
   532          """Convert juju instance ids into Azure service/role names.
   533  
   534          Return a dict mapping service name to role names.
   535          """
   536          services = {}
   537          for instance_id in instance_ids:
   538              service, role = instance_id.rsplit('-', 1)
   539              services.setdefault(service, set()).add(role)
   540          return services
   541  
   542      @contextmanager
   543      def terminate_instances_cxt(self, instance_ids):
   544          """Terminate instances in a context.
   545  
   546          This context manager requests termination, then allows the "with"
   547          block to happen.  When the block is exited, it waits until the
   548          operations complete.
   549  
   550          The strategy for terminating instances varies depending on whether all
   551          roles are being terminated.  If all roles are being terminated, the
   552          deployment and hosted service are deleted.  If not all roles are being
   553          terminated, the roles themselves are deleted.
   554          """
   555          converted = self.convert_instance_ids(instance_ids)
   556          requests = set()
   557          services_to_delete = set(converted.keys())
   558          for service, roles in converted.items():
   559              properties = self.service_client.get_hosted_service_properties(
   560                  service, embed_detail=True)
   561              for deployment in properties.deployments:
   562                  role_names = set(
   563                      d_role.role_name for d_role in deployment.role_list)
   564                  if role_names.difference(roles) == set():
   565                      requests.add(self.service_client.delete_deployment(
   566                          service, deployment.name))
   567                  else:
   568                      services_to_delete.discard(service)
   569                      for role in roles:
   570                          requests.add(
   571                              self.service_client.delete_role(
   572                                  service, deployment.name, role))
   573          yield
   574          self.block_on_requests(requests)
   575          for service in services_to_delete:
   576              self.service_client.delete_hosted_service(service)
   577  
   578      def block_on_requests(self, requests):
   579          """Wait until the requests complete."""
   580          requests = set(requests)
   581          while len(requests) > 0:
   582              for request in list(requests):
   583                  op = self.service_client.get_operation_status(
   584                      request.request_id)
   585                  if op.status == 'Succeeded':
   586                      requests.remove(request)
   587  
   588      def terminate_instances(self, instance_ids):
   589          """Terminate the specified instances.
   590  
   591          See terminate_instances_cxt for details.
   592          """
   593          with self.terminate_instances_cxt(instance_ids):
   594              return
   595  
   596      def ensure_cleanup(self, resource_details):
   597          """
   598          Do Azure specific clean-up activity.
   599          :param resource_details: The list of resource to be cleaned up
   600          :return: list of resources that were not cleaned up
   601          """
   602          uncleaned_resource = []
   603          return uncleaned_resource
   604  
   605  
   606  class MAASAccount:
   607      """Represent a MAAS 2.0 account."""
   608  
   609      _API_PATH = 'api/2.0/'
   610  
   611      STATUS_READY = 4
   612  
   613      SUBNET_CONNECTION_MODES = frozenset(('AUTO', 'DHCP', 'STATIC', 'LINK_UP'))
   614  
   615      ACQUIRING = 'User acquiring node'
   616  
   617      CREATED = 'created'
   618  
   619      NODE = 'node'
   620  
   621      def __init__(self, profile, url, oauth):
   622          self.profile = profile
   623          self.url = urlparse.urljoin(url, self._API_PATH)
   624          self.oauth = oauth
   625  
   626      def _maas(self, *args):
   627          """Call maas api with given arguments and parse json result."""
   628          command = ('maas',) + args
   629          res = subprocess.run(command, stdout=subprocess.PIPE,
   630                               stderr=subprocess.PIPE, universal_newlines=True)
   631          if res.returncode == 0:
   632              if not res.stdout:
   633                  return None
   634              return json.loads(res.stdout)
   635  
   636          raise Exception('%s failed:\n %s%s' % (command, res.stdout,
   637                                                 res.stderr))
   638  
   639      def login(self):
   640          """Login with the maas cli."""
   641          subprocess.check_call([
   642              'maas', 'login', self.profile, self.url, self.oauth])
   643  
   644      def logout(self):
   645          """Logout with the maas cli."""
   646          subprocess.check_call(['maas', 'logout', self.profile])
   647  
   648      def _machine_release_args(self, machine_id):
   649          return (self.profile, 'machine', 'release', machine_id)
   650  
   651      def terminate_instances(self, instance_ids):
   652          """Terminate the specified instances."""
   653          for instance in instance_ids:
   654              maas_system_id = instance.split('/')[5]
   655              log.info('Deleting %s.' % instance)
   656              self._maas(*self._machine_release_args(maas_system_id))
   657  
   658      def _list_allocated_args(self):
   659          return (self.profile, 'machines', 'list-allocated')
   660  
   661      def get_allocated_nodes(self):
   662          """Return a dict of allocated nodes with the hostname as keys."""
   663          nodes = self._maas(*self._list_allocated_args())
   664          allocated = {node['hostname']: node for node in nodes}
   665          return allocated
   666  
   667      def get_acquire_date(self, node):
   668          events = self._maas(
   669              self.profile, 'events', 'query', 'id={}'.format(node))
   670          for event in events['events']:
   671              if node != event[self.NODE]:
   672                  raise ValueError(
   673                      'Node "{}" was not "{}".'.format(event[self.NODE], node))
   674              if event['type'] == self.ACQUIRING:
   675                  return date_parser.parse(event[self.CREATED])
   676          raise LookupError('Unable to find acquire date for "{}".'.format(node))
   677  
   678      def get_allocated_ips(self):
   679          """Return a dict of allocated ips with the hostname as keys.
   680  
   681          A maas node may have many ips. The method selects the first ip which
   682          is the address used for virsh access and ssh.
   683          """
   684          allocated = self.get_allocated_nodes()
   685          ips = {k: v['ip_addresses'][0] for k, v in allocated.items()
   686                 if v['ip_addresses']}
   687          return ips
   688  
   689      def machines(self):
   690          """Return list of all machines."""
   691          return self._maas(self.profile, 'machines', 'read')
   692  
   693      def fabrics(self):
   694          """Return list of all fabrics."""
   695          return self._maas(self.profile, 'fabrics', 'read')
   696  
   697      def create_fabric(self, name, class_type=None):
   698          """Create a new fabric."""
   699          args = [self.profile, 'fabrics', 'create', 'name=' + name]
   700          if class_type is not None:
   701              args.append('class_type=' + class_type)
   702          return self._maas(*args)
   703  
   704      def delete_fabric(self, fabric_id):
   705          """Delete a fabric with given id."""
   706          return self._maas(self.profile, 'fabric', 'delete', str(fabric_id))
   707  
   708      def spaces(self):
   709          """Return list of all spaces."""
   710          return self._maas(self.profile, 'spaces', 'read')
   711  
   712      def create_space(self, name):
   713          """Create a new space with given name."""
   714          return self._maas(self.profile, 'spaces', 'create', 'name=' + name)
   715  
   716      def delete_space(self, space_id):
   717          """Delete a space with given id."""
   718          return self._maas(self.profile, 'space', 'delete', str(space_id))
   719  
   720      def create_vlan(self, fabric_id, vid, name=None):
   721          """Create a new vlan on fabric with given fabric_id."""
   722          args = [
   723              self.profile, 'vlans', 'create', str(fabric_id), 'vid=' + str(vid),
   724          ]
   725          if name is not None:
   726              args.append('name=' + name)
   727          return self._maas(*args)
   728  
   729      def delete_vlan(self, fabric_id, vid):
   730          """Delete a vlan on given fabric_id with vid."""
   731          return self._maas(
   732              self.profile, 'vlan', 'delete', str(fabric_id), str(vid))
   733  
   734      def interfaces(self, system_id):
   735          """Return list of interfaces belonging to node with given system_id."""
   736          return self._maas(self.profile, 'interfaces', 'read', system_id)
   737  
   738      def interface_update(self, system_id, interface_id, name=None,
   739                           mac_address=None, tags=None, vlan_id=None):
   740          """Update fields of existing interface on node with given system_id."""
   741          args = [
   742              self.profile, 'interface', 'update', system_id, str(interface_id),
   743          ]
   744          if name is not None:
   745              args.append('name=' + name)
   746          if mac_address is not None:
   747              args.append('mac_address=' + mac_address)
   748          if tags is not None:
   749              args.append('tags=' + tags)
   750          if vlan_id is not None:
   751              args.append('vlan=' + str(vlan_id))
   752          return self._maas(*args)
   753  
   754      def interface_create_vlan(self, system_id, parent, vlan_id):
   755          """Create a vlan interface on machine with given system_id."""
   756          args = [
   757              self.profile, 'interfaces', 'create-vlan', system_id,
   758              'parent=' + str(parent), 'vlan=' + str(vlan_id),
   759          ]
   760          # TODO(gz): Add support for optional parameters as needed.
   761          return self._maas(*args)
   762  
   763      def delete_interface(self, system_id, interface_id):
   764          """Delete interface on node with given system_id with interface_id."""
   765          return self._maas(
   766              self.profile, 'interface', 'delete', system_id, str(interface_id))
   767  
   768      def interface_link_subnet(self, system_id, interface_id, mode, subnet_id,
   769                                ip_address=None, default_gateway=False):
   770          """Link interface from given system_id and interface_id to subnet."""
   771          if mode not in self.SUBNET_CONNECTION_MODES:
   772              raise ValueError('Invalid subnet connection mode: {}'.format(mode))
   773          if ip_address and mode != 'STATIC':
   774              raise ValueError('Must be mode STATIC for ip_address')
   775          if default_gateway and mode not in ('AUTO', 'STATIC'):
   776              raise ValueError('Must be mode AUTO or STATIC for default_gateway')
   777          args = [
   778              self.profile, 'interface', 'link-subnet', system_id,
   779              str(interface_id), 'mode=' + mode, 'subnet=' + str(subnet_id),
   780          ]
   781          if ip_address:
   782              args.append('ip_address=' + ip_address)
   783          if default_gateway:
   784              args.append('default_gateway=true')
   785          return self._maas(*args)
   786  
   787      def interface_unlink_subnet(self, system_id, interface_id, link_id):
   788          """Unlink subnet from interface."""
   789          return self._maas(
   790              self.profile, 'interface', 'unlink-subnet', system_id,
   791              str(interface_id), 'id=' + str(link_id))
   792  
   793      def subnets(self):
   794          """Return list of all subnets."""
   795          return self._maas(self.profile, 'subnets', 'read')
   796  
   797      def create_subnet(self, cidr, name=None, fabric_id=None, vlan_id=None,
   798                        vid=None, space=None, gateway_ip=None, dns_servers=None):
   799          """Create a subnet with given cidr."""
   800          if vlan_id and vid:
   801              raise ValueError('Must only give one of vlan_id and vid')
   802          args = [self.profile, 'subnets', 'create', 'cidr=' + cidr]
   803          if name is not None:
   804              # Defaults to cidr if none is given
   805              args.append('name=' + name)
   806          if fabric_id is not None:
   807              # Uses default fabric if none is given
   808              args.append('fabric=' + str(fabric_id))
   809          if vlan_id is not None:
   810              # Uses default vlan on fabric if none is given
   811              args.append('vlan=' + str(vlan_id))
   812          if vid is not None:
   813              args.append('vid=' + str(vid))
   814          if space is not None:
   815              # Uses default space if none is given
   816              args.append('space=' + str(space))
   817          if gateway_ip is not None:
   818              args.append('gateway_ip=' + str(gateway_ip))
   819          if dns_servers is not None:
   820              args.append('dns_servers=' + str(dns_servers))
   821          # TODO(gz): Add support for rdns_mode and allow_proxy from MAAS 2.0
   822          return self._maas(*args)
   823  
   824      def delete_subnet(self, subnet_id):
   825          """Delete subnet with given subnet_id."""
   826          return self._maas(
   827              self.profile, 'subnet', 'delete', str(subnet_id))
   828  
   829      def ensure_cleanup(self, resource_details):
   830          """
   831          Do MAAS specific clean-up activity.
   832          :param resource_details: The list of resource to be cleaned up
   833          :return: list of resources that were not cleaned up
   834          """
   835          uncleaned_resource = []
   836          return uncleaned_resource
   837  
   838  
   839  class MAAS1Account(MAASAccount):
   840      """Represent a MAAS 1.X account."""
   841  
   842      _API_PATH = 'api/1.0/'
   843  
   844      def _list_allocated_args(self):
   845          return (self.profile, 'nodes', 'list-allocated')
   846  
   847      def _machine_release_args(self, machine_id):
   848          return (self.profile, 'node', 'release', machine_id)
   849  
   850  
   851  @contextmanager
   852  def maas_account_from_boot_config(env):
   853      """Create a ContextManager for either a MAASAccount or a MAAS1Account.
   854  
   855      As it's not possible to tell from the maas config which version of the api
   856      to use, try 2.0 and if that fails on login fallback to 1.0 instead.
   857      """
   858      maas_oauth = env.get_cloud_credentials()['maas-oauth']
   859      args = (env.get_option('name'), env.get_option('maas-server'), maas_oauth)
   860      manager = MAASAccount(*args)
   861      try:
   862          manager.login()
   863      except subprocess.CalledProcessError as e:
   864          log.info("Could not login with MAAS 2.0 API, trying 1.0! err -> %s", e)
   865          manager = MAAS1Account(*args)
   866          manager.login()
   867      yield manager
   868      # We do not call manager.logout() because it can break concurrent procs.
   869  
   870  
   871  class LXDAccount:
   872      """Represent a LXD account."""
   873  
   874      def __init__(self, remote=None):
   875          self.remote = remote
   876  
   877      @classmethod
   878      @contextmanager
   879      def from_boot_config(cls, boot_config):
   880          """Create a ContextManager for a LXDAccount."""
   881          config = get_config(boot_config)
   882          remote = config.get('region', None)
   883          yield cls(remote=remote)
   884  
   885      def terminate_instances(self, instance_ids):
   886          """Terminate the specified instances."""
   887          for instance_id in instance_ids:
   888              subprocess.check_call(['lxc', 'stop', '--force', instance_id])
   889              if self.remote:
   890                  instance_id = '{}:{}'.format(self.remote, instance_id)
   891              subprocess.check_call(['lxc', 'delete', '--force', instance_id])
   892  
   893      def ensure_cleanup(self, resource_details):
   894          """
   895          Do LXD specific clean-up activity.
   896          :param resource_details: The list of resource to be cleaned up
   897          :return: list of resources that were not cleaned up
   898          """
   899          uncleaned_resource = []
   900          return uncleaned_resource
   901  
   902  
   903  def get_config(boot_config):
   904      config = boot_config.make_config_copy()
   905      if boot_config.provider not in ('lxd', 'manual', 'kubernetes'):
   906          config.update(boot_config.get_cloud_credentials())
   907      return config
   908  
   909  
   910  @contextmanager
   911  def make_substrate_manager(boot_config):
   912      """A ContextManager that returns an Account for the config's substrate.
   913  
   914      Returns None if the substrate is not supported.
   915      """
   916      config = get_config(boot_config)
   917      substrate_factory = {
   918          'ec2': AWSAccount.from_boot_config,
   919          'openstack': OpenStackAccount.from_boot_config,
   920          'rackspace': OpenStackAccount.from_boot_config,
   921          'azure': AzureAccount.from_boot_config,
   922          'azure-arm': AzureARMAccount.from_boot_config,
   923          'lxd': LXDAccount.from_boot_config,
   924          'gce': GCEAccount.from_boot_config,
   925      }
   926      substrate_type = config['type']
   927      if substrate_type == 'azure' and 'application-id' in config:
   928          substrate_type = 'azure-arm'
   929      factory = substrate_factory.get(substrate_type)
   930      if factory is None:
   931          yield None
   932      else:
   933          with factory(boot_config) as substrate:
   934              yield substrate
   935  
   936  
   937  def start_libvirt_domain(uri, domain):
   938      """Call virsh to start the domain.
   939  
   940      @Parms URI: The address of the libvirt service.
   941      @Parm domain: The name of the domain.
   942      """
   943  
   944      command = ['virsh', '-c', uri, 'start', domain]
   945      try:
   946          subprocess.check_output(command, stderr=subprocess.STDOUT)
   947      except subprocess.CalledProcessError as e:
   948          if 'already active' in e.output.decode('utf-8'):
   949              return '%s is already running; nothing to do.' % domain
   950          raise Exception('%s failed:\n %s' % (command, e.output))
   951      sleep(30)
   952      for ignored in until_timeout(120):
   953          if verify_libvirt_domain(uri, domain, LIBVIRT_DOMAIN_RUNNING):
   954              return "%s is now running" % domain
   955          sleep(2)
   956      raise Exception('libvirt domain %s did not start.' % domain)
   957  
   958  
   959  def stop_libvirt_domain(uri, domain):
   960      """Call virsh to shutdown the domain.
   961  
   962      @Parms URI: The address of the libvirt service.
   963      @Parm domain: The name of the domain.
   964      """
   965  
   966      command = ['virsh', '-c', uri, 'shutdown', domain]
   967      try:
   968          subprocess.check_output(command, stderr=subprocess.STDOUT)
   969      except subprocess.CalledProcessError as e:
   970          if 'domain is not running' in e.output.decode('utf-8'):
   971              return ('%s is not running; nothing to do.' % domain)
   972          raise Exception('%s failed:\n %s' % (command, e.output))
   973      sleep(30)
   974      for ignored in until_timeout(120):
   975          if verify_libvirt_domain(uri, domain, LIBVIRT_DOMAIN_SHUT_OFF):
   976              return "%s is now shut off" % domain
   977          sleep(2)
   978      raise Exception('libvirt domain %s is not shut off.' % domain)
   979  
   980  
   981  def verify_libvirt_domain(uri, domain, state=LIBVIRT_DOMAIN_RUNNING):
   982      """Returns a bool based on if the domain is in the given state.
   983  
   984      @Parms URI: The address of the libvirt service.
   985      @Parm domain: The name of the domain.
   986      @Parm state: The state to verify (e.g. "running or "shut off").
   987      """
   988  
   989      dom_status = get_libvirt_domstate(uri, domain)
   990      return state in dom_status
   991  
   992  
   993  def get_libvirt_domstate(uri, domain):
   994      """Call virsh to get the state of the given domain.
   995  
   996      @Parms URI: The address of the libvirt service.
   997      @Parm domain: The name of the domain.
   998      """
   999  
  1000      command = ['virsh', '-c', uri, 'domstate', domain]
  1001      try:
  1002          sub_output = subprocess.check_output(command)
  1003      except subprocess.CalledProcessError:
  1004          raise Exception('%s failed' % command)
  1005      return sub_output
  1006  
  1007  
  1008  def parse_euca(euca_output):
  1009      for line in euca_output.splitlines():
  1010          fields = line.split('\t')
  1011          if fields[0] != 'INSTANCE':
  1012              continue
  1013          yield fields[1], fields[3]
  1014  
  1015  
  1016  def describe_instances(instances=None, running=False, job_name=None,
  1017                         env=None):
  1018      command = ['euca-describe-instances']
  1019      if job_name is not None:
  1020          command.extend(['--filter', 'tag:job_name=%s' % job_name])
  1021      if running:
  1022          command.extend(['--filter', 'instance-state-name=running'])
  1023      if instances is not None:
  1024          command.extend(instances)
  1025      log.info(' '.join(command))
  1026      return parse_euca(subprocess.check_output(command, env=env))
  1027  
  1028  
  1029  def has_nova_instance(boot_config, instance_id):
  1030      """Return True if the instance-id is present.  False otherwise.
  1031  
  1032      This implementation was extracted from wait_for_state_server_to_shutdown.
  1033      It can be fooled into thinking that the instance-id is present when it is
  1034      not, but should be reliable for determining that the instance-id is not
  1035      present.
  1036      """
  1037      environ = dict(os.environ)
  1038      environ.update(translate_to_env(boot_config.make_config_copy()))
  1039      output = subprocess.check_output(['nova', 'list'], env=environ)
  1040      return bool(instance_id in output)
  1041  
  1042  
  1043  def get_job_instances(job_name):
  1044      description = describe_instances(job_name=job_name, running=True)
  1045      return (machine_id for machine_id, name in description)
  1046  
  1047  
  1048  def destroy_job_instances(job_name):
  1049      instances = list(get_job_instances(job_name))
  1050      if len(instances) == 0:
  1051          return
  1052      subprocess.check_call(['euca-terminate-instances'] + instances)
  1053  
  1054  
  1055  def resolve_remote_dns_names(env, remote_machines):
  1056      """Update addresses of given remote_machines as needed by providers."""
  1057      if env.provider != 'maas':
  1058          # Only MAAS requires special handling at prsent.
  1059          return
  1060      # MAAS hostnames are not resolvable, but we can adapt them to IPs.
  1061      with maas_account_from_boot_config(env) as account:
  1062          allocated_ips = account.get_allocated_ips()
  1063      for remote in remote_machines:
  1064          if remote.get_address() in allocated_ips:
  1065              remote.update_address(allocated_ips[remote.address])