diff --git a/docker-compose.yml b/docker-compose.yml index 469202c..54232aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: - netbox-nginx-config:/etc/netbox-nginx/:ro - /etc/cert-client:/etc/cert-client:ro postgres: - image: postgres:11-alpine + image: postgres:10.4-alpine env_file: env/postgres.env volumes: - netbox-postgres-data:/var/lib/postgresql/data diff --git a/scripts/create_vms.py b/scripts/create_vms.py new file mode 100644 index 0000000..0a22557 --- /dev/null +++ b/scripts/create_vms.py @@ -0,0 +1,469 @@ +import netaddr + +from dcim.choices import InterfaceTypeChoices, InterfaceModeChoices +from dcim.models import Platform, DeviceRole, Site +from ipam.models import IPAddress, VRF, Interface, Prefix, VLAN +from tenancy.models import Tenant +from virtualization.models import VirtualMachine, Cluster +from virtualization.choices import VirtualMachineStatusChoices +from extras.scripts import Script, ObjectVar, ChoiceVar, TextVar, IntegerVar +from extras.models import Tag +from utilities.forms import APISelect + + +class DeployVM(Script): + + env = "" + interfaces = [] + time_zone = "" + dns_domain_private = "" + dns_domain_public = "" + dns_servers = [] + ntp_servers = [] + ssh_authorized_keys = [] + ssh_port = "" + _default_interface = {} + tags = [] + output = [] + success_log = "" + + class Meta: + name = "Deploy new VMs" + description = "Deploy new virtual machines from existing platforms and roles using AWX" + fields = ['persist_disk', 'status', 'health_check', 'serial', 'tenant', 'cluster', 'env', 'untagged_vlan', 'backup', 'ip_addresses', 'vm_count', 'vcpus', 'memory', 'platform', 'role', 'disk', 'ssh_authorized_keys', 'hostnames'] + field_order = ['status', 'tenant', 'cluster', 'env', 'platform', 'role', 'health_check', 'serial', 'persist_disk', 'backup', 'vm_count', 'vcpus', 'memory', 'disk', 'hostnames', 'untagged_vlan', 'ip_addresses', 'ssh_authorized_keys'] + commit_default = False + + health_check = ChoiceVar( + label="Health checks on deployment", + description="Deployment will fail if server does not pass Consul health checks", + required=True, + choices=( + ('True', 'Yes'), + ('False', 'No') + ) + ) + + serial = ChoiceVar( + label="Serial deployment", + description="VM will not be parallelized in deployment", + required=True, + choices=( + ('False', 'No'), + ('True', 'Yes'), + ) + ) + + persist_disk = ChoiceVar( + label="Persist volume on redeploy", + description="VM will persist disk2 in vSphere on redeploys", + required=True, + choices=( + ('False', 'No'), + ('True', 'Yes'), + ) + ) + + status = ChoiceVar( + label="VM Status", + description="Deploy VM now or later?", + required=True, + choices=( + (VirtualMachineStatusChoices.STATUS_STAGED, 'Staged (Deploy now)'), + (VirtualMachineStatusChoices.STATUS_PLANNED, 'Planned (Save for later)') + ) + ) + + tenant = ObjectVar( + default="patientsky-hosting", + description="Name of the tenant the VMs beloing to", + queryset=Tenant.objects.filter() + ) + + cluster = ObjectVar( + default="odn1", + description="Name of the vSphere cluster you are deploying to", + queryset=Cluster.objects.all() + ) + + env = ChoiceVar( + label="Environment", + description="Environment to deploy VM", + default="vlb", + choices=( + ('pno', 'pno'), + ('inf', 'inf'), + ('stg', 'stg'), + ('dev', 'dev'), + ('hem', 'hem'), + ('hov', 'hov'), + ('hpl', 'hpl'), + ('mgt', 'mgt'), + ('cse', 'cse'), + ('qua', 'qua'), + ('dmo', 'dmo'), + ('vlb', 'vlb'), + ('cmi', 'cmi') + ) + ) + + platform = ObjectVar( + description="Host OS to deploy", + queryset=Platform.objects.filter( + name__regex=r'^(base_.*)' + ).order_by('name') + ) + + role = ObjectVar( + label="VM Role", + description="VM Role", + queryset=DeviceRole.objects.filter( + vm_role=True + ).order_by('name') + ) + + backup = ChoiceVar( + label="Backup strategy", + description="The backup strategy deployed to this VM with Veeam", + required=True, + choices=( + ('nobackup', 'Never'), + ('backup_general_1', 'Daily'), + ('backup_general_4', 'Monthly') + ) + ) + + ip_addresses = TextVar( + required=False, + label="IP Addresses", + description="List of IP addresses to create w. prefix e.g 192.168.0.10/24. If none given, hosts will be assigned IPs 'automagically'" + ) + + untagged_vlan = ObjectVar( + required=False, + label="VLAN", + widget=APISelect(api_url='/api/ipam/vlans/', display_field='display_name'), + queryset=VLAN.objects.all(), + description="Choose VLAN for IP-addresses", + ) + + hostnames = TextVar( + required=True, + label="Hostnames", + description="List of hostnames to create." + ) + + ssh_authorized_keys = TextVar( + label="SSH Authorized Keys", + required=False, + description="List of accepted SSH keys - defaults to site config context 'ssh_authorized_keys'" + ) + + vm_count = ChoiceVar( + label="Number of VMs", + description="Number of VMs to deploy", + choices=( + ('1', '1'), + ('2', '2'), + ('3', '3'), + ('4', '4'), + ('5', '5'), + ('6', '6'), + ('7', '7'), + ('8', '8'), + ('9', '9'), + ('10', '10') + ) + ) + + vcpus = ChoiceVar( + label="Number of CPUs", + description="Number of virtual CPUs", + default="2", + choices=( + ('1', '1'), + ('2', '2'), + ('3', '3'), + ('4', '4'), + ('5', '5'), + ('6', '6'), + ('7', '7'), + ('8', '8') + ) + ) + + memory = ChoiceVar( + description="Amount of VM memory", + default="4096", + choices=( + ('1024', '1024'), + ('2048', '2048'), + ('4096', '4096'), + ('8192', '8192'), + ('16384', '16384'), + ('32768', '32768') + ) + ) + + disk = IntegerVar( + label="Disk size", + description="Disk size in GB", + default="20" + ) + + def appendLogSuccess(self, log: str, obj=None): + self.success_log += " {} `\n{}\n`".format(log, obj) + return self + + def flushLogSuccess(self): + self.log_success(self.success_log) + self.success_log = "" + return self + + def _generateHostname(self, cluster, env, descriptor): + + # I now proclaim this VM, First of its Name, Queen of the Andals and the First Men, Protector of the Seven Kingdoms + vm_index = "001" + + search_for = str(cluster) + '-' + env + '-' + descriptor + '-' + vms = VirtualMachine.objects.filter( + name__startswith=search_for + ) + + if len(vms) > 0: + # Get last of its kind + last_vm_index = int(vms[len(vms) - 1].name.split('-')[3]) + 1 + if last_vm_index < 10: + vm_index = '00' + str(last_vm_index) + elif last_vm_index < 100: + vm_index = '0' + str(last_vm_index) + else: + vm_index = str(last_vm_index) + + hostname = str(cluster) + '-' + env + '-' + descriptor + '-' + vm_index + + return hostname + + def __validateInput(self, data, base_context_data): + + try: + self.interfaces = base_context_data['interfaces'] + self.time_zone = base_context_data['time_zone'] + self.tags = base_context_data['tags'] + self.dns_domain_private = base_context_data['dns_domain_private'] + self.dns_domain_public = base_context_data['dns_domain_public'] + self.dns_servers = base_context_data['dns_servers'] + self.ntp_servers = base_context_data['ntp_servers'] + self.ssh_authorized_keys = base_context_data['ssh_authorized_keys'] + self.ssh_port = base_context_data['ssh_port'] + self.tags.update({'env_' + self.env: {'comments': 'Environment', 'color': '009688'}}) + self.tags.update({'vsphere_' + data['backup']: {'comments': 'Backup strategy', 'color': '009688'}}) + if (data['health_check'] == 'True'): + self.tags.update({'health_check': {'comments': 'Do health checks in deployment', 'color': '4caf50'}}) + if (data['serial'] == 'True'): + self.tags.update({'serial': {'comments': 'Do health checks in deployment', 'color': '4caf50'}}) + except Exception as error: + self.log_failure("Error when parsing context_data! Error: " + str(error)) + return False + + if self.interfaces is None: + self.log_failure("No interfaces object in context data!") + return False + + if data['ip_addresses'] != "" and len(data['ip_addresses'].splitlines()) != int(data['vm_count']): + self.log_failure("The number of IP addresses and VMs does not match!") + return False + + if data['hostnames'] != "" and len(data['hostnames'].splitlines()) != int(data['vm_count']): + self.log_failure("The number of hostnames and VMs does not match!") + return False + + if self.interfaces is None: + self.log_failure("No interfaces object in context data!") + return False + + return True + + def run(self, data): + + vrf = VRF.objects.get( + name="global" + ) + + self.env = data['env'] + + # Setup base virtual machine for copying config_context + base_vm = VirtualMachine( + cluster=data['cluster'], + platform=data['platform'], + role=data['role'], + tenant=data['tenant'], + name="script_temp" + ) + + base_vm.save() + + if self.__validateInput(data=data, base_context_data=base_vm.get_config_context()) is False: + return False + + # Delete base virtual machine for copying config_context + vm_delete = VirtualMachine.objects.get( + name="script_temp" + ) + vm_delete.delete() + + for i in range(0, int(data['vm_count'])): + + hostnames = data['hostnames'].splitlines() + ip_addresses = data['ip_addresses'].splitlines() + + if len(hostnames) > 0: + hostname = hostnames[i] + else: + hostname = self._generateHostname( + cluster=data['cluster'].name, + env=self.env, + descriptor="na" + ) + + # Check if VM exists + if len(VirtualMachine.objects.filter(name=hostname)) > 0: + self.log_failure("VM with hostname " + hostname + " already exists!") + return False + + if len(ip_addresses) > 0: + + # Check if IP address exists + ip_check = IPAddress.objects.filter( + address=ip_addresses[i] + ) + + if len(ip_check) > 0: + self.log_failure(str(ip_check[0].address) + ' is already assigned to ' + str(ip_check[0].interface.name)) + return False + + domain = self.dns_domain_private if netaddr.IPNetwork(ip_addresses[i]).is_private() is True else self.dns_domain_public + ip_address = IPAddress( + address=ip_addresses[i], + vrf=vrf, + tenant=data['tenant'], + family=4, + dns_name=hostname + '.' + domain, + ) + + ip_address.save() + self.appendLogSuccess(log="Created IP", obj=ip_addresses[i]).appendLogSuccess(log="DNS", obj=hostname + '.' + domain) + else: + if data['untagged_vlan'] is not None: + + try: + + # Auto assign IPs from vsphere_port_group + prefix = Prefix.objects.get( + vlan=data['untagged_vlan'], + site=Site.objects.get(name=data['cluster'].site.name), + is_pool=True + ) + + ip = prefix.get_first_available_ip() + + domain = self.dns_domain_private if netaddr.IPNetwork(ip).is_private() is True else self.dns_domain_public + ip_address = IPAddress( + address=ip, + vrf=vrf, + tenant=data['tenant'], + family=4, + dns_name=hostname + '.' + domain, + ) + + ip_address.save() + self.appendLogSuccess(log="Auto-assigned IP", obj=ip).appendLogSuccess(log="DNS", obj=hostname + '.' + domain) + except Exception: + self.log_failure("An error occurred while auto-assigning IP address. VLAN or Prefix not found!") + return False + else: + self.log_failure("No IP address choice was made") + return False + + vm = VirtualMachine( + status=data['status'], + cluster=data['cluster'], + platform=data['platform'], + role=data['role'], + tenant=data['tenant'], + name=hostname, + disk=data['disk'], + memory=data['memory'], + vcpus=data['vcpus'] + ) + + vm.primary_ip4 = ip_address + vm.save() + self.appendLogSuccess(log="for VM in ", obj=vm.cluster) + + self.__assignTags(vm=vm) + + # Assign IP to interface + if self.__setupInterface(ip_address=ip_address, data=data, vm=vm) is False: + return False + + self.flushLogSuccess() + + return self.success_log + + def __assignTags(self, vm: VirtualMachine): + """ + Assign tags from context data + """ + for tag in self.tags: + if len(Tag.objects.filter(name=tag)) == 0: + color = self.tags[tag]['color'] if 'color' in self.tags[tag] else '9e9e9e' + comments = self.tags[tag]['comments'] if 'comments' in self.tags[tag] else 'No comments' + newTag = Tag(comments=comments, name=tag, color=color) + newTag.save() + + vm.tags.add(tag) + + vm.save() + + def __setupInterface(self, ip_address: IPAddress, vm: VirtualMachine, data): + """ + Setup interface and add IP address + """ + + # Get net address tools + ip = netaddr.IPNetwork(ip_address.address) + prefix_search = str(ip.network) + '/' + str(ip.prefixlen) + + try: + prefix = Prefix.objects.get( + prefix=prefix_search, + is_pool=True + ) + except Exception: + self.log_failure("Prefix for IP " + ip_address.address + " was not found") + return False + + # Right now we dont support multiple nics. But the data model supports it + interface = Interface( + name=self.interfaces['nic0']['name'], + mtu=self.interfaces['nic0']['mtu'], + virtual_machine=vm, + type=InterfaceTypeChoices.TYPE_VIRTUAL + ) + + # If we need anything other than Access, here is were to change it + if self.interfaces['nic0']['mode'] == "Access": + interface.mode = InterfaceModeChoices.MODE_ACCESS + interface.untagged_vlan = prefix.vlan + self.interfaces['nic0']['vsphere_port_group'] = prefix.vlan.name + + interface.save() + + # Add interface to IP address + ip_address.interface = interface + ip_address.save() + + self.appendLogSuccess(log="with interface ", obj=interface).appendLogSuccess(log=", vlan ", obj=prefix.vlan.name) + + return True