diff --git a/README.md b/README.md index d8dbb91..da21349 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,24 @@ worker_nodes = { Stateful apps currently pin retained local PVs to the `debian` node. Move or duplicate those PV manifests when you want storage on another node. +## Workload Placement + +`bootstrap/cluster` labels nodes with homelab placement metadata: + +- `homelab.dev/node-role=control-plane` and `homelab.dev/storage=local` on the + Debian control plane +- `homelab.dev/node-role=edge-app` and `homelab.dev/storage=local` on the + Raspberry Pi worker +- `homelab.dev/node-role=app` and `homelab.dev/storage=nvme` on automated Pimox + worker clones + +Override `control_plane_node_labels`, `worker_node_labels`, +`LAB_RASPBERRY_NODE_LABELS_JSON`, or `LAB_PIMOX_WORKER_NODE_LABELS_JSON` when +the physical layout changes. The current website, demos, registry, and Gitea +manifests are not moved automatically because the public NodePort path and +retained OpenEBS hostpath PVs are node-local. Move workloads only after their +storage and edge path are ready on the target node. + The website and demos NodePorts are reachable from the OCI jump box through the Raspberry Pi Tailscale interface. `bootstrap/cluster` installs a persistent `homelab-tailscale-nodeport.service` on the configured worker to restore the diff --git a/bootstrap/cluster/main.tf b/bootstrap/cluster/main.tf index b92a043..0f679c8 100644 --- a/bootstrap/cluster/main.tf +++ b/bootstrap/cluster/main.tf @@ -635,6 +635,69 @@ EOT } } +locals { + control_plane_node_label_pairs = [ + for label, value in var.control_plane_node_labels : { + node_name = var.control_plane_node_name + label = label + value = value + } + ] + + worker_node_label_pairs = flatten([ + for worker_key, worker in var.worker_nodes : [ + for label, value in lookup(var.worker_node_labels, worker_key, {}) : { + node_name = worker.node_name + label = label + value = value + } + ] + ]) + + node_label_pairs = concat(local.control_plane_node_label_pairs, local.worker_node_label_pairs) +} + +resource "null_resource" "node_labels" { + for_each = { + for pair in local.node_label_pairs : "${pair.node_name}/${pair.label}" => pair + } + + depends_on = [ + null_resource.kubeadm_control_plane, + null_resource.kubeadm_worker, + ] + + triggers = { + kubeconfig_path = var.kubeconfig_path + node_name = each.value.node_name + label = each.value.label + value = each.value.value + } + + provisioner "local-exec" { + interpreter = ["/bin/bash", "-lc"] + environment = { + KUBECONFIG_PATH = self.triggers.kubeconfig_path + NODE_NAME = self.triggers.node_name + NODE_LABEL = self.triggers.label + NODE_LABEL_VALUE = self.triggers.value + } + command = </dev/null 2>&1; then + break + fi + sleep 5 +done + +kubectl --kubeconfig "$${KUBECONFIG_PATH}" get node "$${NODE_NAME}" >/dev/null +kubectl --kubeconfig "$${KUBECONFIG_PATH}" label node "$${NODE_NAME}" "$${NODE_LABEL}=$${NODE_LABEL_VALUE}" --overwrite +EOT + } +} + output "kubeconfig_path" { value = var.kubeconfig_path } diff --git a/bootstrap/cluster/variables.tf b/bootstrap/cluster/variables.tf index fda3bd6..40e8370 100644 --- a/bootstrap/cluster/variables.tf +++ b/bootstrap/cluster/variables.tf @@ -3,6 +3,14 @@ variable "control_plane_node_name" { default = "debian" } +variable "control_plane_node_labels" { + type = map(string) + default = { + "homelab.dev/node-role" = "control-plane" + "homelab.dev/storage" = "local" + } +} + variable "control_plane_advertise_address" { type = string default = "192.168.100.68" @@ -62,6 +70,17 @@ variable "worker_nodes" { } } +variable "worker_node_labels" { + type = map(map(string)) + + default = { + raspberrypi = { + "homelab.dev/node-role" = "edge-app" + "homelab.dev/storage" = "local" + } + } +} + variable "tailscale_nodeport_access" { type = object({ enabled = bool diff --git a/lab.sh b/lab.sh index e13ff2f..ae67048 100755 --- a/lab.sh +++ b/lab.sh @@ -270,10 +270,12 @@ write_cluster_worker_var_file() { local var_file="$2" LAB_INCLUDE_RASPBERRY_WORKER="${LAB_INCLUDE_RASPBERRY_WORKER:-true}" \ - LAB_RASPBERRY_HOST="${LAB_RASPBERRY_HOST:-192.168.100.89}" \ - LAB_RASPBERRY_USER="${LAB_RASPBERRY_USER:-jv}" \ - LAB_RASPBERRY_NODE_NAME="${LAB_RASPBERRY_NODE_NAME:-raspberry}" \ - LAB_RASPBERRY_SSH_KEY_PATH="${LAB_RASPBERRY_SSH_KEY_PATH:-/home/jv/.ssh/id_ed25519}" \ + LAB_RASPBERRY_HOST="${LAB_RASPBERRY_HOST:-192.168.100.89}" \ + LAB_RASPBERRY_USER="${LAB_RASPBERRY_USER:-jv}" \ + LAB_RASPBERRY_NODE_NAME="${LAB_RASPBERRY_NODE_NAME:-raspberry}" \ + LAB_RASPBERRY_SSH_KEY_PATH="${LAB_RASPBERRY_SSH_KEY_PATH:-/home/jv/.ssh/id_ed25519}" \ + LAB_RASPBERRY_NODE_LABELS_JSON="${LAB_RASPBERRY_NODE_LABELS_JSON:-{\"homelab.dev/node-role\":\"edge-app\",\"homelab.dev/storage\":\"local\"}}" \ + LAB_PIMOX_WORKER_NODE_LABELS_JSON="${LAB_PIMOX_WORKER_NODE_LABELS_JSON:-{\"homelab.dev/node-role\":\"app\",\"homelab.dev/storage\":\"nvme\"}}" \ python3 - "${spec_file}" "${var_file}" <<'PY' import json import os @@ -281,6 +283,13 @@ import sys spec_file, var_file = sys.argv[1:3] nodes = {} +node_labels = {} + +try: + raspberry_labels = json.loads(os.environ["LAB_RASPBERRY_NODE_LABELS_JSON"]) + pimox_labels = json.loads(os.environ["LAB_PIMOX_WORKER_NODE_LABELS_JSON"]) +except json.JSONDecodeError as exc: + raise SystemExit(f"Invalid node label JSON: {exc}") from exc if os.environ["LAB_INCLUDE_RASPBERRY_WORKER"].lower() not in {"0", "false", "no", "off", "disabled"}: nodes["raspberrypi"] = { @@ -289,6 +298,7 @@ if os.environ["LAB_INCLUDE_RASPBERRY_WORKER"].lower() not in {"0", "false", "no" "node_name": os.environ["LAB_RASPBERRY_NODE_NAME"], "ssh_key_path": os.environ["LAB_RASPBERRY_SSH_KEY_PATH"], } + node_labels["raspberrypi"] = raspberry_labels with open(spec_file, encoding="utf-8") as handle: for line in handle: @@ -302,9 +312,10 @@ with open(spec_file, encoding="utf-8") as handle: "node_name": node_name, "ssh_key_path": ssh_key_path, } + node_labels[key] = pimox_labels with open(var_file, "w", encoding="utf-8") as handle: - json.dump({"worker_nodes": nodes}, handle, indent=2) + json.dump({"worker_nodes": nodes, "worker_node_labels": node_labels}, handle, indent=2) handle.write("\n") PY }