From c544257bed80c4440f34c2fb2cc5fe0c26c89fe7 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 10 Jun 2024 10:57:29 +1000 Subject: [PATCH] initial commit --- .gitignore | 2 + autonode/cobbler.py | 50 +++++++++++++++ autonode/proxmox.py | 152 ++++++++++++++++++++++++++++++++++++++++++++ config.sample.yaml | 28 ++++++++ createvm.py | 74 +++++++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 .gitignore create mode 100644 autonode/cobbler.py create mode 100644 autonode/proxmox.py create mode 100644 config.sample.yaml create mode 100644 createvm.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64e5cda --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.yaml +*/__pycache__ diff --git a/autonode/cobbler.py b/autonode/cobbler.py new file mode 100644 index 0000000..1c22499 --- /dev/null +++ b/autonode/cobbler.py @@ -0,0 +1,50 @@ +import xmlrpc.client + +def create_system(mac_address, config, details, vmname): + """ + Create or edit a system in Cobbler with detailed configuration for network and management. + """ + + try: + # Connect to the Cobbler XML-RPC server + cobbler = xmlrpc.client.ServerProxy(config['cobbler']['api_url']) + + # Login and get a token + token = cobbler.login(config['cobbler']['user'], config['cobbler']['pass']) + + # Create a new system and get its system_id + system_id = cobbler.new_system(token) + + # Basic system modifications using the provided system data + cobbler.modify_system(system_id, 'name', vmname, token) + cobbler.modify_system(system_id, 'hostname', vmname, token) + + # Setup interface + interface_modifications = { + f"macaddress-{details['interface']}": mac_address, + f"ipaddress-{details['interface']}": details['ip'], + f"dnsname-{details['interface']}": vmname, + f"static-{details['interface']}": "1", + f"netmask-{details['interface']}": details.get('netmask', config['defaults']['netmask']), + } + cobbler.modify_system(system_id, 'modify_interface', interface_modifications, token) + + # Additional modifications + cobbler.modify_system(system_id, 'profile', details.get('profile', config['defaults']['profile']), token) + cobbler.modify_system(system_id, 'name_servers', details.get('name_servers', config['defaults']['name_servers']), token) + cobbler.modify_system(system_id, 'name_servers_search', details.get('name_servers_search', config['defaults']['name_servers_search']), token) + cobbler.modify_system(system_id, 'gateway', details['gw'], token) + cobbler.modify_system(system_id, 'mgmt_classes', details.get('role', config['defaults']['role']), token) + cobbler.modify_system(system_id, 'netboot_enabled', "1", token) # false is 0, true is 1 + cobbler.modify_system(system_id, 'status', 'testing', token) + + # Save and sync the changes + cobbler.save_system(system_id, token) + cobbler.sync(token) + + except xmlrpc.client.ProtocolError as err: + print(f"A protocol error occurred: {err.errcode}, {err.errmsg}") + except xmlrpc.client.Fault as fault: + print(f"An XML-RPC fault occurred: {fault.faultCode}, {fault.faultString}") + except Exception as e: + print(f"An unexpected error occurred: {e}") diff --git a/autonode/proxmox.py b/autonode/proxmox.py new file mode 100644 index 0000000..6be9389 --- /dev/null +++ b/autonode/proxmox.py @@ -0,0 +1,152 @@ +from proxmoxer import ProxmoxAPI +import re + +def connect_proxmox(config): + return ProxmoxAPI(config['proxmox']['host'], + user=config['proxmox']['user'], + password=config['proxmox']['pass'], + verify_ssl=config['proxmox']['verify_ssl']) + +def find_next_vmid(proxmox): + """ + Finds the next available VMID, excluding template ranges. + """ + vmids = [int(vm['vmid']) for vm in proxmox.cluster.resources.get(type='vm')] + # Assuming template VMIDs are in the range 900-910 + vmids = [vmid for vmid in vmids if not (900 <= vmid <= 910)] + if vmids: + return max(vmids) + 1 + return 1 + +def find_node_for_vm(proxmox, details): + """ + Selects the Proxmox host with the most free memory that has at least the required memory available for a new VM. + """ + resources = proxmox.cluster.resources.get(type='node') + suitable_nodes = [] + + for resource in resources: + # Convert bytes to MB for comparison + free_memory_mb = (resource['maxmem'] - resource['mem']) // 1024 // 1024 + if free_memory_mb >= details['mem']: + suitable_nodes.append({ + 'node': resource['node'], + 'free_memory_mb': free_memory_mb + }) + + # Select the node with the most free memory + if suitable_nodes: + target_node = max(suitable_nodes, key=lambda x: x['free_memory_mb']) + return target_node['node'] + return None + +def generate_vm_name(proxmox, vmid, details): + """ + Generates a VM name based on the Proxmox cluster name, a given VMID, and a domain. + """ + # Fetch cluster status to get the cluster name + cluster_status = proxmox.cluster.status.get() + if not cluster_status: + raise ValueError("Failed to fetch cluster status or cluster status is empty") + + # Extract the cluster name, assuming the first entry is the cluster information + cluster_name = cluster_status[0]['name'] if 'name' in cluster_status[0] else None + if not cluster_name: + raise ValueError("Cluster name is not available in the cluster status") + + # Remove underscores and dashes from the cluster name + formatted_cluster_name = cluster_name.replace('_', '').replace('-', '') + + # Generate the VM name + vm_name = f"{formatted_cluster_name}nxvm{vmid}.{details['domain']}" + return vm_name + +def create_vm(proxmox, node, config, details, vmid, vmname): + """ + Create a VM in Proxmox using specified arguments merged with defaults from the config. + """ + # Retrieve disk_pool from details + disk_pool = details['pool'] + + # Basic VM parameters setup + params = { + 'vmid': vmid, + 'name': vmname, + 'memory': details['mem'] * 1024, # Convert GB to MB for Proxmox + 'balloon': details['mem'] * 1024, + 'cores': details['vcpu'], + 'net0': f'virtio,bridge={details["net0"]}', + 'ostype': 'l26', + 'scsihw': 'virtio-scsi-single', + 'startup': 'order=20', + 'acpi': 1, + 'agent': 1, + 'autostart': 0, + 'onboot': 1, + } + + # BIOS and machine type + if details['bios'] == 'uefi': + params['efidisk0'] = f"{disk_pool}:1" + params['machine'] = 'pc-q35-7.0' + else: + params['machine'] = 'pc-i440fx-7.0' + + # Boot order + params['boot'] = 'nc' + params['bootdisk'] = 'virtio0' + + # Create the VM without starting it + try: + proxmox.nodes(node).qemu.create(**params) + except Exception as e: + print(f"Failed to create VM: {e}") + + # default disk settings + disk_settings = "aio=threads,backup=0,cache=none,discard=on" + + # Disk 0 configuration + try: + if 'disk0' in details: + proxmox.nodes(node).qemu(vmid).config.get() + proxmox.nodes(node).qemu(vmid).config.post(**{ + 'vmid': vmid, + 'virtio0': f"{disk_pool}:{details['disk0']},{disk_settings}" + }) + except Exception as e: + print(f"Failed to configure disk 0: {e}") + + # Disk 1 configuration + try: + if 'disk1' in details and details['disk1'] is not None: + proxmox.nodes(node).qemu(vmid).config.get() + proxmox.nodes(node).qemu(vmid).config.post(**{ + 'vmid': vmid, + 'virtio1': f"{disk_pool}:{details['disk1']},{disk_settings}" + }) + except Exception as e: + print(f"Failed to configure disk 1: {e}") + + # Retrieve and return the MAC address of net0 interface + config = proxmox.nodes(node).qemu(vmid).config.get() + + # Search for the MAC address in the string + mac_address_pattern = r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})" + match = re.search(mac_address_pattern, config.get('net0')) + + # Extract the MAC address if a match is found + mac_address = match.group(0) if match else None + print(f"Successfully created virtual-machine vmid: {vmid}, name: {params['name']}, mac: {mac_address}") + return mac_address + + +def start_vm(proxmox, node, vmid): + """ + Start a proxmox VM by VMID + """ + try: + # Start the VM + proxmox.nodes(node).qemu(vmid).status.start.post() + print(f"VM with VMID {vmid} on node '{node}' has been started.") + except Exception as e: + print(f"Failed to start VM: {e}") diff --git a/config.sample.yaml b/config.sample.yaml new file mode 100644 index 0000000..cb1ad68 --- /dev/null +++ b/config.sample.yaml @@ -0,0 +1,28 @@ +proxmox: + host: myproxmox-url.domain.tld:443 + user: autonode@pve + pass: a-secure-password + node: a-node-name + verify_ssl: /etc/pki/tls/cert.pem + # verify_ssl: true | false + +cobbler: + api_url: http://cobbler.domain.tld/cobbler_api + user: cobbler + pass: cobbler-password + +defaults: + vcpu: 2 + mem: 4 + disk0: 32 + disk1: 50 + pool: vmdata + net0: prod1 + bios: bios + role: roles::base + name_servers: ['198.18.13.12', '198.18.13.13'] + name_servers_search: main.domain.tld + interface: eth0 + netmask: 255.255.255.0 + profile: almalinux8.9-kvm + domain: main.domain.tld diff --git a/createvm.py b/createvm.py new file mode 100644 index 0000000..2dca0f7 --- /dev/null +++ b/createvm.py @@ -0,0 +1,74 @@ +import argparse +import yaml +from autonode import proxmox as pve +from autonode import cobbler as cobbler + +def load_config(config_path="config.yaml"): + """ + Load configuration from YAML file. + """ + with open(config_path, "r") as file: + return yaml.safe_load(file) + +def parse_arguments(): + """ + Parse command-line arguments. + """ + parser = argparse.ArgumentParser(description="Create a VM in Proxmox and register it in Cobbler.") + parser.add_argument("--vcpu", type=int, choices=range(1, 17), help="Number of virtual CPUs") + parser.add_argument("--mem", type=int, choices=range(1, 33), help="Memory in GB") + parser.add_argument("--disk0", default="32", help="Size of disk0 in GB") + parser.add_argument("--disk1", type=int, help="Size of disk1 in GB") + parser.add_argument("--pool", help="Disk pool name") + parser.add_argument("--net0", help="Bridge name for net0") + parser.add_argument("--ip", help="IPAddress name for net0") + parser.add_argument("--gw", help="Gateway for net0") + parser.add_argument("--interface", help="Interface name, e.g. eth0 or enp0s2") + parser.add_argument("--bios", choices=["bios", "uefi"], help="BIOS or UEFI") + parser.add_argument("--role", help="Management class in Cobbler") + parser.add_argument("--domain", help="Domain name for host") + return parser.parse_args() + + +def main(): + config = load_config() + args = parse_arguments() + + # define vm details by merging config and arguments + details = { + 'mem': args.mem or config['defaults']['mem'], + 'vcpu': args.vcpu or config['defaults']['vcpu'], + 'disk0': args.disk0 or '32', + 'disk1': args.disk1 or None, + 'pool': args.pool or config['defaults']['pool'], + 'net0': args.net0 or config['defaults']['net0'], + 'ip': args.ip, + 'gw': args.gw, + 'interface': args.interface or config['defaults']['interface'], + 'bios': args.bios or config['defaults']['bios'], + 'domain': args.bios or config['defaults']['domain'], + } + + # Initialise the proxmox connection + proxmox = pve.connect_proxmox(config) + + # Find the next available VMID + vmid = pve.find_next_vmid(proxmox) + + # Generate the VM name + vmname = pve.generate_vm_name(proxmox, vmid, details) + + # Find the best host to create the VM on + node = pve.find_node_for_vm(proxmox, details) + + # Create the VM and save the macaddress + mac_address = pve.create_vm(proxmox, node, config, details, vmid, vmname) + + # Create system in Cobbler + cobbler.create_system(mac_address, config, details, vmname) + + # Start the VM + pve.start_vm(proxmox, node, vmid) + +if __name__ == "__main__": + main()