diff --git a/pulumi-kvm/Pulumi_yaml_template b/pulumi-kvm/Pulumi_yaml_template new file mode 100644 index 0000000..3cc2b5c --- /dev/null +++ b/pulumi-kvm/Pulumi_yaml_template @@ -0,0 +1,13 @@ +name: test-node +runtime: + name: python + options: + toolchain: uv + virtualenv: .venv +description: Reusable templates for managing clustered or single libvirt KVM guests. +packages: + libvirt: + source: pulumi/pulumi/terraform-provider + version: 1.1.4 + parameters: + - dmacvicar/libvirt diff --git a/pulumi-kvm/README.md b/pulumi-kvm/README.md new file mode 100644 index 0000000..bbf13de --- /dev/null +++ b/pulumi-kvm/README.md @@ -0,0 +1,67 @@ +# Instructions for creating projects using these templates +***Note: This assumes SSH access to KVM hosts and Pulumi is [installed](https://www.pulumi.com/docs/install/).*** + +## Login to the backend +Log in to your [pulumi backend](https://www.pulumi.com/docs/iac/concepts/state-and-backends/), in this example a file-based local backend will be used. This will create a state file in your home directory (~/ on Linux, for example) + +`pulumi login --local` + +## Copying and modifying the template +Create a new directory with the name of the project. Copy the templates from the `stack_templates` and `python_templates` directories for the specific node type. + +Example: +``` +mkdir app-cluster +cp stack_templates/cluster_static_pulumi app-cluster/Pulumi.app-cluster.yaml +cp python_templates/cluster_static_template.py app-cluster/__main__.py +cp Pulumi_yaml_template app-cluster/Pulumi.yaml +``` + +Edit to fit the project. The `Pulumi_yaml_template` is already formatted with the runtime an virtual environment. The name and description should be edited to fit the name and description of the project. If using a different runtime and toolchain, edit accordingly. This assumes the runtime is `python` and the toolchain is `uv` + +## Create the pulumi stack +After editing the files, create the pulumi stack +``` +cd app-cluster +pulumi stack init --copy-config-from Pulumi..yaml +``` + +A glimpse of the what the directory should look like at this state is found in the [sample-projects](./sample_projects) directory + +and create a bare uv project and add the packages + +``` +uv init --bare +uv add pulumi pyyaml +``` + +## Add the terraform-provider for libvirt +Pulumi no longer maintains a dedicated provider for libvirt. A local [terraform provider](https://www.pulumi.com/registry/packages/libvirt/) will need to be installed + +`pulumi package add terraform-provider dmacvicar/libvirt` + +This will add the source package to the `pyproject.toml` file created when `uv` initialized the project + +# Add the hashed password +A password will need to be set in order to access the console. SSH access is public key/certificate based. + +`pulumi config set --secret kvm:password_hash ` + +Ensure the password is stored securely and can be retrieved readily in the event troubleshooting from the console is necessary. + +Preview the stack before deploying + +`pulumi preview` + +And deploy the stack + +`pulumi up` + +The resources are destroyed by executing + +`pulumi destroy` + +# Limitations + +- The major limitation is that Pulumi does not start the KVM guest. The guest will need to manually (or some how programmatically) started for the cloud-init workflow to begin. Once the cloud-init workflow completes, the guest will reboot and will be ready for use. +- The `os-variant` that is normally necessary by `virt-install` is not set. This can be set manually after Pulumi deploys the guest or anytime the guest is shutdown. diff --git a/pulumi-kvm/python_templates/cluster_static_template.py b/pulumi-kvm/python_templates/cluster_static_template.py new file mode 100644 index 0000000..a707197 --- /dev/null +++ b/pulumi-kvm/python_templates/cluster_static_template.py @@ -0,0 +1,229 @@ +import pulumi +import pulumi_libvirt as libvirt +import yaml + +# function to convert GB to bytes for disk size stated in stack config +def gb_to_bytes(gb: int) -> int: + """Converts gigabytes to bytes for resource capacity.""" + return int(gb) * 1024 * 1024 * 1024 + + +config = pulumi.Config("kvm") + +ssh_user = config.get("ssh_user") or "root" +base_image_path = config.require("base_image_path") +nodes = config.require_object("nodes") +secret_hash = config.require_secret("password_hash") + +# Load user data once outside the loop +try: + with open(config.require("user_data_path"), "r") as f: + user_data = pulumi.Output.format(f.read(), password_hash=secret_hash) +except Exception as e: + # Handle case where file path is wrong or secret fails to resolve + print(f"Warning: Could not process user_data. Skipping node deployment. Error: {e}") + +# Dictionary to cache providers per target host +providers = {} + +for node in nodes: + name = node["name"] + target_host = node["host"] + bridge_interface = node["bridge"] + disk_gb = node["disk_gb"] + memory_mb = node["memory_mb"] + vcpu = node["vcpu"] + + # Get or cache providers in providers dictionary + if target_host not in providers: + providers[target_host] = libvirt.Provider( + f"prov-{target_host}", + uri=f"qemu+sshcmd://{ssh_user}@{target_host}/system" + ) + host_provider = providers[target_host] + + meta_data_yaml = yaml.dump({"instance-id": f"i-{name}", "local-hostname": name}) + + # Initialize network payload in V1 format + network_config_payload = { + "version": 1, + "config": [] # Start with an empty list of configurations (The list container) + } + + + # physical interface + physical_interface = { + "type": "physical", + "name": "eth0", + "subnets": [ + { + "type": "static", + "address": node["static_ip"], + "gateway": node["gateway"], + } + ] + } + network_config_payload["config"].append(physical_interface) + + + # Append the nameservers to network_config_payload (solves missing nameserver issues) + if "dns_servers" in node: + nameserver_block = { + "type": "nameserver", + "address": [str(ip) for ip in node["dns_servers"]] # Create clean python list of strings + } + network_config_payload["config"].append(nameserver_block) + + + if "dns_search" in node and node["dns_search"]: + network_config_payload["config"][0]["subnets"][0]["dns_search"] = [str(s) for s in node["dns_search"]] # Create clean python list of strings + + # complete network config yaml + network_config_yaml = yaml.dump(network_config_payload) + + + # cloud image backing store + base_volume = libvirt.Volume( + f"base-vol-{name}", + name=f"{name}-base", + pool=pool_target, + capacity=gb_to_bytes(10), + target={ + "format": {"type": "qcow2"} + }, + create={ + "content": { + "url": base_image_path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + guest_volume = libvirt.Volume( + f"{name}", + name=f"{name}", + pool=pool_target, + capacity=gb_to_bytes(disk_gb), + target={ + "format": {"type": "qcow2"} + }, + backing_store={ + "path": base_volume.id, + "format": { + "type": "qcow2" + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + cloud_init_disk = libvirt.CloudinitDisk( + f"ci-disk-{name}", + meta_data=meta_data_yaml, + user_data=user_data, + network_config=network_config_yaml, # Passes the fully constructed network payload + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + cloud_init_volume = libvirt.Volume( + f"ci-vol-{name}", + pool=pool_target, + target={ + "format": {"type": "raw"} + }, + create={ + "content": { + "url": cloud_init_disk.path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + guest = libvirt.Domain( + f"dom-{name}", + name=name, + type="kvm", + autostart=True, + cpu={ + "mode": "host-passthrough" + }, + memory=memory_mb, + memory_unit="MiB", + vcpu=vcpu, + os={ + "type": "hvm", + "type_arch": "x86_64", + "type_machine": "q35", + "firmware": "efi", + "boot_devices": [] + }, + features={ + "acpi": True + }, + devices={ + "disks": [ + { + "source": { + "volume": { + "pool": guest_volume.pool, + "volume": guest_volume.name + } + }, + "target": { + "dev": "vda", + "bus": "virtio" + }, + "driver": { + "type": "qcow2" + }, + "boot": { + "order": 1 + } + }, + { + "device": "cdrom", + "source": { + "volume": { + "pool": cloud_init_volume.pool, + "volume": cloud_init_volume.name + } + }, + "target": { + "dev": "sda", + "bus": "sata" + }, + "driver": { + "type": "raw" + } + } + ], + "interfaces": [ + { + "type": "bridge", + "model": { + "type": "virtio" + }, + "source": { + "bridge": { + "bridge": bridge_interface + } + }, + "wait_for_ip": { + "timeout": 300, + "source": "lease" + } + } + ], + "graphics": [ + { + "type": "vnc", + "vnc": { + "autoport": True, + "listen": "127.0.0.1" + } + } + ] + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +pulumi.export("deployed_node", guest.name) diff --git a/pulumi-kvm/python_templates/single_static_template.py b/pulumi-kvm/python_templates/single_static_template.py new file mode 100644 index 0000000..959120b --- /dev/null +++ b/pulumi-kvm/python_templates/single_static_template.py @@ -0,0 +1,217 @@ +import pulumi +import pulumi_libvirt as libvirt +import yaml + +# Function to convert GB to bytes for disk size stated in stack config +def gb_to_bytes(gb: int) -> int: + return int(gb) * 1024 * 1024 * 1024 + +config = pulumi.Config("kvm") +ssh_user = config.get("ssh_user") or "root" +target_host = config.require("target_host") +guest_name = config.require("guest_name") +bridge_interface = config.require("bridge_interface") +secret_hash = config.require_secret("password_hash") + +# Load cloud-config +with open(config.require("user_data_path"), "r") as f: + user_data = pulumi.Output.format(f.read(), password_hash=secret_hash) + +# Set KVM host +host_provider = libvirt.Provider( + "ssh-provider", + uri=f"qemu+sshcmd://{ssh_user}@{target_host}/system" +) + +meta_data_yaml = yaml.dump({"instance-id": f"i-{guest_name}", "local-hostname": guest_name}) + +# Initialize DNS nameservers and search domain +dns_servers = config.require_object("dns_servers") +dns_search = config.get_object("dns_search") or [] + +# Initialize network config +network_config_payload = { + "version": 1, + "config": [] +} + + +# Append "physical" interface +physical_interface = { + "type": "physical", + "name": "eth0", + "subnets": [ + { + "type": "static", + "address": config.require("static_ip"), + "gateway": config.require("gateway") + } + ] +} +network_config_payload["config"].append(physical_interface) + +# Append nameservers +if dns_servers: + nameserver_block = { + "type": "nameserver", + "interface": "eth0", + "address": dns_servers + } + network_config_payload["config"].append(nameserver_block) + + +# Append search domain +if dns_search: + network_config_payload["config"][0]["subnets"][0]["dns_search"] = list(str(dns_search)) + + +network_config_yaml = yaml.dump(network_config_payload) + + +base_volume = libvirt.Volume( + "base-vol", + name=f"{guest_name}-base", + pool=pool_target, + capacity=gb_to_bytes(10), + target={ + "format": {"type": "qcow2"} + }, + create={ + "content": { + "url": base_image_path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + + +guest_volume = libvirt.Volume( + "guest-disk", + name=f"{guest_name}", + pool=pool_target, + capacity=gb_to_bytes(config.require_int("disk_gb")), + target={ + "format": {"type": "qcow2"} + }, + backing_store={ + "path": base_volume.id, + "format": { + "type": "qcow2" + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + + +cloud_init_disk = libvirt.CloudinitDisk( + "cloudinit-iso", + meta_data=meta_data_yaml, + user_data=user_data, + network_config=network_config_yaml, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +# Create the cloud-init volume with metadata +cloud_init_volume = libvirt.Volume( + "cloudinit-vol", + pool=pool_target, + target={ + "format": {"type": "raw"} + }, + create={ + "content": { + "url": cloud_init_disk.path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +guest = libvirt.Domain( + "kvm-guest", + name=guest_name, + type="kvm", + autostart=True, + cpu={ + "mode": "host-passthrough" + }, + memory=config.require_int("memory_mb"), + memory_unit="MiB", + vcpu=config.require_int("vcpu"), + os={ + "type": "hvm", + "type_arch": "x86_64", + "type_machine": "q35", + "firmware": "efi", + "boot_devices": [] + }, + features={ + "acpi": True + }, + devices={ + "disks": [ + { + "source": { + "volume": { + "pool": guest_volume.pool, + "volume": guest_volume.name + } + }, + "target": { + "dev": "vda", + "bus": "virtio" + }, + "driver": { + "type": "qcow2" + }, + "boot": { + "order": 1 + } + }, + { + "device": "cdrom", + "source": { + "volume": { + "pool": cloud_init_volume.pool, + "volume": cloud_init_volume.name + } + }, + "target": { + "dev": "sda", + "bus": "sata" + }, + "driver": { + "type": "raw" + } + } + ], + "interfaces": [ + { + "type": "bridge", + "model": { + "type": "virtio" + }, + "source": { + "bridge": { + "bridge": bridge_interface + } + }, + "wait_for_ip": { + "timeout": 300, + "source": "lease" + } + } + ], + "graphics": [ + { + "type": "vnc", + "vnc": { + "autoport": True, + "listen": "127.0.0.1" + } + } + ] + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +pulumi.export("deployed_node", guest.name) diff --git a/pulumi-kvm/sample-projects/test-static-cluster/Pulumi.test-static-cluster.yaml b/pulumi-kvm/sample-projects/test-static-cluster/Pulumi.test-static-cluster.yaml new file mode 100644 index 0000000..5dbd6ec --- /dev/null +++ b/pulumi-kvm/sample-projects/test-static-cluster/Pulumi.test-static-cluster.yaml @@ -0,0 +1,42 @@ +config: + kvm:ssh_user: "root" + kvm:pool_target: "default" + kvm:base_image_path: "https://repo.almalinux.org/almalinux/10/cloud/x86_64_v2/images/AlmaLinux-10-GenericCloud-latest.x86_64_v2.qcow2" + kvm:user_data_path: "../user-data/alma-node" + kvm:nodes: + - name: "db-node-01" + host: "192.168.1.1" + bridge: "br0" + vcpu: 8 + memory_mb: 16384 + disk_gb: 120 + static_ip: "192.168.1.100/24" + gateway: "192.168.1.254" + dns_servers: + - "9.9.9.9" + dns_search: + - "search.local" + - name: "db-node-02" + host: "192.168.1.2" + bridge: "br0" + vcpu: 8 + memory_mb: 16384 + disk_gb: 120 + static_ip: "192.168.1.101/24" + gateway: "192.168.1.254" + dns_servers: + - "9.9.9.9" + dns_search: + - "search.local" + - name: "db-node-03" + host: "192.168.1.3" + bridge: "br0" + vcpu: 8 + memory_mb: 16384 + disk_gb: 120 + static_ip: "192.168.1.102/24" + gateway: "192.168.1.254" + dns_servers: + - "9.9.9.9" + dns_search: + - "search.local" diff --git a/pulumi-kvm/sample-projects/test-static-cluster/Pulumi.yaml b/pulumi-kvm/sample-projects/test-static-cluster/Pulumi.yaml new file mode 100644 index 0000000..284ded7 --- /dev/null +++ b/pulumi-kvm/sample-projects/test-static-cluster/Pulumi.yaml @@ -0,0 +1,13 @@ +name: test-static-cluster +runtime: + name: python + options: + toolchain: uv + virtualenv: .venv +description: Reusable templates for managing clustered or single libvirt KVM guests. +packages: + libvirt: + source: terraform-provider + version: 1.1.4 + parameters: + - dmacvicar/libvirt diff --git a/pulumi-kvm/sample-projects/test-static-cluster/__main__.py b/pulumi-kvm/sample-projects/test-static-cluster/__main__.py new file mode 100644 index 0000000..a707197 --- /dev/null +++ b/pulumi-kvm/sample-projects/test-static-cluster/__main__.py @@ -0,0 +1,229 @@ +import pulumi +import pulumi_libvirt as libvirt +import yaml + +# function to convert GB to bytes for disk size stated in stack config +def gb_to_bytes(gb: int) -> int: + """Converts gigabytes to bytes for resource capacity.""" + return int(gb) * 1024 * 1024 * 1024 + + +config = pulumi.Config("kvm") + +ssh_user = config.get("ssh_user") or "root" +base_image_path = config.require("base_image_path") +nodes = config.require_object("nodes") +secret_hash = config.require_secret("password_hash") + +# Load user data once outside the loop +try: + with open(config.require("user_data_path"), "r") as f: + user_data = pulumi.Output.format(f.read(), password_hash=secret_hash) +except Exception as e: + # Handle case where file path is wrong or secret fails to resolve + print(f"Warning: Could not process user_data. Skipping node deployment. Error: {e}") + +# Dictionary to cache providers per target host +providers = {} + +for node in nodes: + name = node["name"] + target_host = node["host"] + bridge_interface = node["bridge"] + disk_gb = node["disk_gb"] + memory_mb = node["memory_mb"] + vcpu = node["vcpu"] + + # Get or cache providers in providers dictionary + if target_host not in providers: + providers[target_host] = libvirt.Provider( + f"prov-{target_host}", + uri=f"qemu+sshcmd://{ssh_user}@{target_host}/system" + ) + host_provider = providers[target_host] + + meta_data_yaml = yaml.dump({"instance-id": f"i-{name}", "local-hostname": name}) + + # Initialize network payload in V1 format + network_config_payload = { + "version": 1, + "config": [] # Start with an empty list of configurations (The list container) + } + + + # physical interface + physical_interface = { + "type": "physical", + "name": "eth0", + "subnets": [ + { + "type": "static", + "address": node["static_ip"], + "gateway": node["gateway"], + } + ] + } + network_config_payload["config"].append(physical_interface) + + + # Append the nameservers to network_config_payload (solves missing nameserver issues) + if "dns_servers" in node: + nameserver_block = { + "type": "nameserver", + "address": [str(ip) for ip in node["dns_servers"]] # Create clean python list of strings + } + network_config_payload["config"].append(nameserver_block) + + + if "dns_search" in node and node["dns_search"]: + network_config_payload["config"][0]["subnets"][0]["dns_search"] = [str(s) for s in node["dns_search"]] # Create clean python list of strings + + # complete network config yaml + network_config_yaml = yaml.dump(network_config_payload) + + + # cloud image backing store + base_volume = libvirt.Volume( + f"base-vol-{name}", + name=f"{name}-base", + pool=pool_target, + capacity=gb_to_bytes(10), + target={ + "format": {"type": "qcow2"} + }, + create={ + "content": { + "url": base_image_path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + guest_volume = libvirt.Volume( + f"{name}", + name=f"{name}", + pool=pool_target, + capacity=gb_to_bytes(disk_gb), + target={ + "format": {"type": "qcow2"} + }, + backing_store={ + "path": base_volume.id, + "format": { + "type": "qcow2" + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + cloud_init_disk = libvirt.CloudinitDisk( + f"ci-disk-{name}", + meta_data=meta_data_yaml, + user_data=user_data, + network_config=network_config_yaml, # Passes the fully constructed network payload + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + cloud_init_volume = libvirt.Volume( + f"ci-vol-{name}", + pool=pool_target, + target={ + "format": {"type": "raw"} + }, + create={ + "content": { + "url": cloud_init_disk.path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) + ) + + guest = libvirt.Domain( + f"dom-{name}", + name=name, + type="kvm", + autostart=True, + cpu={ + "mode": "host-passthrough" + }, + memory=memory_mb, + memory_unit="MiB", + vcpu=vcpu, + os={ + "type": "hvm", + "type_arch": "x86_64", + "type_machine": "q35", + "firmware": "efi", + "boot_devices": [] + }, + features={ + "acpi": True + }, + devices={ + "disks": [ + { + "source": { + "volume": { + "pool": guest_volume.pool, + "volume": guest_volume.name + } + }, + "target": { + "dev": "vda", + "bus": "virtio" + }, + "driver": { + "type": "qcow2" + }, + "boot": { + "order": 1 + } + }, + { + "device": "cdrom", + "source": { + "volume": { + "pool": cloud_init_volume.pool, + "volume": cloud_init_volume.name + } + }, + "target": { + "dev": "sda", + "bus": "sata" + }, + "driver": { + "type": "raw" + } + } + ], + "interfaces": [ + { + "type": "bridge", + "model": { + "type": "virtio" + }, + "source": { + "bridge": { + "bridge": bridge_interface + } + }, + "wait_for_ip": { + "timeout": 300, + "source": "lease" + } + } + ], + "graphics": [ + { + "type": "vnc", + "vnc": { + "autoport": True, + "listen": "127.0.0.1" + } + } + ] + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +pulumi.export("deployed_node", guest.name) diff --git a/pulumi-kvm/sample-projects/test-static/Pulumi.test-static.yaml b/pulumi-kvm/sample-projects/test-static/Pulumi.test-static.yaml new file mode 100644 index 0000000..7d49027 --- /dev/null +++ b/pulumi-kvm/sample-projects/test-static/Pulumi.test-static.yaml @@ -0,0 +1,18 @@ +config: + kvm:ssh_user: "root" + kvm:pool_target: "default" + kvm:base_image_path: "https://repo.almalinux.org/almalinux/10/cloud/x86_64_v2/images/AlmaLinux-10-GenericCloud-latest.x86_64_v2.qcow2" + kvm:user_data_path: "../user-data/alma-node.yaml" + kvm:guest_name: "prod-app-01" + kvm:target_host: "192.168.1.1" + kvm:bridge_interface: "br0" + kvm:vcpu: 2 + kvm:memory_mb: 4096 + kvm:disk_gb: 30 + kvm:net_device: "eth0" + kvm:static_ip: "192.168.1.10/24" + kvm:gateway: "192.168.1.254" + kvm:dns_servers: + - "9.9.9.9" + kvm:dns_search: + - "search.local" diff --git a/pulumi-kvm/sample-projects/test-static/Pulumi.yaml b/pulumi-kvm/sample-projects/test-static/Pulumi.yaml new file mode 100644 index 0000000..8f6e97c --- /dev/null +++ b/pulumi-kvm/sample-projects/test-static/Pulumi.yaml @@ -0,0 +1,13 @@ +name: test-static +runtime: + name: python + options: + toolchain: uv + virtualenv: .venv +description: Reusable templates for managing clustered or single libvirt KVM guests. +packages: + libvirt: + source: terraform-provider + version: 1.1.4 + parameters: + - dmacvicar/libvirt diff --git a/pulumi-kvm/sample-projects/test-static/__main__.py b/pulumi-kvm/sample-projects/test-static/__main__.py new file mode 100644 index 0000000..959120b --- /dev/null +++ b/pulumi-kvm/sample-projects/test-static/__main__.py @@ -0,0 +1,217 @@ +import pulumi +import pulumi_libvirt as libvirt +import yaml + +# Function to convert GB to bytes for disk size stated in stack config +def gb_to_bytes(gb: int) -> int: + return int(gb) * 1024 * 1024 * 1024 + +config = pulumi.Config("kvm") +ssh_user = config.get("ssh_user") or "root" +target_host = config.require("target_host") +guest_name = config.require("guest_name") +bridge_interface = config.require("bridge_interface") +secret_hash = config.require_secret("password_hash") + +# Load cloud-config +with open(config.require("user_data_path"), "r") as f: + user_data = pulumi.Output.format(f.read(), password_hash=secret_hash) + +# Set KVM host +host_provider = libvirt.Provider( + "ssh-provider", + uri=f"qemu+sshcmd://{ssh_user}@{target_host}/system" +) + +meta_data_yaml = yaml.dump({"instance-id": f"i-{guest_name}", "local-hostname": guest_name}) + +# Initialize DNS nameservers and search domain +dns_servers = config.require_object("dns_servers") +dns_search = config.get_object("dns_search") or [] + +# Initialize network config +network_config_payload = { + "version": 1, + "config": [] +} + + +# Append "physical" interface +physical_interface = { + "type": "physical", + "name": "eth0", + "subnets": [ + { + "type": "static", + "address": config.require("static_ip"), + "gateway": config.require("gateway") + } + ] +} +network_config_payload["config"].append(physical_interface) + +# Append nameservers +if dns_servers: + nameserver_block = { + "type": "nameserver", + "interface": "eth0", + "address": dns_servers + } + network_config_payload["config"].append(nameserver_block) + + +# Append search domain +if dns_search: + network_config_payload["config"][0]["subnets"][0]["dns_search"] = list(str(dns_search)) + + +network_config_yaml = yaml.dump(network_config_payload) + + +base_volume = libvirt.Volume( + "base-vol", + name=f"{guest_name}-base", + pool=pool_target, + capacity=gb_to_bytes(10), + target={ + "format": {"type": "qcow2"} + }, + create={ + "content": { + "url": base_image_path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + + +guest_volume = libvirt.Volume( + "guest-disk", + name=f"{guest_name}", + pool=pool_target, + capacity=gb_to_bytes(config.require_int("disk_gb")), + target={ + "format": {"type": "qcow2"} + }, + backing_store={ + "path": base_volume.id, + "format": { + "type": "qcow2" + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + + +cloud_init_disk = libvirt.CloudinitDisk( + "cloudinit-iso", + meta_data=meta_data_yaml, + user_data=user_data, + network_config=network_config_yaml, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +# Create the cloud-init volume with metadata +cloud_init_volume = libvirt.Volume( + "cloudinit-vol", + pool=pool_target, + target={ + "format": {"type": "raw"} + }, + create={ + "content": { + "url": cloud_init_disk.path + } + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +guest = libvirt.Domain( + "kvm-guest", + name=guest_name, + type="kvm", + autostart=True, + cpu={ + "mode": "host-passthrough" + }, + memory=config.require_int("memory_mb"), + memory_unit="MiB", + vcpu=config.require_int("vcpu"), + os={ + "type": "hvm", + "type_arch": "x86_64", + "type_machine": "q35", + "firmware": "efi", + "boot_devices": [] + }, + features={ + "acpi": True + }, + devices={ + "disks": [ + { + "source": { + "volume": { + "pool": guest_volume.pool, + "volume": guest_volume.name + } + }, + "target": { + "dev": "vda", + "bus": "virtio" + }, + "driver": { + "type": "qcow2" + }, + "boot": { + "order": 1 + } + }, + { + "device": "cdrom", + "source": { + "volume": { + "pool": cloud_init_volume.pool, + "volume": cloud_init_volume.name + } + }, + "target": { + "dev": "sda", + "bus": "sata" + }, + "driver": { + "type": "raw" + } + } + ], + "interfaces": [ + { + "type": "bridge", + "model": { + "type": "virtio" + }, + "source": { + "bridge": { + "bridge": bridge_interface + } + }, + "wait_for_ip": { + "timeout": 300, + "source": "lease" + } + } + ], + "graphics": [ + { + "type": "vnc", + "vnc": { + "autoport": True, + "listen": "127.0.0.1" + } + } + ] + }, + opts=pulumi.ResourceOptions(provider=host_provider) +) + +pulumi.export("deployed_node", guest.name) diff --git a/pulumi-kvm/stack_templates/cluster_static_pulumi b/pulumi-kvm/stack_templates/cluster_static_pulumi new file mode 100644 index 0000000..5dbd6ec --- /dev/null +++ b/pulumi-kvm/stack_templates/cluster_static_pulumi @@ -0,0 +1,42 @@ +config: + kvm:ssh_user: "root" + kvm:pool_target: "default" + kvm:base_image_path: "https://repo.almalinux.org/almalinux/10/cloud/x86_64_v2/images/AlmaLinux-10-GenericCloud-latest.x86_64_v2.qcow2" + kvm:user_data_path: "../user-data/alma-node" + kvm:nodes: + - name: "db-node-01" + host: "192.168.1.1" + bridge: "br0" + vcpu: 8 + memory_mb: 16384 + disk_gb: 120 + static_ip: "192.168.1.100/24" + gateway: "192.168.1.254" + dns_servers: + - "9.9.9.9" + dns_search: + - "search.local" + - name: "db-node-02" + host: "192.168.1.2" + bridge: "br0" + vcpu: 8 + memory_mb: 16384 + disk_gb: 120 + static_ip: "192.168.1.101/24" + gateway: "192.168.1.254" + dns_servers: + - "9.9.9.9" + dns_search: + - "search.local" + - name: "db-node-03" + host: "192.168.1.3" + bridge: "br0" + vcpu: 8 + memory_mb: 16384 + disk_gb: 120 + static_ip: "192.168.1.102/24" + gateway: "192.168.1.254" + dns_servers: + - "9.9.9.9" + dns_search: + - "search.local" diff --git a/pulumi-kvm/stack_templates/single_static_pulumi b/pulumi-kvm/stack_templates/single_static_pulumi new file mode 100644 index 0000000..7d49027 --- /dev/null +++ b/pulumi-kvm/stack_templates/single_static_pulumi @@ -0,0 +1,18 @@ +config: + kvm:ssh_user: "root" + kvm:pool_target: "default" + kvm:base_image_path: "https://repo.almalinux.org/almalinux/10/cloud/x86_64_v2/images/AlmaLinux-10-GenericCloud-latest.x86_64_v2.qcow2" + kvm:user_data_path: "../user-data/alma-node.yaml" + kvm:guest_name: "prod-app-01" + kvm:target_host: "192.168.1.1" + kvm:bridge_interface: "br0" + kvm:vcpu: 2 + kvm:memory_mb: 4096 + kvm:disk_gb: 30 + kvm:net_device: "eth0" + kvm:static_ip: "192.168.1.10/24" + kvm:gateway: "192.168.1.254" + kvm:dns_servers: + - "9.9.9.9" + kvm:dns_search: + - "search.local" diff --git a/pulumi-kvm/user-data/alma-node.yaml b/pulumi-kvm/user-data/alma-node.yaml new file mode 100644 index 0000000..ff65fb6 --- /dev/null +++ b/pulumi-kvm/user-data/alma-node.yaml @@ -0,0 +1,29 @@ +#cloud-config +users: + - name: username + lock_passwd: false + passwd: "{password_hash}" + ssh_authorized_keys: + - ssh-ed25519 AAAAC..[rest of pubkey] + groups: wheel + shell: /bin/bash + sudo: ['ALL=(ALL) NOPASSWD: ALL'] +prefer_fqdn_over_hostname: true +packages: + - bind-utils + - plocate + - unzip + - tar + - policycoreutils-python-utils + - rsync + - nfs-utils + - vim + - firewalld + - curl +runcmd: + - echo "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub" >> /etc/ssh/sshd_config + - sed -i 's/^#*HostKey \/etc\/ssh\/ssh_host_ed25519_key/HostKey \/etc\/ssh\/ssh_host_ed25519_key/' /etc/ssh/sshd_config + - sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config + - sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config + - sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + - dnf upgrade -y && systemctl reboot