From 799b19d445432b73a508c5468cb50fb879a78f22 Mon Sep 17 00:00:00 2001 From: James Allenby Date: Wed, 17 Jun 2026 22:31:40 +0100 Subject: [PATCH] Initial commit --- .editorconfig | 10 ++ .gitignore | 7 + Justfile | 60 ++++++++ ansible/hosts.ini | 5 + ansible/playbook.yaml | 36 +++++ ansible/playbooks/master-playbook.yaml | 154 +++++++++++++++++++ ansible/playbooks/setup-playbook.yaml | 87 +++++++++++ ansible/playbooks/worker-playbook.yaml | 24 +++ infra/bootstrap.sh | 32 ++++ infra/main.tofu | 202 +++++++++++++++++++++++++ infra/variables.tofu | 18 +++ 11 files changed, 635 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 ansible/hosts.ini create mode 100644 ansible/playbook.yaml create mode 100644 ansible/playbooks/master-playbook.yaml create mode 100644 ansible/playbooks/setup-playbook.yaml create mode 100644 ansible/playbooks/worker-playbook.yaml create mode 100644 infra/bootstrap.sh create mode 100644 infra/main.tofu create mode 100644 infra/variables.tofu diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d5c7d13 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.tofu] +indent_size = 2 +indent_style = space diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28641cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# General rules +.* +terraform.tfstate +terraform.tfstate.backup + +!.gitignore +!.editorconfig diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..5b9769e --- /dev/null +++ b/Justfile @@ -0,0 +1,60 @@ +#!/usr/bin/env just + +opentofu_dir := "infra/" + +up: + just gen-ssh-pair + tofu -chdir=infra init + tofu -chdir=infra apply -auto-approve + + just playbook + just get-admin-conf + +tofu-init: + tofu -chdir={{ opentofu_dir }} init -upgrade + +tofu-apply: tofu-init gen-ssh-pair + tofu -chdir={{ opentofu_dir }} apply -auto-approve + +tofu-destroy: tofu-init + tofu -chdir={{ opentofu_dir }} apply -auto-approve -destroy + +build-inventory: + #!/usr/bin/env bash + master_ips=$(tofu -chdir=infra output -json master_addresses) + worker_ips=$(tofu -chdir=infra output -json worker_addresses) + + # Build the inventory file cleanly using bash loops over the JSON arrays + { + echo "[masters]" + for i in $(seq 0 $(jq 'length - 1' <<< "${master_ips}")); do + ip=$(jq -r ".[$i]" <<< "${master_ips}") + echo "master-${i} ansible_host=${ip} ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent" + done + + echo "" + echo "[workers]" + for i in $(seq 0 $(jq 'length - 1' <<< "${worker_ips}")); do + ip=$(jq -r ".[$i]" <<< "${worker_ips}") + echo "worker-${i} ansible_host=${ip} ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent" + done + } > ansible/hosts.ini + +playbook: build-inventory + ansible-playbook --inventory ansible/hosts.ini ansible/playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no" + # ansible-playbook --inventory ansible/hosts.ini ansible/setup-playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no" + # ansible-playbook --inventory ansible/hosts.ini ansible/master-playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no" + # ansible-playbook --inventory ansible/hosts.ini ansible/worker-playbook.yaml --ssh-extra-args "-o StrictHostKeyChecking=no" + +down: tofu-destroy + rm ansible/hosts.ini || true + rm -r ansible/.ssh/ || true + rm admin.conf || true + +gen-ssh-pair: + #!/usr/bin/env bash + mkdir -p ansible/.ssh + [ ! -f ansible/.ssh/key ] && ssh-keygen -f ansible/.ssh/key -P '' || true + +get-admin-conf: + incus exec master-0 -- cat /etc/kubernetes/admin.conf > k8s-admin.conf diff --git a/ansible/hosts.ini b/ansible/hosts.ini new file mode 100644 index 0000000..d02ca0b --- /dev/null +++ b/ansible/hosts.ini @@ -0,0 +1,5 @@ +[masters] +master-0 ansible_host=10.150.0.2 ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent +master-1 ansible_host=10.150.0.4 ansible_user=root ansible_ssh_private_key_file=ansible/.ssh/key ansible_python_interpreter=auto_silent + +[workers] diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml new file mode 100644 index 0000000..c185968 --- /dev/null +++ b/ansible/playbook.yaml @@ -0,0 +1,36 @@ +--- +- name: Initialise empty disk with BTRFS + hosts: all + vars: + target_disk: /dev/disk/by-id/ata-QEMU_HARDDISK_incus_ext + tasks: + - name: Ensure btrfs-progs, parted and blkid is installed + ansible.builtin.package: + name: "{{ item }}" + state: present + loop: + - btrfs-progs + - parted + - blkid + - name: Ensure external disk has single partition on GPT + community.general.parted: + device: "{{ target_disk }}" + name: ext + number: 1 + label: gpt + state: present + - name: Ensure BTRFS is formatted on external disk + community.general.filesystem: + dev: "{{ target_disk }}-part1" + fstype: btrfs + - name: Mount external disk to /mnt + ansible.posix.mount: + src: "{{ target_disk }}-part1" + path: /mnt + fstype: btrfs + state: mounted + + # - name: Create BTRFS filesystem on external disk + # community.general.filesystem: + # fstype: btrfs + # dev: "{{ target_disk }}" diff --git a/ansible/playbooks/master-playbook.yaml b/ansible/playbooks/master-playbook.yaml new file mode 100644 index 0000000..8733958 --- /dev/null +++ b/ansible/playbooks/master-playbook.yaml @@ -0,0 +1,154 @@ +- name: Primary Master Initialisation + hosts: masters[0] + tasks: + - name: Initialise high-availability Kubernetes cluster + ansible.builtin.command: >- + kubeadm init + --pod-network-cidr="172.16.0.0/16" + --control-plane-endpoint="{{ ansible_default_ipv4.address }}:6443" + --upload-certs + args: + creates: /etc/kubernetes/admin.conf + - name: Copy Kubernetes Admin Config + ansible.builtin.command: "{{ item }}" + loop: + - mkdir -p $HOME/.kube + - cp /etc/kubernetes/admin.conf $HOME/.kube/config + - chown $(id -u):$(id -g) $HOME/.kube/config + args: + creates: $HOME/.kube/config + + - name: Install Pod Network (Calico) + ansible.builtin.shell: kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/calico.yaml > pod_network_setup.txt + args: + chdir: $HOME + creates: pod_network_setup.txt + + - name: Add Helm repositories + kubernetes.core.helm_repository: + name: "{{ item.name }}" + repo_url: "{{ item.url }}" + loop: + - name: metrics-server + url: https://kubernetes-sigs.github.io/metrics-server/ + - name: prometheus-community + url: https://prometheus-community.github.io/helm-charts + - name: longhorn + url: https://charts.longhorn.io + - name: traefik + url: https://traefik.github.io/charts + - name: minecraft-server-charts + url: https://itzg.github.io/minecraft-server-charts/ + + - name: Install Metrics Server + kubernetes.core.helm: + release_name: metrics-server + release_namespace: kube-system + chart_ref: metrics-server/metrics-server + chart_version: 3.13.0 + values: + args: + - --kubelet-insecure-tls + + - name: Install Longhorn + kubernetes.core.helm: + release_name: longhorn + release_namespace: longhorn-system + create_namespace: true + chart_ref: longhorn/longhorn + chart_version: 1.12.0 + + - name: Install Prometheus + kubernetes.core.helm: + release_name: prometheus + release_namespace: monitoring + create_namespace: true + chart_ref: prometheus-community/prometheus + chart_version: 29.10.0 + state: absent + + - name: Install Traefik + kubernetes.core.helm: + release_name: traefik + release_namespace: default + create_namespace: true + chart_ref: traefik/traefik + chart_version: 40.2.0 + values: + deployment: + kind: DaemonSet + securityContext: + capabilities: + drop: [ALL] + add: [NET_BIND_SERVICE] + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + ports: + web: + port: 80 + containerPort: 80 + hostPort: 80 + websecure: + port: 443 + containerPort: 443 + hostPort: 443 + + - name: Install Minecraft + kubernetes.core.helm: + release_name: minecraft + release_namespace: default + chart_ref: minecraft-server-charts/minecraft + chart_version: 5.1.3 + values: + minecraftServer: + eula: true + persistence: + dataDir: + enabled: true + + - name: Extract Control Plane Decryption Key & Join Token + block: + - name: Generate Fresh Join Command + ansible.builtin.command: kubeadm token create --print-join-command + register: join_command_raw + + - name: Upload Certs and Capture Certificate Key + shell: kubeadm init phase upload-certs --upload-certs | tail -n 1 + register: cert_key_raw + + - name: Set facts across playbooks + ansible.builtin.set_fact: + k8s_join_base: "{{ join_command_raw.stdout }}" + k8s_cert_key: "{{ cert_key_raw.stdout }}" + delegate_to: localhost + delegate_facts: true + + +- name: Join Secondary Control Planes + hosts: masters:!masters[0] # <--- Targets masters 1 and 2 + tasks: + - name: Join Cluster as Control Plane Node + ansible.builtin.shell: "{{ hostvars['localhost']['k8s_join_base'] }} --control-plane --certificate-key {{ hostvars['localhost']['k8s_cert_key'] }}" + args: + creates: /etc/kubernetes/admin.conf + + - name: Copy Kubernetes Admin Config + ansible.builtin.shell: "{{ item }}" + loop: + - mkdir -p $HOME/.kube + - cp /etc/kubernetes/admin.conf $HOME/.kube/config + - chown $(id -u):$(id -g) $HOME/.kube/config + args: + creates: $HOME/.kube/config + +- name: Remove control plane taint + hosts: masters[0] + tasks: + - name: Remove control plane NoSchedule taint from all nodes + kubernetes.core.k8s_taint: + state: absent + name: "{{ item }}" + taints: + - key: node-role.kubernetes.io/control-plane + effect: NoSchedule + loop: "{{ groups['masters'] }}" diff --git a/ansible/playbooks/setup-playbook.yaml b/ansible/playbooks/setup-playbook.yaml new file mode 100644 index 0000000..ff10fd6 --- /dev/null +++ b/ansible/playbooks/setup-playbook.yaml @@ -0,0 +1,87 @@ +- name: Initialise Master and Worker Nodes + hosts: all + tasks: + - name: Enable IPv4 forwarding + ansible.posix.sysctl: + name: net.ipv4.ip_forward + value: 1 + sysctl_set: true + + - name: Enable Kernel Modules + community.general.modprobe: + name: "{{ item }}" + persistent: present + loop: + - br_netfilter + - dm-crypt + + - name: Ensure /sys mount is set to shared for container runtimes + block: + - name: Remount /sys as shared immediately + ansible.builtin.command: mount --make-rshared {{ item }} + loop: + - / + - /sys + - /run + changed_when: false + + - name: Create Alpine local.d script for permanent shared mount + ansible.builtin.copy: + dest: /etc/local.d/mount-shared.start + content: | + #!/bin/sh + mount --make-rshared / + mount --make-rshared /sys + mount --make-rshared /run + owner: root + group: root + mode: '0755' + + - name: Ensure Alpine local service is enabled on boot + ansible.builtin.service: + name: local + enabled: true + runlevel: default + + - name: Install tools + ansible.builtin.package: + name: "{{ item }}" + state: present + loop: + - htop + + - name: Install kubectl, kubelet, kubeadm and containerd + ansible.builtin.package: + name: "{{ item }}" + state: present + loop: + - kubectl + - kubeadm + - kubelet + - containerd + - cni-plugins + - helm + - open-iscsi + + - name: Start kubelet and containerd + ansible.builtin.service: + name: "{{ item }}" + enabled: true + state: started + loop: + - kubelet + - containerd + - iscsid + + - name: Add /opt/cni/bin to containerd CNI binary directories + ansible.builtin.replace: + path: /etc/containerd/config.toml + regexp: "bin_dirs = \\['/usr/libexec/cni'\\]" + replace: "bin_dirs = ['/opt/cni/bin', '/usr/libexec/cni']" + notify: Restart containerd + + handlers: + - name: Restart containerd + ansible.builtin.service: + name: containerd + state: restarted diff --git a/ansible/playbooks/worker-playbook.yaml b/ansible/playbooks/worker-playbook.yaml new file mode 100644 index 0000000..9bb4099 --- /dev/null +++ b/ansible/playbooks/worker-playbook.yaml @@ -0,0 +1,24 @@ +- name: Extract Worker Join Token from Primary Master + hosts: masters[0] # <--- Targets only the first master safely + gather_facts: false + tasks: + - name: Generate worker join command + ansible.builtin.command: kubeadm token create --print-join-command + register: join_command_raw + changed_when: false # Reading/generating a token text string changes no host state + + - name: Save join command globally + ansible.builtin.set_fact: + k8s_worker_join: "{{ join_command_raw.stdout }}" + delegate_to: localhost + delegate_facts: true + +- name: Join Worker Nodes to Cluster + hosts: workers + gather_facts: false + tasks: + - name: Join cluster + ansible.builtin.shell: "{{ hostvars['localhost']['k8s_worker_join'] }} >> node_joined.txt" + args: + chdir: $HOME + creates: node_joined.txt diff --git a/infra/bootstrap.sh b/infra/bootstrap.sh new file mode 100644 index 0000000..05c3221 --- /dev/null +++ b/infra/bootstrap.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env sh +set -e + +err() { echo "$@" >&2; exit 1; } +log() { echo "$@" >&2; } + +[ "$(id -u)" -ne 0 ] && err "You must run this script as root" + +# Install minimal packages for provisioning +PACKAGES="dropbear openssh-sftp-server python3 py3-yaml py3-kubernetes cloud-utils-growpart e2fsprogs-extra lsblk" +apk update && apk upgrade +apk add ${PACKAGES} + +# Enable and start services +enable_and_start() { + service="$1" + rc-update add "${service}" default + service "${service}" start +} + +enable_and_start dropbear + +# increase root partition size +root_dev=$(readlink -f /dev/root) +disk=$(lsblk -no pkname "$root_dev") +part_num=$(echo "$root_dev" | sed 's|.*[^0-9]||') + +echo "Growing /dev/${disk}${part_num}..." + +# Grow partition and resize +growpart "/dev/$disk" "$part_num" || err "Partition growth failed" +resize2fs "$root_dev" || err "Filesystem resize failed" diff --git a/infra/main.tofu b/infra/main.tofu new file mode 100644 index 0000000..b0f7ed5 --- /dev/null +++ b/infra/main.tofu @@ -0,0 +1,202 @@ +# ------ +# Locals +# ------ + +locals { + root_disk_size = "16GiB" + ext_disk_size = "16GiB" + network_cidr = "10.150.0.1/24" + + master_count = 2 + worker_count = 0 +} + +# --------- +# Resources +# --------- + +resource "incus_project" "this" { + name = "deskpi-cluster" + description = "The emulated DeskPi Super6C stack." +} + +resource "incus_image" "alpine" { + project = incus_project.this.name + + source_image = { + remote = "images" + name = "alpine/edge" + type = "virtual-machine" + } +} + +resource "incus_profile" "this" { + name = "Default" + description = "The default profile applied to both masters and workers." + project = incus_project.this.name + + depends_on = [incus_network.this] + + config = { + "limits.cpu" = var.cpus + "limits.memory" = var.memory + "boot.autostart" = false + "security.secureboot" = false + } + + device { + name = "root" + type = "disk" + + properties = { + "pool" = "default" + "path" = "/" + "size" = local.root_disk_size + } + } +} + +resource "incus_network" "this" { + name = "deskpi-cluster" + description = "An emulated network for the DeskPi Super6C cluster project." + project = incus_project.this.name + type = "bridge" + + config = { + "ipv4.address" = local.network_cidr + "ipv4.nat" = true + } +} + +resource "incus_instance" "master" { + count = local.master_count + + name = "master-${count.index}" + description = "Kubernetes Master ${count.index}" + project = incus_project.this.name + + type = "virtual-machine" + image = incus_image.alpine.fingerprint + profiles = [incus_profile.this.name] + + wait_for { + type = "agent" + } + + file { + source_path = "bootstrap.sh" + target_path = "/bootstrap.sh" + } + + file { + source_path = "../ansible/.ssh/key.pub" + target_path = "/root/.ssh/authorized_keys" + create_directories = true + } + + device { + name = "eth0" + type = "nic" + + properties = { + "network" = incus_network.this.name + "ipv4.address" = cidrhost(local.network_cidr, 2 + (2 * count.index)) + } + } + + device { + name = "ext" + type = "disk" + + properties = { + "pool" = "default" + "source" = incus_storage_volume.master[count.index].name + } + } + + exec = { "00-init" = { command = ["/bootstrap.sh"] } } +} + +resource "incus_storage_volume" "master" { + count = local.master_count + + name = "master-ext-${count.index}" + description = "External drive for master ${count.index}" + pool = "default" + project = incus_project.this.name + + content_type = "block" + + config = { + "size" = local.ext_disk_size + } +} + +resource "incus_instance" "worker" { + count = local.worker_count + + name = "worker-${count.index}" + description = "Kubernetes Worker ${count.index}" + project = incus_project.this.name + + type = "virtual-machine" + image = incus_image.alpine.fingerprint + profiles = [incus_profile.this.name] + + wait_for { + type = "agent" + } + + file { + source_path = "bootstrap.sh" + target_path = "/bootstrap.sh" + } + + file { + source_path = "../ansible/.ssh/key.pub" + target_path = "/root/.ssh/authorized_keys" + create_directories = true + } + + device { + name = "eth0" + type = "nic" + + properties = { + "network" = incus_network.this.name + "ipv4.address" = cidrhost(local.network_cidr, 3 + (2 * count.index)) + } + } + + exec = { "00-init" = { command = ["/bootstrap.sh"] } } +} + +# ------- +# Outputs +# ------- + +output "master_addresses" { + value = incus_instance.master[*].ipv4_address +} + +output "worker_addresses" { + value = incus_instance.worker[*].ipv4_address +} + +# --------- +# Terraform +# --------- + +terraform { + required_version = "~> 1" + + required_providers { + incus = { + source = "lxc/incus" + version = "~> 1" + } + } +} + +provider "incus" { +} diff --git a/infra/variables.tofu b/infra/variables.tofu new file mode 100644 index 0000000..6bfeac1 --- /dev/null +++ b/infra/variables.tofu @@ -0,0 +1,18 @@ +variable "cpus" { + description = "The number of CPU cores allocated to each virtual machine." + type = number + default = 2 + nullable = false + + validation { + condition = var.cpus >= 1 + error_message = "Allocated CPUs must be equal to or greater than 1." + } +} + +variable "memory" { + description = "The amount of memory allocated to each virtual machine." + type = string + default = "4GiB" + nullable = false +}