From d19a2c379819d982687f614740bff555026e67a2 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Tue, 8 Sep 2026 20:56:43 +0000 Subject: [PATCH 01/14] add: terraform project kubernetes_vms --- terraform/projects/kubernetes_vms/main.tf | 72 +++++++++++++++++++ terraform/projects/kubernetes_vms/provider.tf | 21 ++++++ .../projects/kubernetes_vms/variables.tf | 63 ++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 terraform/projects/kubernetes_vms/main.tf create mode 100644 terraform/projects/kubernetes_vms/provider.tf create mode 100644 terraform/projects/kubernetes_vms/variables.tf diff --git a/terraform/projects/kubernetes_vms/main.tf b/terraform/projects/kubernetes_vms/main.tf new file mode 100644 index 0000000..4653221 --- /dev/null +++ b/terraform/projects/kubernetes_vms/main.tf @@ -0,0 +1,72 @@ +module "pve_vm" { + source = "../../modules/pve_vm" + for_each = local.vm_list + node_name = each.value.node_name + vm_name = each.key + vm_cpu = each.value.vm_cpu + vm_ram = each.value.vm_ram + vm_vlan = each.value.vm_vlan + vm_tags = var.vm_tags +} + +locals { + node_stats = [ + for i, name in data.proxmox_virtual_environment_nodes.pve_nodes.names : { + name = name + free_mib = floor(data.proxmox_virtual_environment_nodes.pve_nodes.memory_available[i] / 1024 / 1024) + } + if data.proxmox_virtual_environment_nodes.pve_nodes.online[i] + ] + + max_free_mib = length(local.node_stats) > 0 ? max([for node in local.node_stats : node.free_mib]...) : 0 + + # Online nodes ordered by available memory, most available first (name as tie-break) + node_order = var.target_node != "" ? [var.target_node] : [ + for key in sort([ + for node in local.node_stats : "${format("%012d", local.max_free_mib - node.free_mib)}|${node.name}" + ]) : split("|", key)[1] + ] + + vm_layout = flatten([ + for role in ["master", "worker"] : [ + for i in range(role == "master" ? var.master_count : var.worker_count) : { + vm_name = "kub-${var.vm_env}-${substr(role, 0, 1)}${format("%02d", i + 1)}" + seq_index = (role == "master" ? 0 : var.master_count) + i + vm_role = role + } + ] + ]) + + vm_list = { + for vm in local.vm_layout : + vm.vm_name => { + node_name = length(local.node_order) > 0 ? local.node_order[vm.seq_index % length(local.node_order)] : "" + vm_cpu = var.vm_attr[vm.vm_role].cpu + vm_ram = var.vm_attr[vm.vm_role].ram + vm_vlan = var.vm_attr[vm.vm_role].vlan + vm_role = vm.vm_role + } + } + + roles = toset([for vm in local.vm_list : vm.vm_role]) +} + +data "proxmox_virtual_environment_nodes" "pve_nodes" {} + +output "vm_ip" { + value = { for k, v in module.pve_vm : k => v.vm_ip } +} + +resource "ansible_group" "vm_groups" { + for_each = local.roles + name = each.key +} + +resource "ansible_host" "vm_hosts" { + for_each = module.pve_vm + name = each.key + variables = { + ansible_host = each.value.vm_ip + } + groups = [local.vm_list[each.key].vm_role] +} \ No newline at end of file diff --git a/terraform/projects/kubernetes_vms/provider.tf b/terraform/projects/kubernetes_vms/provider.tf new file mode 100644 index 0000000..0b2729c --- /dev/null +++ b/terraform/projects/kubernetes_vms/provider.tf @@ -0,0 +1,21 @@ +terraform { + required_providers { + proxmox = { + source = "bpg/proxmox" + } + ansible = { + source = "ansible/ansible" + } + } +} + +provider "proxmox" { + endpoint = var.proxmox_endpoint + api_token = var.proxmox_api_token + insecure = false + ssh { + agent = false + private_key = file("~/.ssh/id_ed25519") + username = "root" + } +} \ No newline at end of file diff --git a/terraform/projects/kubernetes_vms/variables.tf b/terraform/projects/kubernetes_vms/variables.tf new file mode 100644 index 0000000..e94d4df --- /dev/null +++ b/terraform/projects/kubernetes_vms/variables.tf @@ -0,0 +1,63 @@ +variable "proxmox_endpoint" { + description = "Proxmox URL endpoint" + type = string +} + +variable "proxmox_api_token" { + description = "Proxmox API token" + type = string + sensitive = true +} + +variable "target_node" { + description = "Node which hosts the VMs when set; otherwise a node is selected based on available resources" + type = string + default = "" +} + +variable "master_count" { + description = "Number of Kubernetes control-plane (master) VMs to deploy" + type = number + default = 3 + + validation { + condition = var.master_count >= 1 + error_message = "master_count must be at least 1." + } +} + +variable "worker_count" { + description = "Number of Kubernetes worker VMs to deploy" + type = number + default = 3 + + validation { + condition = var.worker_count >= 1 + error_message = "worker_count must be at least 1." + } +} + +variable "vm_attr" { + description = "VM attributes" + type = map(object({ + ram = number + cpu = number + vlan = number + })) + default = { + "master" = { ram = 2048, cpu = 2, vlan = 66 } + "worker" = { ram = 4096, cpu = 2, vlan = 66 } + } +} + +variable "vm_env" { + description = "VM environment" + type = string + default = "prod" +} + +variable "vm_tags" { + description = "Tags for the VM" + type = list(any) + default = ["prod"] +} \ No newline at end of file -- 2.54.0 From fec0471881f54182a986916a1a6943dd77ea9ada Mon Sep 17 00:00:00 2001 From: Vezpi Date: Wed, 9 Sep 2026 07:37:58 +0000 Subject: [PATCH 02/14] refactor: add ssh password instead of key --- terraform/projects/kubernetes_vms/main.tf | 4 ++-- terraform/projects/kubernetes_vms/provider.tf | 4 ++-- terraform/projects/kubernetes_vms/variables.tf | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/terraform/projects/kubernetes_vms/main.tf b/terraform/projects/kubernetes_vms/main.tf index 4653221..e4a05d3 100644 --- a/terraform/projects/kubernetes_vms/main.tf +++ b/terraform/projects/kubernetes_vms/main.tf @@ -6,7 +6,7 @@ module "pve_vm" { vm_cpu = each.value.vm_cpu vm_ram = each.value.vm_ram vm_vlan = each.value.vm_vlan - vm_tags = var.vm_tags + vm_tags = ["${var.vm_env}"] } locals { @@ -21,7 +21,7 @@ locals { max_free_mib = length(local.node_stats) > 0 ? max([for node in local.node_stats : node.free_mib]...) : 0 # Online nodes ordered by available memory, most available first (name as tie-break) - node_order = var.target_node != "" ? [var.target_node] : [ + node_order = [ for key in sort([ for node in local.node_stats : "${format("%012d", local.max_free_mib - node.free_mib)}|${node.name}" ]) : split("|", key)[1] diff --git a/terraform/projects/kubernetes_vms/provider.tf b/terraform/projects/kubernetes_vms/provider.tf index 0b2729c..5a0be7a 100644 --- a/terraform/projects/kubernetes_vms/provider.tf +++ b/terraform/projects/kubernetes_vms/provider.tf @@ -15,7 +15,7 @@ provider "proxmox" { insecure = false ssh { agent = false - private_key = file("~/.ssh/id_ed25519") - username = "root" + username = var.proxmox_ssh_username + password = var.proxmox_ssh_password } } \ No newline at end of file diff --git a/terraform/projects/kubernetes_vms/variables.tf b/terraform/projects/kubernetes_vms/variables.tf index e94d4df..1525310 100644 --- a/terraform/projects/kubernetes_vms/variables.tf +++ b/terraform/projects/kubernetes_vms/variables.tf @@ -9,10 +9,16 @@ variable "proxmox_api_token" { sensitive = true } -variable "target_node" { - description = "Node which hosts the VMs when set; otherwise a node is selected based on available resources" +variable "proxmox_ssh_username" { + description = "Proxmox SSH username" type = string - default = "" + sensitive = true +} + +variable "proxmox_ssh_password" { + description = "Proxmox SSH password" + type = string + sensitive = true } variable "master_count" { @@ -55,9 +61,3 @@ variable "vm_env" { type = string default = "prod" } - -variable "vm_tags" { - description = "Tags for the VM" - type = list(any) - default = ["prod"] -} \ No newline at end of file -- 2.54.0 From 080a67835fa4fb83ae33de2b97c3ac1fdf1f24a0 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Wed, 9 Sep 2026 19:34:54 +0000 Subject: [PATCH 03/14] add: ansible inventory and test playbook --- ansible/kubernetes/inventories/tf_k8s_vms.yml | 4 ++++ ansible/kubernetes/playbooks/ping.yml | 5 +++++ ansible/kubernetes/requirements.yml | 3 +++ 3 files changed, 12 insertions(+) create mode 100644 ansible/kubernetes/inventories/tf_k8s_vms.yml create mode 100644 ansible/kubernetes/playbooks/ping.yml create mode 100644 ansible/kubernetes/requirements.yml diff --git a/ansible/kubernetes/inventories/tf_k8s_vms.yml b/ansible/kubernetes/inventories/tf_k8s_vms.yml new file mode 100644 index 0000000..c6b4cc3 --- /dev/null +++ b/ansible/kubernetes/inventories/tf_k8s_vms.yml @@ -0,0 +1,4 @@ +--- +plugin: cloud.terraform.terraform_provider +project_path: + - /tmp/semaphore/project_1/repository_1_template_7/terraform/projects/kubernetes_vms \ No newline at end of file diff --git a/ansible/kubernetes/playbooks/ping.yml b/ansible/kubernetes/playbooks/ping.yml new file mode 100644 index 0000000..ecd6527 --- /dev/null +++ b/ansible/kubernetes/playbooks/ping.yml @@ -0,0 +1,5 @@ +--- +- name: Prepare Kubernetes nodes + hosts: all + tasks: + - ansible.builtin.ping: \ No newline at end of file diff --git a/ansible/kubernetes/requirements.yml b/ansible/kubernetes/requirements.yml new file mode 100644 index 0000000..7c67e70 --- /dev/null +++ b/ansible/kubernetes/requirements.yml @@ -0,0 +1,3 @@ +--- +collections: + - name: cloud.terraform \ No newline at end of file -- 2.54.0 From 4960bbc9fb79459d173d2036698dd81eb628acf1 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Wed, 9 Sep 2026 19:51:38 +0000 Subject: [PATCH 04/14] refactor: file structure --- ansible/kubernetes/{ => collections}/requirements.yml | 0 ansible/kubernetes/{playbooks => }/ping.yml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename ansible/kubernetes/{ => collections}/requirements.yml (100%) rename ansible/kubernetes/{playbooks => }/ping.yml (100%) diff --git a/ansible/kubernetes/requirements.yml b/ansible/kubernetes/collections/requirements.yml similarity index 100% rename from ansible/kubernetes/requirements.yml rename to ansible/kubernetes/collections/requirements.yml diff --git a/ansible/kubernetes/playbooks/ping.yml b/ansible/kubernetes/ping.yml similarity index 100% rename from ansible/kubernetes/playbooks/ping.yml rename to ansible/kubernetes/ping.yml -- 2.54.0 From 59e6c3ca631b33bb169712df6e971a9ef66d9d00 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Wed, 9 Sep 2026 20:34:37 +0000 Subject: [PATCH 05/14] add: ansible k8s_node role --- .../kubernetes/collections/requirements.yml | 3 +- ansible/kubernetes/k8s_node.yml | 6 +++ .../roles/k8s_node/defaults/main.yml | 28 ++++++++++ .../roles/k8s_node/tasks/containerd.yml | 34 +++++++++++++ .../roles/k8s_node/tasks/firewall.yml | 44 ++++++++++++++++ .../kubernetes/roles/k8s_node/tasks/main.yml | 5 ++ .../roles/k8s_node/tasks/packages.yml | 36 +++++++++++++ .../roles/k8s_node/tasks/system.yml | 51 +++++++++++++++++++ 8 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 ansible/kubernetes/k8s_node.yml create mode 100644 ansible/kubernetes/roles/k8s_node/defaults/main.yml create mode 100644 ansible/kubernetes/roles/k8s_node/tasks/containerd.yml create mode 100644 ansible/kubernetes/roles/k8s_node/tasks/firewall.yml create mode 100644 ansible/kubernetes/roles/k8s_node/tasks/main.yml create mode 100644 ansible/kubernetes/roles/k8s_node/tasks/packages.yml create mode 100644 ansible/kubernetes/roles/k8s_node/tasks/system.yml diff --git a/ansible/kubernetes/collections/requirements.yml b/ansible/kubernetes/collections/requirements.yml index 7c67e70..fc84c67 100644 --- a/ansible/kubernetes/collections/requirements.yml +++ b/ansible/kubernetes/collections/requirements.yml @@ -1,3 +1,4 @@ --- collections: - - name: cloud.terraform \ No newline at end of file + - name: cloud.terraform + - name: community.general \ No newline at end of file diff --git a/ansible/kubernetes/k8s_node.yml b/ansible/kubernetes/k8s_node.yml new file mode 100644 index 0000000..c999c49 --- /dev/null +++ b/ansible/kubernetes/k8s_node.yml @@ -0,0 +1,6 @@ +--- +- name: Prepare Kubernetes nodes + hosts: all + + roles: + - role: k8s_node \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/defaults/main.yml b/ansible/kubernetes/roles/k8s_node/defaults/main.yml new file mode 100644 index 0000000..249acb4 --- /dev/null +++ b/ansible/kubernetes/roles/k8s_node/defaults/main.yml @@ -0,0 +1,28 @@ +--- +k8s_version: "1.37" + +k8s_repo: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/deb" +k8s_repo_key: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/deb/Release.key" + +k8s_packages: + - kubelet + - kubeadm + +k8s_prerequisite_packages: + - apt-transport-https + - ca-certificates + - curl + - gnupg + +k8s_firewall_admin_subnet: "192.168.66.0/24" +k8s_firewall_ssh_port: "22" +k8s_firewall_common_ports: + - "6443" + - "10250" +k8s_firewall_master_ports: + - "2379:2380" + - "10257" + - "10259" +k8s_firewall_worker_ports: + - "10256" + - "30000:32767" \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/tasks/containerd.yml b/ansible/kubernetes/roles/k8s_node/tasks/containerd.yml new file mode 100644 index 0000000..5f0d65d --- /dev/null +++ b/ansible/kubernetes/roles/k8s_node/tasks/containerd.yml @@ -0,0 +1,34 @@ +--- +- name: Install containerd + ansible.builtin.apt: + name: containerd + state: present + update_cache: true + become: yes + +- name: Create containerd configuration directory + ansible.builtin.file: + path: /etc/containerd + state: directory + mode: "0755" + become: yes + +- name: Generate default containerd configuration + ansible.builtin.shell: + cmd: containerd config default > /etc/containerd/config.toml + creates: /etc/containerd/config.toml + become: yes + +- name: Enable systemd cgroup driver in containerd + ansible.builtin.replace: + path: /etc/containerd/config.toml + regexp: '^(\s*)SystemdCgroup(\s*=\s*)false' + replace: '\1SystemdCgroup\2true' + become: yes + +- name: Enable and start containerd + ansible.builtin.systemd: + name: containerd + state: restarted + enabled: true + become: yes \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml b/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml new file mode 100644 index 0000000..9f315ed --- /dev/null +++ b/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml @@ -0,0 +1,44 @@ +--- +- name: Allow SSH from admin subnet + community.general.ufw: + rule: allow + port: "{{ k8s_firewall_ssh_port }}" + proto: tcp + from_ip: "{{ k8s_firewall_admin_subnet }}" + become: yes + +- name: Allow common Kubernetes ports from admin subnet + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + from_ip: "{{ k8s_firewall_admin_subnet }}" + become: yes + loop: "{{ k8s_firewall_common_ports }}" + +- name: Allow etcd and control plane ports on control-plane nodes + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + from_ip: "{{ k8s_firewall_admin_subnet }}" + become: yes + loop: "{{ k8s_firewall_master_ports }}" + when: inventory_hostname in groups['master'] + +- name: Allow kube-proxy and NodePort ports on worker nodes + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + from_ip: "{{ k8s_firewall_admin_subnet }}" + become: yes + loop: "{{ k8s_firewall_worker_ports }}" + when: inventory_hostname in groups['worker'] + +- name: Enable ufw and deny incoming by default + community.general.ufw: + state: enabled + policy: deny + direction: incoming + become: yes \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/tasks/main.yml b/ansible/kubernetes/roles/k8s_node/tasks/main.yml new file mode 100644 index 0000000..1b0af9f --- /dev/null +++ b/ansible/kubernetes/roles/k8s_node/tasks/main.yml @@ -0,0 +1,5 @@ +--- +- ansible.builtin.import_tasks: system.yml +- ansible.builtin.import_tasks: packages.yml +- ansible.builtin.import_tasks: containerd.yml +- ansible.builtin.import_tasks: firewall.yml \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/tasks/packages.yml b/ansible/kubernetes/roles/k8s_node/tasks/packages.yml new file mode 100644 index 0000000..a38b7c0 --- /dev/null +++ b/ansible/kubernetes/roles/k8s_node/tasks/packages.yml @@ -0,0 +1,36 @@ +--- +- name: Install prerequisite packages + ansible.builtin.apt: + name: "{{ k8s_prerequisite_packages }}" + state: present + update_cache: true + become: yes + +- name: Add Kubernetes apt key + ansible.builtin.apt_key: + url: "{{ k8s_repo_key }}" + keyring: /etc/apt/keyrings/kubernetes-apt-keyring.gpg + state: present + become: yes + +- name: Add Kubernetes apt repository + ansible.builtin.apt_repository: + repo: "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] {{ k8s_repo }} /" + state: present + filename: kubernetes + become: yes + +- name: Install kubelet and kubeadm + ansible.builtin.apt: + name: "{{ k8s_packages }}" + state: present + update_cache: true + become: yes + +- name: Hold kubelet and kubeadm packages + ansible.builtin.command: + cmd: apt-mark hold {{ item }} + loop: "{{ k8s_packages }}" + register: _kube_hold + become: yes + changed_when: "'already set on hold' not in _kube_hold.stdout" \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/tasks/system.yml b/ansible/kubernetes/roles/k8s_node/tasks/system.yml new file mode 100644 index 0000000..83dada1 --- /dev/null +++ b/ansible/kubernetes/roles/k8s_node/tasks/system.yml @@ -0,0 +1,51 @@ +--- +- name: Disable swap + ansible.builtin.shell: + cmd: swapoff -a + become: yes + changed_when: false + +- name: Comment out swap entries in fstab + ansible.builtin.replace: + path: /etc/fstab + regexp: '^(\s*[^#\s][^\s]*\s+swap\s+.*)$' + replace: '# \1' + become: yes + +- name: Load overlay kernel module + ansible.builtin.shell: + cmd: modprobe overlay + become: yes + changed_when: false + failed_when: false + +- name: Load br_netfilter kernel module + ansible.builtin.shell: + cmd: modprobe br_netfilter + become: yes + changed_when: false + +- name: Persist kernel modules + ansible.builtin.copy: + dest: /etc/modules-load.d/k8s.conf + content: | + overlay + br_netfilter + mode: "0644" + become: yes + +- name: Configure kernel network settings + ansible.builtin.copy: + dest: /etc/sysctl.d/k8s.conf + content: | + net.bridge.bridge-nf-call-iptables = 1 + net.bridge.bridge-nf-call-ip6tables = 1 + net.ipv4.ip_forward = 1 + mode: "0644" + become: yes + +- name: Apply kernel network settings + ansible.builtin.command: + cmd: sysctl --system + become: yes + changed_when: false \ No newline at end of file -- 2.54.0 From 8c2dd38e66477c58bc3484da9c605a17889d90d2 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Fri, 11 Sep 2026 21:56:16 +0000 Subject: [PATCH 06/14] refactor: node selection based on resource --- terraform/projects/kubernetes_vms/main.tf | 96 +++++++++++++++++++++-- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/terraform/projects/kubernetes_vms/main.tf b/terraform/projects/kubernetes_vms/main.tf index e4a05d3..b2631e3 100644 --- a/terraform/projects/kubernetes_vms/main.tf +++ b/terraform/projects/kubernetes_vms/main.tf @@ -27,20 +27,79 @@ locals { ]) : split("|", key)[1] ] - vm_layout = flatten([ - for role in ["master", "worker"] : [ - for i in range(role == "master" ? var.master_count : var.worker_count) : { - vm_name = "kub-${var.vm_env}-${substr(role, 0, 1)}${format("%02d", i + 1)}" - seq_index = (role == "master" ? 0 : var.master_count) + i - vm_role = role + node_free = { for node in local.node_stats : node.name => node.free_mib } + + master_ram = var.vm_attr.master.ram + worker_ram = var.vm_attr.worker.ram + + # Masters: one per node, only on nodes with enough free memory + feasible_masters = [ + for node in local.node_order : node + if local.node_free[node] >= local.master_ram + ] + + master_nodes = length(local.feasible_masters) >= var.master_count ? [ + for i in range(var.master_count) : local.feasible_masters[i] + ] : [] + + master_hosts = toset(local.master_nodes) + + # Residual capacity per node after masters are reserved + residual_mib = { + for node in local.node_order : + node => local.node_free[node] - (contains(local.master_hosts, node) ? local.master_ram : 0) + } + + max_residual_mib = length(local.node_order) > 0 ? max([for node in local.node_order : local.residual_mib[node]]...) : 0 + + worker_slots = { + for node in local.node_order : node => floor(local.residual_mib[node] / local.worker_ram) + } + + # Workers: pack nodes with residual capacity, most available first (name as tie-break) + worker_precedence = [ + for key in sort([ + for node in local.node_order : + "${format("%012d", local.max_residual_mib - local.residual_mib[node])}|${node}" + if local.worker_slots[node] >= 1 + ]) : split("|", key)[1] + ] + + worker_slots_list = [for node in local.worker_precedence : local.worker_slots[node]] + + # Water-fill: each node takes workers until it runs out of capacity or demand is met + worker_placed = [ + for i in range(length(local.worker_precedence)) : + min(local.worker_slots_list[i], max(0, var.worker_count - try(sum([for j in range(i) : local.worker_slots_list[j]]), 0))) + ] + + worker_prefix = [ + for i in range(length(local.worker_placed)) : + try(sum([for j in range(i) : local.worker_placed[j]]), 0) + ] + + master_placements = [ + for i in range(length(local.master_nodes)) : { + vm_name = "kub-${var.vm_env}-m${format("%02d", i + 1)}" + vm_role = "master" + vm_node = local.master_nodes[i] + } + ] + + worker_placements = flatten([ + for p in range(length(local.worker_precedence)) : [ + for k in range(local.worker_placed[p]) : { + vm_name = "kub-${var.vm_env}-w${format("%02d", local.worker_prefix[p] + k + 1)}" + vm_role = "worker" + vm_node = local.worker_precedence[p] } ] ]) vm_list = { - for vm in local.vm_layout : + for vm in concat(local.master_placements, local.worker_placements) : vm.vm_name => { - node_name = length(local.node_order) > 0 ? local.node_order[vm.seq_index % length(local.node_order)] : "" + node_name = vm.vm_node vm_cpu = var.vm_attr[vm.vm_role].cpu vm_ram = var.vm_attr[vm.vm_role].ram vm_vlan = var.vm_attr[vm.vm_role].vlan @@ -53,6 +112,27 @@ locals { data "proxmox_virtual_environment_nodes" "pve_nodes" {} +check "proxmox_nodes" { + assert { + condition = length(local.node_order) > 0 + error_message = "No online Proxmox node available for deployment." + } +} + +check "master_capacity" { + assert { + condition = length(local.master_nodes) == var.master_count + error_message = "Cannot place ${var.master_count} master(s): only ${length(local.feasible_masters)} online node(s) have at least ${local.master_ram} MiB free memory." + } +} + +check "worker_capacity" { + assert { + condition = try(sum(local.worker_placed), 0) == var.worker_count + error_message = "Cannot place ${var.worker_count} worker(s) (${local.worker_ram} MiB each): residual free capacity on online nodes fits only ${try(sum(local.worker_placed), 0)} worker(s)." + } +} + output "vm_ip" { value = { for k, v in module.pve_vm : k => v.vm_ip } } -- 2.54.0 From 30576351989602894c507ae2291d61a3b6ed45d4 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Fri, 11 Sep 2026 22:07:02 +0000 Subject: [PATCH 07/14] fix: reduce vm name to one digit --- terraform/projects/kubernetes_vms/main.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/projects/kubernetes_vms/main.tf b/terraform/projects/kubernetes_vms/main.tf index b2631e3..50ed4cf 100644 --- a/terraform/projects/kubernetes_vms/main.tf +++ b/terraform/projects/kubernetes_vms/main.tf @@ -80,7 +80,7 @@ locals { master_placements = [ for i in range(length(local.master_nodes)) : { - vm_name = "kub-${var.vm_env}-m${format("%02d", i + 1)}" + vm_name = "kub-${var.vm_env}-m${format("%01d", i + 1)}" vm_role = "master" vm_node = local.master_nodes[i] } @@ -89,7 +89,7 @@ locals { worker_placements = flatten([ for p in range(length(local.worker_precedence)) : [ for k in range(local.worker_placed[p]) : { - vm_name = "kub-${var.vm_env}-w${format("%02d", local.worker_prefix[p] + k + 1)}" + vm_name = "kub-${var.vm_env}-w${format("%01d", local.worker_prefix[p] + k + 1)}" vm_role = "worker" vm_node = local.worker_precedence[p] } -- 2.54.0 From fa1be640f911aee8860c6fac561f5c8e79391fa9 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Sat, 12 Sep 2026 11:05:24 +0000 Subject: [PATCH 08/14] refactor: allow input variable --- terraform/projects/kubernetes_vms/main.tf | 146 +++++++++++------- .../projects/kubernetes_vms/variables.tf | 119 ++++++++++---- 2 files changed, 184 insertions(+), 81 deletions(-) diff --git a/terraform/projects/kubernetes_vms/main.tf b/terraform/projects/kubernetes_vms/main.tf index 50ed4cf..32d4d67 100644 --- a/terraform/projects/kubernetes_vms/main.tf +++ b/terraform/projects/kubernetes_vms/main.tf @@ -29,80 +29,115 @@ locals { node_free = { for node in local.node_stats : node.name => node.free_mib } - master_ram = var.vm_attr.master.ram - worker_ram = var.vm_attr.worker.ram + # Per-role simple overrides merged over vm_attr defaults (null keeps the default) + role_overrides = { + for role, cfg in var.vm_attr : role => lookup({ + "master" = { ram = var.master_ram != null ? var.master_ram * 1024 : null, cpu = var.master_cpu, vlan = var.master_vlan } + "worker" = { ram = var.worker_ram != null ? var.worker_ram * 1024 : null, cpu = var.worker_cpu, vlan = var.worker_vlan } + }, role, {}) + } - # Masters: one per node, only on nodes with enough free memory - feasible_masters = [ + effective_vm_attr = { + for role, cfg in var.vm_attr : role => merge(cfg, { + for attr, value in local.role_overrides[role] : attr => value if value != null + }) + } + + # Control plane (max_per_node = 1): one VM per node, only on nodes with enough free memory + cp_role = [for role, cfg in local.effective_vm_attr : role if cfg.max_per_node == 1][0] + cp_attr = local.effective_vm_attr[local.cp_role] + + cp_feasible = [ for node in local.node_order : node - if local.node_free[node] >= local.master_ram + if local.node_free[node] >= local.cp_attr.ram ] - master_nodes = length(local.feasible_masters) >= var.master_count ? [ - for i in range(var.master_count) : local.feasible_masters[i] + cp_nodes = length(local.cp_feasible) >= local.cp_attr.count ? [ + for i in range(local.cp_attr.count) : local.cp_feasible[i] ] : [] - master_hosts = toset(local.master_nodes) + cp_hosts = toset(local.cp_nodes) - # Residual capacity per node after masters are reserved + # Residual capacity per node after control-plane VMs are reserved residual_mib = { for node in local.node_order : - node => local.node_free[node] - (contains(local.master_hosts, node) ? local.master_ram : 0) + node => local.node_free[node] - (contains(local.cp_hosts, node) ? local.cp_attr.ram : 0) } max_residual_mib = length(local.node_order) > 0 ? max([for node in local.node_order : local.residual_mib[node]]...) : 0 - worker_slots = { - for node in local.node_order : node => floor(local.residual_mib[node] / local.worker_ram) + # Remaining roles (max_per_node != 1): pack nodes with residual capacity, most available first + pack_roles = [for role, cfg in local.effective_vm_attr : role if cfg.max_per_node != 1] + + pack_slots = { + for role in local.pack_roles : role => { + for node in local.node_order : + node => min( + floor(local.residual_mib[node] / local.effective_vm_attr[role].ram), + local.effective_vm_attr[role].max_per_node == 0 + ? floor(local.residual_mib[node] / local.effective_vm_attr[role].ram) + : local.effective_vm_attr[role].max_per_node + ) + } } - # Workers: pack nodes with residual capacity, most available first (name as tie-break) - worker_precedence = [ - for key in sort([ - for node in local.node_order : - "${format("%012d", local.max_residual_mib - local.residual_mib[node])}|${node}" - if local.worker_slots[node] >= 1 - ]) : split("|", key)[1] - ] + pack_precedence = { + for role in local.pack_roles : role => [ + for key in sort([ + for node in local.node_order : + "${format("%012d", local.max_residual_mib - local.residual_mib[node])}|${node}" + if local.pack_slots[role][node] >= 1 + ]) : split("|", key)[1] + ] + } - worker_slots_list = [for node in local.worker_precedence : local.worker_slots[node]] + pack_slots_list = { + for role in local.pack_roles : role => + [for node in local.pack_precedence[role] : local.pack_slots[role][node]] + } - # Water-fill: each node takes workers until it runs out of capacity or demand is met - worker_placed = [ - for i in range(length(local.worker_precedence)) : - min(local.worker_slots_list[i], max(0, var.worker_count - try(sum([for j in range(i) : local.worker_slots_list[j]]), 0))) - ] + # Water-fill: each node takes VMs until it runs out of capacity or demand is met + pack_placed = { + for role in local.pack_roles : role => [ + for i in range(length(local.pack_precedence[role])) : + min(local.pack_slots_list[role][i], max(0, local.effective_vm_attr[role].count - try(sum([for j in range(i) : local.pack_slots_list[role][j]]), 0))) + ] + } - worker_prefix = [ - for i in range(length(local.worker_placed)) : - try(sum([for j in range(i) : local.worker_placed[j]]), 0) - ] + pack_prefix = { + for role in local.pack_roles : role => [ + for i in range(length(local.pack_placed[role])) : + try(sum([for j in range(i) : local.pack_placed[role][j]]), 0) + ] + } - master_placements = [ - for i in range(length(local.master_nodes)) : { - vm_name = "kub-${var.vm_env}-m${format("%01d", i + 1)}" - vm_role = "master" - vm_node = local.master_nodes[i] + cp_placements = [ + for i in range(length(local.cp_nodes)) : { + vm_name = "kub-${var.vm_env}-${substr(local.cp_role, 0, 1)}${format("%01d", i + 1)}" + vm_role = local.cp_role + vm_node = local.cp_nodes[i] } ] - worker_placements = flatten([ - for p in range(length(local.worker_precedence)) : [ - for k in range(local.worker_placed[p]) : { - vm_name = "kub-${var.vm_env}-w${format("%01d", local.worker_prefix[p] + k + 1)}" - vm_role = "worker" - vm_node = local.worker_precedence[p] - } + pack_placements = flatten([ + for role in local.pack_roles : [ + for p in range(length(local.pack_precedence[role])) : [ + for k in range(local.pack_placed[role][p]) : { + vm_name = "kub-${var.vm_env}-${substr(role, 0, 1)}${format("%01d", local.pack_prefix[role][p] + k + 1)}" + vm_role = role + vm_node = local.pack_precedence[role][p] + } + ] ] ]) vm_list = { - for vm in concat(local.master_placements, local.worker_placements) : + for vm in concat(local.cp_placements, local.pack_placements) : vm.vm_name => { node_name = vm.vm_node - vm_cpu = var.vm_attr[vm.vm_role].cpu - vm_ram = var.vm_attr[vm.vm_role].ram - vm_vlan = var.vm_attr[vm.vm_role].vlan + vm_cpu = local.effective_vm_attr[vm.vm_role].cpu + vm_ram = local.effective_vm_attr[vm.vm_role].ram + vm_vlan = local.effective_vm_attr[vm.vm_role].vlan vm_role = vm.vm_role } } @@ -119,17 +154,24 @@ check "proxmox_nodes" { } } -check "master_capacity" { +check "control_plane_capacity" { assert { - condition = length(local.master_nodes) == var.master_count - error_message = "Cannot place ${var.master_count} master(s): only ${length(local.feasible_masters)} online node(s) have at least ${local.master_ram} MiB free memory." + condition = length(local.cp_nodes) == local.cp_attr.count + error_message = "Cannot place ${local.cp_attr.count} ${local.cp_role} VM(s): only ${length(local.cp_feasible)} online node(s) have at least ${local.cp_attr.ram} MiB free memory." } } -check "worker_capacity" { +check "pack_capacity" { assert { - condition = try(sum(local.worker_placed), 0) == var.worker_count - error_message = "Cannot place ${var.worker_count} worker(s) (${local.worker_ram} MiB each): residual free capacity on online nodes fits only ${try(sum(local.worker_placed), 0)} worker(s)." + condition = length(local.pack_roles) == 0 ? true : alltrue([ + for role in local.pack_roles : + try(sum(local.pack_placed[role]), 0) == local.effective_vm_attr[role].count + ]) + error_message = "Cannot place all pack VMs: ${join(", ", [ + for role in local.pack_roles : + "${role} needs ${local.effective_vm_attr[role].count} VM(s) (${local.effective_vm_attr[role].ram} MiB each), residual free capacity fits only ${try(sum(local.pack_placed[role]), 0)} VM(s)." + if try(sum(local.pack_placed[role]), 0) != local.effective_vm_attr[role].count + ])}" } } diff --git a/terraform/projects/kubernetes_vms/variables.tf b/terraform/projects/kubernetes_vms/variables.tf index 1525310..f3ce432 100644 --- a/terraform/projects/kubernetes_vms/variables.tf +++ b/terraform/projects/kubernetes_vms/variables.tf @@ -21,38 +21,99 @@ variable "proxmox_ssh_password" { sensitive = true } -variable "master_count" { - description = "Number of Kubernetes control-plane (master) VMs to deploy" - type = number - default = 3 - - validation { - condition = var.master_count >= 1 - error_message = "master_count must be at least 1." - } -} - -variable "worker_count" { - description = "Number of Kubernetes worker VMs to deploy" - type = number - default = 3 - - validation { - condition = var.worker_count >= 1 - error_message = "worker_count must be at least 1." - } -} - variable "vm_attr" { - description = "VM attributes" + description = "VM attributes per role. max_per_node = 0 means unlimited VMs per node." type = map(object({ - ram = number - cpu = number - vlan = number + ram = number + cpu = number + vlan = number + count = number + max_per_node = number })) default = { - "master" = { ram = 2048, cpu = 2, vlan = 66 } - "worker" = { ram = 4096, cpu = 2, vlan = 66 } + "master" = { ram = 2048, cpu = 2, vlan = 66, count = 3, max_per_node = 1 } + "worker" = { ram = 4096, cpu = 2, vlan = 66, count = 3, max_per_node = 0 } + } + + validation { + condition = alltrue([for v in values(var.vm_attr) : v.count >= 1]) + error_message = "Each role must deploy at least 1 VM." + } + + validation { + condition = alltrue([for v in values(var.vm_attr) : v.max_per_node >= 0]) + error_message = "max_per_node must be 0 (unlimited) or a positive number." + } + + validation { + condition = length([for v in values(var.vm_attr) : v if v.max_per_node == 1]) == 1 + error_message = "Exactly one role must have max_per_node = 1 (the control-plane role)." + } +} + +variable "master_cpu" { + description = "Master role CPU override. null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.master_cpu == null || var.master_cpu >= 1 + error_message = "master_cpu must be null or at least 1." + } +} + +variable "master_ram" { + description = "Master role RAM override in GiB (converted to MiB internally). null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.master_ram == null || var.master_ram >= 1 + error_message = "master_ram must be null or at least 1 GiB." + } +} + +variable "master_vlan" { + description = "Master role VLAN override. null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.master_vlan == null || var.master_vlan >= 1 + error_message = "master_vlan must be null or at least 1." + } +} + +variable "worker_cpu" { + description = "Worker role CPU override. null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.worker_cpu == null || var.worker_cpu >= 1 + error_message = "worker_cpu must be null or at least 1." + } +} + +variable "worker_ram" { + description = "Worker role RAM override in GiB (converted to MiB internally). null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.worker_ram == null || var.worker_ram >= 1 + error_message = "worker_ram must be null or at least 1 GiB." + } +} + +variable "worker_vlan" { + description = "Worker role VLAN override. null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.worker_vlan == null || var.worker_vlan >= 1 + error_message = "worker_vlan must be null or at least 1." } } @@ -60,4 +121,4 @@ variable "vm_env" { description = "VM environment" type = string default = "prod" -} +} \ No newline at end of file -- 2.54.0 From d6ae654a80ca80a0d67dd53ad66583f34a9fde8f Mon Sep 17 00:00:00 2001 From: Vezpi Date: Sat, 12 Sep 2026 11:17:07 +0000 Subject: [PATCH 09/14] add: master and worker count --- terraform/projects/kubernetes_vms/main.tf | 10 +++-- .../projects/kubernetes_vms/variables.tf | 41 ++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/terraform/projects/kubernetes_vms/main.tf b/terraform/projects/kubernetes_vms/main.tf index 32d4d67..a78a3b3 100644 --- a/terraform/projects/kubernetes_vms/main.tf +++ b/terraform/projects/kubernetes_vms/main.tf @@ -32,15 +32,19 @@ locals { # Per-role simple overrides merged over vm_attr defaults (null keeps the default) role_overrides = { for role, cfg in var.vm_attr : role => lookup({ - "master" = { ram = var.master_ram != null ? var.master_ram * 1024 : null, cpu = var.master_cpu, vlan = var.master_vlan } - "worker" = { ram = var.worker_ram != null ? var.worker_ram * 1024 : null, cpu = var.worker_cpu, vlan = var.worker_vlan } + "master" = { count = var.master_count, ram = var.master_ram != null ? var.master_ram * 1024 : null, cpu = var.master_cpu, vlan = var.master_vlan } + "worker" = { count = var.worker_count, ram = var.worker_ram != null ? var.worker_ram * 1024 : null, cpu = var.worker_cpu, vlan = var.worker_vlan } }, role, {}) } + # Internal placement rule: the control-plane role (master) sits one per node, pack roles are unlimited + cp_role_name = "master" + role_max_per_node = { for role, cfg in var.vm_attr : role => role == local.cp_role_name ? 1 : 0 } + effective_vm_attr = { for role, cfg in var.vm_attr : role => merge(cfg, { for attr, value in local.role_overrides[role] : attr => value if value != null - }) + }, { max_per_node = local.role_max_per_node[role] }) } # Control plane (max_per_node = 1): one VM per node, only on nodes with enough free memory diff --git a/terraform/projects/kubernetes_vms/variables.tf b/terraform/projects/kubernetes_vms/variables.tf index f3ce432..fff808f 100644 --- a/terraform/projects/kubernetes_vms/variables.tf +++ b/terraform/projects/kubernetes_vms/variables.tf @@ -22,32 +22,32 @@ variable "proxmox_ssh_password" { } variable "vm_attr" { - description = "VM attributes per role. max_per_node = 0 means unlimited VMs per node." + description = "VM attributes per role. The control-plane role (master) is placed one per node; other roles are packed without a per-node limit." type = map(object({ - ram = number - cpu = number - vlan = number - count = number - max_per_node = number + ram = number + cpu = number + vlan = number + count = number })) default = { - "master" = { ram = 2048, cpu = 2, vlan = 66, count = 3, max_per_node = 1 } - "worker" = { ram = 4096, cpu = 2, vlan = 66, count = 3, max_per_node = 0 } + "master" = { ram = 2048, cpu = 2, vlan = 66, count = 3 } + "worker" = { ram = 4096, cpu = 2, vlan = 66, count = 3 } } validation { condition = alltrue([for v in values(var.vm_attr) : v.count >= 1]) error_message = "Each role must deploy at least 1 VM." } +} + +variable "master_count" { + description = "Master role VM count override. null keeps the vm_attr default." + type = number + default = null validation { - condition = alltrue([for v in values(var.vm_attr) : v.max_per_node >= 0]) - error_message = "max_per_node must be 0 (unlimited) or a positive number." - } - - validation { - condition = length([for v in values(var.vm_attr) : v if v.max_per_node == 1]) == 1 - error_message = "Exactly one role must have max_per_node = 1 (the control-plane role)." + condition = var.master_count == null || var.master_count >= 1 + error_message = "master_count must be null or at least 1." } } @@ -84,6 +84,17 @@ variable "master_vlan" { } } +variable "worker_count" { + description = "Worker role VM count override. null keeps the vm_attr default." + type = number + default = null + + validation { + condition = var.worker_count == null || var.worker_count >= 1 + error_message = "worker_count must be null or at least 1." + } +} + variable "worker_cpu" { description = "Worker role CPU override. null keeps the vm_attr default." type = number -- 2.54.0 From 57c42b94ea61176cebf48d6b679bc11efa15273b Mon Sep 17 00:00:00 2001 From: Vezpi Date: Sat, 12 Sep 2026 20:39:00 +0000 Subject: [PATCH 10/14] docs: create README.me --- ansible/kubernetes/README.md | 97 +++++++++++++++++++++ terraform/projects/kubernetes_vms/README.md | 93 ++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 ansible/kubernetes/README.md create mode 100644 terraform/projects/kubernetes_vms/README.md diff --git a/ansible/kubernetes/README.md b/ansible/kubernetes/README.md new file mode 100644 index 0000000..0768604 --- /dev/null +++ b/ansible/kubernetes/README.md @@ -0,0 +1,97 @@ +# Ansible – Kubernetes nodes + +Prepares the Kubernetes VM nodes provisioned by the +[`kubernetes_vms` Terraform project](../../terraform/projects/kubernetes_vms/). + +## What it does + +The `k8s_node` playbook configures each node for `kubeadm`/`kubelet`: + +1. **System** – disables swap (runtime + fstab), loads `overlay` and `br_netfilter`, + persists kernel modules, applies the Kubernetes `sysctl` settings. +2. **Packages** – installs prerequisites, adds the official Kubernetes apt repo/key, + installs and holds `kubeadm` and `kubelet`. +3. **containerd** – installs it, generates the default config, enables the + `SystemdCgroup` driver, starts and enables the service. +4. **Firewall** – configures UFW per role: SSH + common Kubernetes ports from the admin + subnet, control-plane ports (`2379:2380`, `10257`, `10259`) on masters, worker ports + (`10256`, `30000:32767`) on workers, default deny incoming. + +Group membership (who is a `master` / `worker`) is resolved from the Terraform inventory, +so the role-specific firewall rules apply automatically. + +> Cluster bootstrap (`kubeadm init` / `kubeadm join`) is **not** part of this playbook +> and is done manually per the migration roadmap. + +## Inventory + +The inventory is generated from Terraform state via the `cloud.terraform` inventory +plugin: + +```yaml +# inventories/tf_k8s_vms.yml +--- +plugin: cloud.terraform.terraform_provider +project_path: + - /tmp/semaphore/project_1/repository_1_template_7/terraform/projects/kubernetes_vms +``` + +> The `project_path` is a Semaphore runtime checkout path. If you run locally, point it +> at your own `terraform/projects/kubernetes_vms` directory. The same `ansible` provider +> that writes the inventory is used by Terraform, so hosts/groups are always in sync with +> the applied VMs. + +## Requirements + +Install the collections referenced by `collections/requirements.yml`: + +```bash +ansible-galaxy collection install -r collections/requirements.yml +``` + +- `cloud.terraform` – Terraform inventory plugin +- `community.general` – `ufw` module + +## Usage + +```bash +# Connectivity check +ansible-playbook -i inventories/tf_k8s_vms.yml ping.yml + +# Prepare all Kubernetes nodes +ansible-playbook -i inventories/tf_k8s_vms.yml k8s_node.yml +``` + +Both playbooks target `all`; the inventory plugin runs from the Terraform project path. + +## Role variables + +Defined in `roles/k8s_node/defaults/main.yml` (override as needed): + +| Variable | Default | Description | +| ------------------------------- | ---------------------------- | -------------------------------------- | +| `k8s_version` | `1.37` | Kubernetes minor version | +| `k8s_repo` | `pkgs.k8s.io` core v | Kubernetes apt repository | +| `k8s_repo_key` | matching `Release.key` URL | Repository signing key | +| `k8s_packages` | `kubelet`, `kubeadm` | Packages installed and held | +| `k8s_prerequisite_packages` | `apt-transport-https`, `ca-certificates`, `curl`, `gnupg` | Build deps for the repo | +| `k8s_firewall_admin_subnet` | `192.168.66.0/24` | Subnet allowed through UFW | +| `k8s_firewall_ssh_port` | `22` | SSH port | +| `k8s_firewall_common_ports` | `6443`, `10250` | Ports open on all nodes | +| `k8s_firewall_master_ports` | `2379:2380`, `10257`, `10259` | Ports open on masters | +| `k8s_firewall_worker_ports` | `10256`, `30000:32767` | Ports open on workers | + +## File reference + +| Path | Purpose | +| ---------------------------------------- | ------------------------------------ | +| `k8s_node.yml` | Main playbook (role `k8s_node`) | +| `ping.yml` | Connectivity smoke-test playbook | +| `inventories/tf_k8s_vms.yml` | Terraform-backed inventory | +| `collections/requirements.yml` | Required Ansible collections | +| `roles/k8s_node/tasks/main.yml` | Task dispatcher | +| `roles/k8s_node/tasks/system.yml` | Swap, kernel modules, sysctl | +| `roles/k8s_node/tasks/packages.yml` | Kubernetes repo + kubeadm/kubelet | +| `roles/k8s_node/tasks/containerd.yml` | containerd install/config | +| `roles/k8s_node/tasks/firewall.yml` | Per-role UFW rules | +| `roles/k8s_node/defaults/main.yml` | Role defaults | \ No newline at end of file diff --git a/terraform/projects/kubernetes_vms/README.md b/terraform/projects/kubernetes_vms/README.md new file mode 100644 index 0000000..3e65e20 --- /dev/null +++ b/terraform/projects/kubernetes_vms/README.md @@ -0,0 +1,93 @@ +# Terraform – Kubernetes VMs + +Provisions the Kubernetes node VMs on Proxmox for the homelab Kubernetes cluster. + +## Overview + +This Terraform project clones the `ubuntu-noble-cloud` template into virtual machines +on the proxmox nodes, following a capacity-aware placement algorithm: + +1. **Control plane (master)** VMs are spread one per node, on the nodes with the most + free memory that still fit the role's `ram`. +2. **Pack roles (worker)** are water-filled over the remaining capacity, most-available + node first, without a per-node limit. + +Each VM is tagged with the environment (`vm_env`) and registered in a Terraform-backed +Ansible inventory (via the `ansible` provider), so the `k8s_node` playbook can target +them by role group. + +## Placement details + +- Online Proxmox nodes are ordered by available memory (most available first, node name + as tie-break). +- The control-plane role must fit entirely on distinct nodes with `free_mib >= ram`. +- Workers are packed into residual capacity (`free_mib - ram_per_cp_vm_on_node`), + again most-available first. +- Naming: `kub--m` for masters and `kub--w` for workers, e.g. + `kub-prod-m01`, `kub-prod-w01`. + +Deployment is blocked by `check` blocks when the fleet cannot be placed: + +- no online node is available, +- not enough nodes have the free memory required for the control-plane role, +- residual capacity cannot fit all worker VMs. + +## Prerequisites + +- Proxmox with: + - a cloneable template named `ubuntu-noble-cloud` (from the `pve_vm` module), + - a `ceph-workload` datastore, + - VLAN bridge `vlan` (default `vlan66`), + - an API token and an SSH user/password with rights to create VMs on the nodes. +- Terraform with the `bpg/proxmox` and `ansible/ansible` providers (init downloads them). + +## Input variables + +| Variable | Type | Required | Default | Description | +| ---------------------------- | -------- | -------- | ---------------------------- | -------------------------------------------------- | +| `proxmox_endpoint` | string | yes | – | Proxmox API URL | +| `proxmox_api_token` | string | yes | – | Proxmox API token (secret) | +| `proxmox_ssh_username` | string | yes | – | Proxmox SSH user for placement (secret) | +| `proxmox_ssh_password` | string | yes | – | Proxmox SSH password (secret) | +| `vm_env` | string | no | `"prod"` | Environment, part of the VM name and tags | +| `master_count` | number | no | `null` → `vm_attr` default | Number of master VMs | +| `master_cpu` | number | no | `null` → `vm_attr` default | vCPU cores for masters | +| `master_ram` | number | no | `null` → `vm_attr` default | Master RAM in **GiB** (converted to MiB internally)| +| `master_vlan` | number | no | `null` → `vm_attr` default | VLAN for masters | +| `worker_count` | number | no | `null` → `vm_attr` default | Number of worker VMs | +| `worker_cpu` | number | no | `null` → `vm_attr` default | vCPU cores for workers | +| `worker_ram` | number | no | `null` → `vm_attr` default | Worker RAM in **GiB** | +| `worker_vlan` | number | no | `null` → `vm_attr` default | VLAN for workers | +| `vm_attr` | map(… ) | no | see below | Full per-role VM defaults | + +The `vm_attr` map holds the fallback attributes used when the corresponding +`master_*`/`worker_*` override is `null`: + +```hcl +vm_attr = { + master = { ram = 2048, cpu = 2, vlan = 66, count = 3 } + worker = { ram = 4096, cpu = 2, vlan = 66, count = 3 } +} +``` + +> `ram` values inside `vm_attr` are MiB; the `master_ram` / `worker_ram` overrides are +> set in GiB and converted internally (`× 1024`). The placement math always works in MiB. + +## Usage + +```bash +terraform init +terraform plan +terraform apply +``` + +The `vm_ip` output maps VM name to assigned IP and feeds the Ansible inventory +(`inventories/`) of the `k8s_node` playbook. + +## File reference + +| File | Purpose | +| ------------ | --------------------------------------------------------- | +| `main.tf` | Placement algorithm, `pve_vm` module wiring, checks | +| `variables.tf` | Clusters, Proxmox credentials, per-role defaults/overrides | +| `provider.tf` | `proxmox` and `ansible` provider configuration | \ No newline at end of file -- 2.54.0 From ee0f1c8c1c56512c599aa3f0155921348b1ab298 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Mon, 14 Sep 2026 07:23:48 +0000 Subject: [PATCH 11/14] add: cilium firewall ports to open --- .../roles/k8s_node/defaults/main.yml | 7 ++++++- .../roles/k8s_node/tasks/firewall.yml | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ansible/kubernetes/roles/k8s_node/defaults/main.yml b/ansible/kubernetes/roles/k8s_node/defaults/main.yml index 249acb4..da97458 100644 --- a/ansible/kubernetes/roles/k8s_node/defaults/main.yml +++ b/ansible/kubernetes/roles/k8s_node/defaults/main.yml @@ -25,4 +25,9 @@ k8s_firewall_master_ports: - "10259" k8s_firewall_worker_ports: - "10256" - - "30000:32767" \ No newline at end of file + - "30000:32767" +k8s_firewall_cilium_udp_ports: + - "8472" + - "4240" +k8s_firewall_cilium_tcp_ports: + - "4240" \ No newline at end of file diff --git a/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml b/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml index 9f315ed..a8306b2 100644 --- a/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml +++ b/ansible/kubernetes/roles/k8s_node/tasks/firewall.yml @@ -36,6 +36,24 @@ loop: "{{ k8s_firewall_worker_ports }}" when: inventory_hostname in groups['worker'] +- name: Allow Cilium VXLAN and health check UDP ports from admin subnet + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: udp + from_ip: "{{ k8s_firewall_admin_subnet }}" + become: yes + loop: "{{ k8s_firewall_cilium_udp_ports }}" + +- name: Allow Cilium health check TCP ports from admin subnet + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + from_ip: "{{ k8s_firewall_admin_subnet }}" + become: yes + loop: "{{ k8s_firewall_cilium_tcp_ports }}" + - name: Enable ufw and deny incoming by default community.general.ufw: state: enabled -- 2.54.0 From 6c77ed2a1f90d104e861a9ad5e582484a96dd683 Mon Sep 17 00:00:00 2001 From: Vezpi Date: Tue, 15 Sep 2026 21:35:53 +0000 Subject: [PATCH 12/14] add: patch Proxmox nag --- ansible/proxmox/update_proxmox.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ansible/proxmox/update_proxmox.yml b/ansible/proxmox/update_proxmox.yml index 8d5205f..127fc49 100644 --- a/ansible/proxmox/update_proxmox.yml +++ b/ansible/proxmox/update_proxmox.yml @@ -103,7 +103,24 @@ upgrade: full autoremove: true autoclean: true - + + - name: Check whether Proxmox subscription nag is already patched + ansible.builtin.shell: + cmd: > + grep -Fq + 'return (typeof orig_cmd === "function" && (orig_cmd(), true));' + /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js + register: proxmox_nag_patch + changed_when: false + failed_when: false + + - name: Patch Proxmox subscription nag + ansible.builtin.lineinfile: + path: /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js + insertafter: 'checked_command: function (orig_cmd) {' + line: ' return (typeof orig_cmd === "function" && (orig_cmd(), true));' + when: proxmox_nag_patch.rc != 0 + - name: Disable Ceph rebalancing ansible.builtin.command: ceph osd set noout -- 2.54.0 From a5b6b9d2495fc226c9a916a5769e8a2b930ae4fa Mon Sep 17 00:00:00 2001 From: Vezpi Date: Wed, 16 Sep 2026 05:58:02 +0000 Subject: [PATCH 13/14] fix: admin subnet to VLAN 88 --- ansible/kubernetes/roles/k8s_node/defaults/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/kubernetes/roles/k8s_node/defaults/main.yml b/ansible/kubernetes/roles/k8s_node/defaults/main.yml index da97458..828a114 100644 --- a/ansible/kubernetes/roles/k8s_node/defaults/main.yml +++ b/ansible/kubernetes/roles/k8s_node/defaults/main.yml @@ -14,7 +14,7 @@ k8s_prerequisite_packages: - curl - gnupg -k8s_firewall_admin_subnet: "192.168.66.0/24" +k8s_firewall_admin_subnet: "192.168.88.0/24" k8s_firewall_ssh_port: "22" k8s_firewall_common_ports: - "6443" -- 2.54.0 From c343613b7486d896af8c48c2e7d361cfba2ba33b Mon Sep 17 00:00:00 2001 From: Vezpi Date: Fri, 18 Sep 2026 13:09:36 +0000 Subject: [PATCH 14/14] fix: remove cilium 4240 udp port --- ansible/kubernetes/roles/k8s_node/defaults/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/ansible/kubernetes/roles/k8s_node/defaults/main.yml b/ansible/kubernetes/roles/k8s_node/defaults/main.yml index 828a114..37471eb 100644 --- a/ansible/kubernetes/roles/k8s_node/defaults/main.yml +++ b/ansible/kubernetes/roles/k8s_node/defaults/main.yml @@ -28,6 +28,5 @@ k8s_firewall_worker_ports: - "30000:32767" k8s_firewall_cilium_udp_ports: - "8472" - - "4240" k8s_firewall_cilium_tcp_ports: - "4240" \ No newline at end of file -- 2.54.0