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