This commit is contained in:
juvdiaz 2026-05-23 21:00:28 -06:00
parent eadac604f7
commit 8c96d4ce10
26 changed files with 1241 additions and 486 deletions

3
.gitignore vendored
View File

@ -2,12 +2,11 @@
*.tfstate
*.tfstate.backup
.terraform/
.terraform.lock.hcl
# Ignore local archive dumps and backups
*.tar
*.zip
gittea/gittea-docker-backup
apps/gitea/gitea-docker-backup
# Ignore older source iterations
*.old

70
README.md Normal file
View File

@ -0,0 +1,70 @@
# Homelab Kubernetes Pipeline
This repo bootstraps a hybrid kubeadm cluster and then hands app delivery to
Argo CD.
## Flow
1. `bootstrap/cluster`
- creates the kubeadm control plane on the Debian amd64 node
- joins worker nodes such as Raspberry Pi arm64 nodes
- configures Calico-compatible pod CIDR
- configures containerd to pull from the in-cluster NodePort registry
- creates retained host directories under `/var/openebs/local`
2. `bootstrap/platform`
- installs a minimal Calico deployment through the Tigera operator
- installs OpenEBS
- creates `openebs-hostpath-retain`
- installs Argo CD
- registers the private GitOps repo without storing the SSH private key in
Terraform state
3. `bootstrap/apps`
- registers Argo CD Applications from the `applications` map
- default apps are `container-registry`, `gitea`, and
`website-production`
## Adding Nodes
Add entries to `bootstrap/cluster/variables.tf` or a `.tfvars` file:
```hcl
worker_nodes = {
raspberrypi = {
host = "192.168.100.89"
user = "jv"
node_name = "raspberry"
ssh_key_path = "/home/jv/.ssh/id_ed25519"
}
}
```
Stateful apps currently pin retained local PVs to the `debian` node. Move or
duplicate those PV manifests when you want storage on another node.
For `./lab.sh nuke`, set `WORKER_SSH_TARGETS` to a space-separated list of
remote SSH targets when more worker nodes exist. Set it to an empty string for a
single-node rebuild.
## Adding Platform Tools
Add Helm releases through `bootstrap/platform`'s `extra_helm_releases` map.
## Adding Apps
Add Kubernetes manifests under `apps/<name>` and register them in
`bootstrap/apps`'s `applications` map. Argo CD will own sync, pruning, and
self-healing for the app.
## Storage
OpenEBS provides the platform storage provisioner. Stateful homelab apps use
retained local PV paths such as `/var/openebs/local/gitea` and
`/var/openebs/local/registry`; these paths are intentionally outside kubeadm
reset paths so data can survive cluster destroy/create cycles. Those critical
volumes are declared explicitly as retained local PVs so a rebuilt cluster binds
back to the same host paths instead of creating fresh directories.
Keep the `.terraform.lock.hcl` files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: container-registry

View File

@ -15,11 +15,39 @@ spec:
labels:
app: local-registry
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- debian
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
name: http
readinessProbe:
httpGet:
path: /v2/
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /v2/
port: http
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
volumeMounts:
- name: registry-vol
mountPath: /var/lib/registry

View File

@ -1,17 +1,25 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: registry-pv
labels:
type: local
name: registry-data-debian
spec:
storageClassName: manual
capacity:
storage: 20Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
hostPath:
path: "/home/k8s-storage/registry-data"
persistentVolumeReclaimPolicy: Retain
storageClassName: openebs-hostpath-retain
local:
path: /var/openebs/local/registry
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- debian
---
apiVersion: v1
kind: PersistentVolumeClaim
@ -19,7 +27,8 @@ metadata:
name: registry-pvc
namespace: container-registry
spec:
storageClassName: manual
storageClassName: openebs-hostpath-retain
volumeName: registry-data-debian
accessModes:
- ReadWriteOnce
resources:

View File

@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: gitea-system
labels:
app: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- debian
containers:
- name: gitea
image: gitea/gitea:1.21.7
ports:
- containerPort: 3000
name: http
- containerPort: 22
name: ssh
env:
- name: USER_UID
value: "1000"
- name: USER_GID
value: "1000"
- name: GITEA__database__DB_TYPE
value: sqlite3
- name: GITEA__repository__ENABLE_PUSH_MIRROR
value: "true"
- name: GITEA__migrations__ALLOW_LOCALNETWORKS
value: "true"
- name: GITEA__server__SSH_PORT
value: "32222"
- name: GITEA__server__SSH_LISTEN_PORT
value: "22"
volumeMounts:
- name: gitea-data
mountPath: /data
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 20
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 60
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumes:
- name: gitea-data
persistentVolumeClaim:
claimName: gitea-data

View File

@ -1,33 +0,0 @@
# Pull down the Gitea image locally via the Pi's Docker engine
resource "docker_image" "gitea_backup" {
name = "gitea/gitea:1.21.7"
keep_locally = true
}
# Fire up the standalone container wrapper
resource "docker_container" "gitea_backup" {
name = "gitea-backup"
image = docker_image.gitea_backup.image_id
restart = "always"
env = [
"USER_UID=1000",
"USER_GID=1000",
"GITEA__database__DB_TYPE=sqlite3"
]
ports {
internal = 3000
external = 3000
}
ports {
internal = 22
external = 2222
}
volumes {
host_path = "/home/pi/gitea-backup-data"
container_path = "/data"
}
}

View File

@ -1,18 +0,0 @@
resource "null_resource" "node_prep" {
for_each = var.nodes
connection {
type = "ssh"
user = each.value.user
host = each.value.ip
private_key = file(var.ssh_key_path)
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y open-iscsi util-linux",
"sudo systemctl enable --now iscsid"
]
}
}

View File

@ -1,134 +0,0 @@
resource "kubernetes_namespace" "gitea" {
metadata {
name = "gitea-system"
}
}
resource "null_resource" "install_longhorn" {
depends_on = [null_resource.node_prep]
provisioner "local-exec" {
command = "kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.6.2/deploy/longhorn.yaml"
}
}
resource "kubernetes_persistent_volume_claim" "gitea_pvc" {
depends_on = [null_resource.install_longhorn]
metadata {
name = "gitea-pvc"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "longhorn"
resources {
requests = {
storage = "10Gi"
}
}
}
}
resource "kubernetes_deployment" "gitea_prod" {
metadata {
name = "gitea-prod"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
replicas = 1
selector {
match_labels = {
app = "gitea"
}
}
template {
metadata {
labels = {
app = "gitea"
}
}
spec {
container {
name = "gitea"
image = "gitea/gitea:1.21.7"
port {
container_port = 3000
name = "http"
}
port {
container_port = 22
name = "ssh"
}
env {
name = "USER_UID"
value = "1000"
}
env {
name = "USER_GID"
value = "1000"
}
env {
name = "GITEA__database__DB_TYPE"
value = "sqlite3"
}
env {
name = "GITEA__repository__ENABLE_PUSH_MIRROR"
value = "true"
}
env {
name = "GITEA__repository__ENABLE_PUSH_MIRROR"
value = "true"
}
# ADD THIS NEW BLOCK:
env {
name = "GITEA__migrations__ALLOW_LOCALNETWORKS"
value = "true"
}
volume_mount {
mount_path = "/data"
name = "gitea-storage"
}
}
volume {
name = "gitea-storage"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.gitea_pvc.metadata[0].name
}
}
}
}
}
}
resource "kubernetes_service" "gitea_service" {
metadata {
name = "gitea-service"
namespace = kubernetes_namespace.gitea.metadata[0].name
}
spec {
type = "NodePort"
selector = {
app = "gitea"
}
port {
name = "http"
port = 3000
target_port = 3000
node_port = 30300
}
port {
name = "ssh"
port = 22
target_port = 22
node_port = 32222
}
}
}

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: gitea-system

View File

@ -1,31 +0,0 @@
terraform {
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.23"
}
}
}
# Core Cluster API access
provider "kubernetes" {
config_path = "~/.kube/config"
config_context = "kubernetes-admin@kubernetes"
}
# Remote Docker control over the Raspberry Pi
provider "docker" {
host = "ssh://jv@192.168.100.89:22"
ssh_opts = [
"-o", "StrictHostKeyChecking=no",
"-i", var.ssh_key_path
]
}

18
apps/gitea/service.yaml Normal file
View File

@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: gitea-system
spec:
type: NodePort
selector:
app: gitea
ports:
- name: http
port: 3000
targetPort: http
nodePort: 30300
- name: ssh
port: 22
targetPort: ssh
nodePort: 32222

36
apps/gitea/storage.yaml Normal file
View File

@ -0,0 +1,36 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: gitea-data-debian
spec:
capacity:
storage: 20Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: openebs-hostpath-retain
local:
path: /var/openebs/local/gitea
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- debian
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-data
namespace: gitea-system
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath-retain
volumeName: gitea-data-debian
resources:
requests:
storage: 20Gi

View File

@ -1,26 +0,0 @@
variable "home_dir" {
type = string
default = "/home/jv"
}
variable "ssh_key_path" {
type = string
default = "/home/jv/.ssh/id_ed25519"
}
variable "nodes" {
type = map(object({
ip = string
user = string
}))
default = {
raspberrypi = {
ip = "192.168.100.89"
user = "jv"
}
debian_laptop = {
ip = "192.168.100.68"
user = "jv"
}
}
}

View File

@ -1,7 +1,11 @@
[registry."host.docker.internal:30500"]
http = true
insecure = true
[registry."192.168.100.68:30500"]
http = true
insecure = true
[registry."127.0.0.1:30500"]
http = true
insecure = true
[registry."localhost:30500"]
http = true
insecure = true

View File

@ -34,10 +34,29 @@ spec:
topologyKey: "kubernetes.io/hostname"
containers:
- name: php-app
image: local-registry-svc.container-registry.svc.cluster.local:5000/php-website:latest
image: 192.168.100.68:30500/php-website:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
memory: 256Mi
---
apiVersion: v1
kind: Service

View File

@ -0,0 +1,27 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hashicorp/kubernetes" {
version = "2.38.0"
constraints = "~> 2.26"
hashes = [
"h1:3VVgWmwdwXFT54fjrplnq+N4+4LZ3ZeLHAlr/0jhPiA=",
"h1:9LfHMXiMOboc6PhcEEelKjA3VL94l3MCj7RlbKO1PQM=",
"h1:AkW6iAcMGHS+P2BIc2nvQe3PZCHtDL4m6+80tEDLge0=",
"h1:HGkB9bCmUqMRcR5/bAUOSqPBsx6DAIEnbT1fZ8vzI78=",
"h1:eCV78xGlh9eay+62U4gAgCEMohuiBJXN9XTIZNn+rX4=",
"h1:ems+O2dA7atxMWpbtqIrsH7Oa+u+ERWSfpMaFnZPbh0=",
"h1:iEDf790HE0h3kz58zfj5IhTVODCDqWF1hISPHz210Uw=",
"h1:nY7J9jFXcsRINog0KYagiWZw1GVYF9D2JmtIB7Wnrao=",
"h1:yw6JHRONXmiTumaQfJBxl1ierFnNNT/Qio8+ttfMcG4=",
"zh:1096b41c4e5b2ee6c1980916fb9a8579bc1892071396f7a9432be058aabf3cbc",
"zh:2959fde9ae3d1deb5e317df0d7b02ea4977951ee6b9c4beb083c148ca8f3681c",
"zh:5082f98fcb3389c73339365f7df39fc6912bf2bd1a46d5f97778f441a67fd337",
"zh:620fd5d0fbc2d7a24ac6b420a4922e6093020358162a62fa8cbd37b2bac1d22e",
"zh:7f47c2de179bba35d759147c53082cad6c3449d19b0ec0c5a4ca8db5b06393e1",
"zh:89c3aa2a87e29febf100fd21cead34f9a4c0e6e7ae5f383b5cef815c677eb52a",
"zh:96eecc9f94938a0bc35b8a63d2c4a5f972395e44206620db06760b730d0471fc",
"zh:e15567c1095f898af173c281b66bffdc4f3068afdd9f84bb5b5b5521d9f29584",
"zh:ecc6b912629734a9a41a7cf1c4c73fb13b4b510afc9e7b2e0011d290bcd6d77f",
]
}

View File

@ -9,45 +9,11 @@ terraform {
}
provider "kubernetes" {
config_path = "/home/jv/.kube/config"
config_path = var.kubeconfig_path
}
resource "kubernetes_manifest" "container_registry" {
field_manager {
force_conflicts = true
}
manifest = {
apiVersion = "argoproj.io/v1alpha1"
kind = "Application"
metadata = {
name = "container-registry"
namespace = "argocd"
}
spec = {
project = "default"
source = {
repoURL = "ssh://jv@192.168.100.68/home/jv/git-server/my-homelab-configs.git"
targetRevision = "main"
path = "apps/container-registry"
}
destination = {
server = "https://kubernetes.default.svc"
namespace = "container-registry"
}
syncPolicy = {
automated = {
prune = true
selfHeal = true
}
syncOptions = ["CreateNamespace=true"]
}
}
}
}
resource "kubernetes_manifest" "production_website" {
depends_on = [kubernetes_manifest.container_registry]
resource "kubernetes_manifest" "argocd_application" {
for_each = var.applications
field_manager {
force_conflicts = true
@ -57,26 +23,26 @@ resource "kubernetes_manifest" "production_website" {
apiVersion = "argoproj.io/v1alpha1"
kind = "Application"
metadata = {
name = "php-web-app"
namespace = "argocd"
name = each.key
namespace = var.argocd_namespace
}
spec = {
project = "default"
project = each.value.project
source = {
repoURL = "ssh://jv@192.168.100.68/home/jv/git-server/my-homelab-configs.git"
targetRevision = "main"
path = "apps/website"
repoURL = var.gitops_repo_url
targetRevision = each.value.target_revision
path = each.value.path
}
destination = {
server = "https://kubernetes.default.svc"
namespace = "default"
namespace = each.value.namespace
}
syncPolicy = {
automated = {
prune = true
selfHeal = true
prune = each.value.prune
selfHeal = each.value.self_heal
}
syncOptions = ["CreateNamespace=true"]
syncOptions = each.value.create_namespace ? ["CreateNamespace=true"] : []
}
}
}

View File

@ -0,0 +1,56 @@
variable "kubeconfig_path" {
type = string
default = "/home/jv/.kube/config"
}
variable "argocd_namespace" {
type = string
default = "argocd"
}
variable "gitops_repo_url" {
type = string
default = "ssh://jv@192.168.100.68/home/jv/git-server/my-homelab-configs.git"
}
variable "applications" {
type = map(object({
project = string
path = string
namespace = string
target_revision = string
prune = bool
self_heal = bool
create_namespace = bool
}))
default = {
container-registry = {
project = "default"
path = "apps/container-registry"
namespace = "container-registry"
target_revision = "main"
prune = true
self_heal = true
create_namespace = true
}
gitea = {
project = "default"
path = "apps/gitea"
namespace = "gitea-system"
target_revision = "main"
prune = true
self_heal = true
create_namespace = true
}
website-production = {
project = "default"
path = "apps/website"
namespace = "website-production"
target_revision = "main"
prune = true
self_heal = true
create_namespace = true
}
}
}

View File

@ -0,0 +1,76 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hashicorp/external" {
version = "2.4.0"
constraints = "~> 2.3"
hashes = [
"h1:/+SUsgcSNW/RVmImj/m1Z+hSUU5BUMu8CMbLwyuhMMU=",
"h1:/MpYrEgBd7Gu/lyuyViR4ccaYSpHHIiHZfIxy7AGy2U=",
"h1:0SJyK1GT4ma4uTSUz8Y619fc1xRTzJ9FKFvcpvZlSIo=",
"h1:4WFLgDJ0NXqNlKJ2UFniW8/GMMterkrWBXmi/+yW2do=",
"h1:5vgd2l3wLBjETL3h0npHKNm8efKSE7Xb2uxh+8cysm8=",
"h1:6dSIL7ou4oq3hcuRUx4OLRc31PQ1oAC1d885hnQlOh0=",
"h1:7OLUuwL6BIhAYehzbDX5NVTIoDAIBf2dYZGrpSKe0r0=",
"h1:9PmuE7y6euSGJtnl7W0TIBvZBU1pNojfa615jnJAiIs=",
"h1:ME8EoyLapCOOAzPP8uPTm+OSF7nil1r0wOeJ+Q9KNZ8=",
"h1:PxaDF/WKlkCtfmiqzws5/23jq3QgcoJ5TMeEIimtvDQ=",
"h1:auXuC9e9AsQxrYQj6OyYgqKHgjry0IkJnKHpPShoLHM=",
"h1:eBAcMZAh1wmCyYQMCIPyXljazVRYAHkEu/31RbZD6Sw=",
"h1:jkfHEdIUc6uxIm8zuw0lVZliedeqgzDE9qb1Y608VUI=",
"h1:mC6Rw0cABPfsBZLVXfdOByUUW1hHkfscaQufTzVM+rw=",
"h1:nE1mN6A/5lJVLiNQmVm43m9ME2uHlqwIYUVxlZgblSk=",
"zh:483962b782cee2c970f4bdf6118e4bb665f37a0d488024c660b7a7c9853afc93",
"zh:4a8b42651f6de0ea93854ece3ccdf8e2b21b911145859402a9a6ec6ecf31a23c",
"zh:56836ea1468cb328e98cccc76cccc208fb7336513bc76de76309541b8e5ceef2",
"zh:6d30c2c1aff7e0ddcfffdf815e570f5ed8b77d1ce2d31440c7c50c6f3e7cbe97",
"zh:80a21a23bd9bfafb74e4fc5f2a0e2bc33517c418d5f16dc020b7febba9ef6cd2",
"zh:95d9ce0e6407f199f7e9d3aefc3345a6e67c18f0c8280207417272cbb3f973ad",
"zh:98e46c6504da5f1020489730d23f03a1337e643ed452f271c2a13e6af4687a16",
"zh:a3f59b81da319a87bb3ac4a31e669874a090f082959da29975fa6f11f53b4731",
"zh:a5793f88bb25000d6e6b8b2cd5ca71366a8d4e373e17a247089a80331ccf824e",
"zh:cfc9af183162936b2e9a726c4a68d773b4511ada23b2c764f5add90f080e747d",
"zh:d85e722d771fb7865d9d5b591334d0f2b7598b7e66f534f93923effe6d5134ed",
"zh:de6e5d954ef91bb0ad41f30305ab8c31718ea39308523529f528babd0b27db71",
"zh:fca19dda5e05221e231d400fc39fda4f9e9abfe33e8e833a215eb094fe226ed8",
"zh:fcf67b347f2dc3c609c819ad5f27078fa3d0235311cfe5e33242eb49b3a4a6a8",
"zh:fea69a81ffcd63cb776b0f2c13a99a8a6d64e0b9de111ea1926bc4e713023ece",
]
}
provider "registry.opentofu.org/hashicorp/null" {
version = "3.3.0"
constraints = "~> 3.2"
hashes = [
"h1:0r7+t8CqzjfBgHgEiJGBCw+McEUdRXliMdF+Hk29d8o=",
"h1:EvvCOc4FJY3NitSm6BpzCcUPU53LayVCB/tPOxYmy7U=",
"h1:IDVnZXNCh0u4LfeSazc9z1v/kNz+92Eej7ePWV6SbyE=",
"h1:Iw2c0n9/4fS92N5WnJ3CCSwSUXZO953oHp9gj3pWCaM=",
"h1:JofS1og3hPN0ANjH+gNjxrJyyk6znodpC/F0qhp4eEk=",
"h1:QIBhsJ4+5+t0vFEgJwtezNLT31tsptFHOEyGAAhLR1o=",
"h1:RjjoL9qRPwNTwLdtJsYUaFvunbPM2/oujf2DcUcitOE=",
"h1:SSirA+z2VWTs1s+TCAx8vVKg9jh6cRjxqc8LYi2iQTI=",
"h1:U2XZc7hxcpcWp/C2S9LtuGUimhMOD2UT5xAEJJQQQaU=",
"h1:bPG+xE5UonkJv3y/Yn9Q7OfbP2qHU/QKiS31nwfe7S0=",
"h1:eODLdk/pARc4yxChAFtwseVmBr+r5fF9yGOvUhwGEyM=",
"h1:iFj1oM5ZPENspsPqK1kcvZzyP95jJE/CM0rlu0MfIss=",
"h1:mdu+qpyVmjDDLMrcL1JFy+cSyF58I3TFJwB5NssCZ58=",
"h1:tJmep6aoBeDH77XsYU65HAbi0RAjxtsmbCOXmnqT13U=",
"h1:tdMTn1evBLd6KCeLqWdQXCpF07hBu3n5rY6N3rXw3Rc=",
"zh:083dcc0bec53f8abfa3f2aa2ce9d732a9675338fd60ae7d61162e25db7cb08bf",
"zh:19f7456b5a2ad16595860974714bfdb25b87bc16356ea9d5c7453892aaa27864",
"zh:222c0ed1fed4e4c677ebe626104dbfdba66763e264de0d9c27c58ce60104ee69",
"zh:271711d6caa7dd5a4e9b79fe8c679fab61a840bcf80040a0f5ebb425d1b27d97",
"zh:5adcf35f30baaea13f80c2a2c774deb9369892719493049687e23476c9dff40f",
"zh:5bcfd19df16e73d7f0ad75bd09e2b3b86cf6700d09822d585d68304b71de1d97",
"zh:604edecf263e38674decb35bb4e0e048fdc951f26fa103c33065ff9728f0313b",
"zh:782acbfb4fa4807e273e588fe45b4aaea9dd0fd1136f76ec3200f6f4db3af8d6",
"zh:84411a596d528fe67294e5c1cfd0c2036b08802497bcc4215ce518924f3c9a4a",
"zh:85e79eecf3f5348975cffec3016b0eba3baf605646102d4348796ccd2df2e5f6",
"zh:95669535ca17aeefef307ebfd59ce6930953173baae5637e8cbbf0297ec7ad58",
"zh:d04d9b177747bfd66b4a45b5d911a2a7822aa8451f5e35621971fb7a4206b530",
"zh:e6d9c924475283e90833450a14a732f4deb6d9bb131db8f86ab856e894270836",
"zh:ebcab0c8a1334c86ed7cfa53f571a17ad6d27e9901f27a8854ea622a74b54bb6",
"zh:ef9c757bb2c83d2103811a3d86b6ec5be06b0ffc337b84db1582d023bce7cdcd",
]
}

View File

@ -13,63 +13,143 @@ terraform {
}
resource "null_resource" "kubeadm_control_plane" {
provisioner "local-exec" {
command = <<EOT
sudo apt-get update && sudo apt-get install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid
sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --node-name=debian
mkdir -p /home/jv/.kube
sudo cp -i /etc/kubernetes/admin.conf /home/jv/.kube/config
sudo chown jv:jv /home/jv/.kube/config
kubectl taint nodes debian node-role.kubernetes.io/control-plane-
EOT
triggers = {
node_name = var.control_plane_node_name
advertise_address = var.control_plane_advertise_address
pod_network_cidr = var.pod_network_cidr
kubeconfig_path = var.kubeconfig_path
kubeconfig_owner = var.kubeconfig_owner
registry_endpoint = var.registry_endpoint
persistent_volume_dirs = join(",", var.persistent_volume_dirs)
}
provisioner "local-exec" {
when = destroy
interpreter = ["/bin/bash", "-lc"]
command = <<EOT
sudo kubeadm reset --force
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X
sudo ip link delete cilium_host || true
sudo ip link delete cilium_net || true
sudo ip link delete cilium_vxlan || true
rm -rf /home/jv/.kube
sudo rm -rf /etc/kubernetes/ /var/lib/etcd/ /var/lib/kubelet/ /var/lib/cni/ /etc/cni/net.d
EOT
set -euo pipefail
sudo apt-get update
sudo apt-get install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid
sudo systemctl enable --now kubelet || true
sudo mkdir -p /etc/containerd
if [ ! -f /etc/containerd/config.toml ]; then
sudo containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
fi
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo sed -i 's#config_path = ""#config_path = "/etc/containerd/certs.d"#' /etc/containerd/config.toml
sudo mkdir -p /etc/containerd/certs.d/${self.triggers.registry_endpoint}
sudo tee /etc/containerd/certs.d/${self.triggers.registry_endpoint}/hosts.toml >/dev/null <<REGISTRY_EOT
server = "http://${self.triggers.registry_endpoint}"
[host."http://${self.triggers.registry_endpoint}"]
capabilities = ["pull", "resolve", "push"]
skip_verify = true
REGISTRY_EOT
sudo systemctl restart containerd
IFS=',' read -r -a pv_dirs <<< "${self.triggers.persistent_volume_dirs}"
for path in "$${pv_dirs[@]}"; do
sudo mkdir -p "$path"
sudo chmod 0775 "$path"
done
if [ ! -f /etc/kubernetes/admin.conf ]; then
sudo kubeadm init \
--pod-network-cidr=${self.triggers.pod_network_cidr} \
--node-name=${self.triggers.node_name} \
--apiserver-advertise-address=${self.triggers.advertise_address}
fi
mkdir -p "$(dirname "${self.triggers.kubeconfig_path}")"
sudo cp -f /etc/kubernetes/admin.conf "${self.triggers.kubeconfig_path}"
sudo chown ${self.triggers.kubeconfig_owner} "${self.triggers.kubeconfig_path}"
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" taint nodes "${self.triggers.node_name}" node-role.kubernetes.io/control-plane- || true
EOT
}
}
data "external" "kubeadm_join_command" {
depends_on = [null_resource.kubeadm_control_plane]
program = ["sh", "-c", "echo \"{\\\"cmd\\\":\\\"$(sudo kubeadm token create --print-join-command)\\\"}\""]
program = [
"bash",
"-lc",
<<EOT
set -euo pipefail
cmd="$(sudo kubeadm token create --print-join-command)"
printf '{"cmd":"%s"}\n' "$(printf '%s' "$cmd" | sed 's/\\/\\\\/g; s/"/\\"/g')"
EOT
]
}
resource "null_resource" "kubeadm_worker_raspberry" {
depends_on = [null_resource.kubeadm_control_plane]
resource "null_resource" "kubeadm_worker" {
for_each = var.worker_nodes
depends_on = [data.external.kubeadm_join_command]
triggers = {
node_name = each.value.node_name
host = each.value.host
user = each.value.user
ssh_key_path = each.value.ssh_key_path
registry_endpoint = var.registry_endpoint
persistent_volume_dirs = join(",", var.persistent_volume_dirs)
}
connection {
type = "ssh"
user = "jv"
private_key = file("/home/jv/.ssh/id_ed25519")
host = "192.168.100.89"
user = self.triggers.user
private_key = file(self.triggers.ssh_key_path)
host = self.triggers.host
}
provisioner "remote-exec" {
inline = [
"sudo apt-get update && sudo apt-get install -y open-iscsi nfs-common",
"sudo systemctl enable --now iscsid",
"echo '${data.external.kubeadm_join_command.result.cmd} --node-name=raspberry' > /tmp/join.sh",
"sudo sh /tmp/join.sh",
"rm -f /tmp/join.sh"
]
}
<<EOT
set -eu
provisioner "remote-exec" {
when = destroy
inline = [
"sudo kubeadm reset --force",
"sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X",
"sudo rm -rf /var/lib/kubelet/ /var/lib/cni/ /etc/cni/net.d"
sudo apt-get update
sudo apt-get install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid
sudo systemctl enable --now kubelet || true
sudo mkdir -p /etc/containerd
if [ ! -f /etc/containerd/config.toml ]; then
sudo containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
fi
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo sed -i 's#config_path = ""#config_path = "/etc/containerd/certs.d"#' /etc/containerd/config.toml
sudo mkdir -p /etc/containerd/certs.d/${self.triggers.registry_endpoint}
sudo tee /etc/containerd/certs.d/${self.triggers.registry_endpoint}/hosts.toml >/dev/null <<REGISTRY_EOT
server = "http://${self.triggers.registry_endpoint}"
[host."http://${self.triggers.registry_endpoint}"]
capabilities = ["pull", "resolve", "push"]
skip_verify = true
REGISTRY_EOT
sudo systemctl restart containerd
pv_dirs="${self.triggers.persistent_volume_dirs}"
IFS=','
for path in $pv_dirs; do
sudo mkdir -p "$path"
sudo chmod 0775 "$path"
done
if [ ! -f /etc/kubernetes/kubelet.conf ]; then
sudo ${data.external.kubeadm_join_command.result.cmd} --node-name=${self.triggers.node_name}
fi
EOT
]
}
}
output "kubeconfig_path" {
value = var.kubeconfig_path
}
output "pod_network_cidr" {
value = var.pod_network_cidr
}

View File

@ -0,0 +1,55 @@
variable "control_plane_node_name" {
type = string
default = "debian"
}
variable "control_plane_advertise_address" {
type = string
default = "192.168.100.68"
}
variable "pod_network_cidr" {
type = string
default = "10.244.0.0/16"
}
variable "kubeconfig_path" {
type = string
default = "/home/jv/.kube/config"
}
variable "kubeconfig_owner" {
type = string
default = "jv:jv"
}
variable "registry_endpoint" {
type = string
default = "192.168.100.68:30500"
}
variable "persistent_volume_dirs" {
type = list(string)
default = [
"/var/openebs/local/registry",
"/var/openebs/local/gitea",
]
}
variable "worker_nodes" {
type = map(object({
host = string
user = string
node_name = string
ssh_key_path = string
}))
default = {
raspberrypi = {
host = "192.168.100.89"
user = "jv"
node_name = "raspberry"
ssh_key_path = "/home/jv/.ssh/id_ed25519"
}
}
}

View File

@ -0,0 +1,91 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hashicorp/helm" {
version = "2.17.0"
constraints = "~> 2.12"
hashes = [
"h1:+NIiFaAqUKl8JBJNk11tfXE4reXce5e3D3V0MOl7/SI=",
"h1:/1qQSZBcGfGdSJ1PVDIIRLHF6cCNQDpFnZHA8Z1h5Hg=",
"h1:69PnHoYrrDrm7C8+8PiSvRGPI55taqL14SvQR/FGM+g=",
"h1:CaN+iT1/mgn062wIkmcZyqhIQEvUQYpGcBYZ7Y7sM84=",
"h1:RMCFME+dnIAfqWesdTwaktFf5TCa9shMDtlLWwpJjAw=",
"h1:ShIag7wqd5Rs+zYpVMpjAh+T0ozr4XGYfSTKWqceQBY=",
"h1:WX5D6JJke4iG8ecGoUt1Gml/ggLHTFhswesyQqxKKG0=",
"h1:gy5bFfc81+K/Mi5KRQ6LfRJmgyaTxJnLTzDK+OYJAQg=",
"h1:kzDwclZLK5tIKJo3ATWM7a5ODmeczfWkvQDZkr9dVro=",
"h1:ojHGbVqPy4ShrUnNL7jif6AnEwgc8vC8sP7f37/VBC8=",
"zh:02690815e35131a42cb9851f63a3369c216af30ad093d05b39001d43da04b56b",
"zh:27a62f12b29926387f4d71aeeee9f7ffa0ccb81a1b6066ee895716ad050d1b7a",
"zh:2d0a5babfa73604b3fefc9dab9c87f91c77fce756c2e32b294e9f1290aed26c0",
"zh:3976400ceba6dda4636e1d297e3097e1831de5628afa534a166de98a70d1dcbe",
"zh:54440ef14f342b41d75c1aded7487bfcc3f76322b75894235b47b7e89ac4bfa4",
"zh:6512e2ab9f2fa31cbb90d9249647b5c5798f62eb1215ec44da2cdaa24e38ad25",
"zh:795f327ca0b8c5368af0ed03d5d4f6da7260692b4b3ca0bd004ed542e683464d",
"zh:ba659e1d94f224bc3f1fd34cbb9d2663e3a8e734108e5a58eb49eda84b140978",
"zh:c5c8575c4458835c2acbc3d1ed5570589b14baa2525d8fbd04295c097caf41eb",
"zh:e0877a5dac3de138e61eefa26b2f5a13305a17259779465899880f70e11314e0",
]
}
provider "registry.opentofu.org/hashicorp/kubernetes" {
version = "2.38.0"
constraints = "~> 2.26"
hashes = [
"h1:3VVgWmwdwXFT54fjrplnq+N4+4LZ3ZeLHAlr/0jhPiA=",
"h1:9LfHMXiMOboc6PhcEEelKjA3VL94l3MCj7RlbKO1PQM=",
"h1:AkW6iAcMGHS+P2BIc2nvQe3PZCHtDL4m6+80tEDLge0=",
"h1:HGkB9bCmUqMRcR5/bAUOSqPBsx6DAIEnbT1fZ8vzI78=",
"h1:eCV78xGlh9eay+62U4gAgCEMohuiBJXN9XTIZNn+rX4=",
"h1:ems+O2dA7atxMWpbtqIrsH7Oa+u+ERWSfpMaFnZPbh0=",
"h1:iEDf790HE0h3kz58zfj5IhTVODCDqWF1hISPHz210Uw=",
"h1:nY7J9jFXcsRINog0KYagiWZw1GVYF9D2JmtIB7Wnrao=",
"h1:yw6JHRONXmiTumaQfJBxl1ierFnNNT/Qio8+ttfMcG4=",
"zh:1096b41c4e5b2ee6c1980916fb9a8579bc1892071396f7a9432be058aabf3cbc",
"zh:2959fde9ae3d1deb5e317df0d7b02ea4977951ee6b9c4beb083c148ca8f3681c",
"zh:5082f98fcb3389c73339365f7df39fc6912bf2bd1a46d5f97778f441a67fd337",
"zh:620fd5d0fbc2d7a24ac6b420a4922e6093020358162a62fa8cbd37b2bac1d22e",
"zh:7f47c2de179bba35d759147c53082cad6c3449d19b0ec0c5a4ca8db5b06393e1",
"zh:89c3aa2a87e29febf100fd21cead34f9a4c0e6e7ae5f383b5cef815c677eb52a",
"zh:96eecc9f94938a0bc35b8a63d2c4a5f972395e44206620db06760b730d0471fc",
"zh:e15567c1095f898af173c281b66bffdc4f3068afdd9f84bb5b5b5521d9f29584",
"zh:ecc6b912629734a9a41a7cf1c4c73fb13b4b510afc9e7b2e0011d290bcd6d77f",
]
}
provider "registry.opentofu.org/hashicorp/null" {
version = "3.3.0"
constraints = "~> 3.2"
hashes = [
"h1:0r7+t8CqzjfBgHgEiJGBCw+McEUdRXliMdF+Hk29d8o=",
"h1:EvvCOc4FJY3NitSm6BpzCcUPU53LayVCB/tPOxYmy7U=",
"h1:IDVnZXNCh0u4LfeSazc9z1v/kNz+92Eej7ePWV6SbyE=",
"h1:Iw2c0n9/4fS92N5WnJ3CCSwSUXZO953oHp9gj3pWCaM=",
"h1:JofS1og3hPN0ANjH+gNjxrJyyk6znodpC/F0qhp4eEk=",
"h1:QIBhsJ4+5+t0vFEgJwtezNLT31tsptFHOEyGAAhLR1o=",
"h1:RjjoL9qRPwNTwLdtJsYUaFvunbPM2/oujf2DcUcitOE=",
"h1:SSirA+z2VWTs1s+TCAx8vVKg9jh6cRjxqc8LYi2iQTI=",
"h1:U2XZc7hxcpcWp/C2S9LtuGUimhMOD2UT5xAEJJQQQaU=",
"h1:bPG+xE5UonkJv3y/Yn9Q7OfbP2qHU/QKiS31nwfe7S0=",
"h1:eODLdk/pARc4yxChAFtwseVmBr+r5fF9yGOvUhwGEyM=",
"h1:iFj1oM5ZPENspsPqK1kcvZzyP95jJE/CM0rlu0MfIss=",
"h1:mdu+qpyVmjDDLMrcL1JFy+cSyF58I3TFJwB5NssCZ58=",
"h1:tJmep6aoBeDH77XsYU65HAbi0RAjxtsmbCOXmnqT13U=",
"h1:tdMTn1evBLd6KCeLqWdQXCpF07hBu3n5rY6N3rXw3Rc=",
"zh:083dcc0bec53f8abfa3f2aa2ce9d732a9675338fd60ae7d61162e25db7cb08bf",
"zh:19f7456b5a2ad16595860974714bfdb25b87bc16356ea9d5c7453892aaa27864",
"zh:222c0ed1fed4e4c677ebe626104dbfdba66763e264de0d9c27c58ce60104ee69",
"zh:271711d6caa7dd5a4e9b79fe8c679fab61a840bcf80040a0f5ebb425d1b27d97",
"zh:5adcf35f30baaea13f80c2a2c774deb9369892719493049687e23476c9dff40f",
"zh:5bcfd19df16e73d7f0ad75bd09e2b3b86cf6700d09822d585d68304b71de1d97",
"zh:604edecf263e38674decb35bb4e0e048fdc951f26fa103c33065ff9728f0313b",
"zh:782acbfb4fa4807e273e588fe45b4aaea9dd0fd1136f76ec3200f6f4db3af8d6",
"zh:84411a596d528fe67294e5c1cfd0c2036b08802497bcc4215ce518924f3c9a4a",
"zh:85e79eecf3f5348975cffec3016b0eba3baf605646102d4348796ccd2df2e5f6",
"zh:95669535ca17aeefef307ebfd59ce6930953173baae5637e8cbbf0297ec7ad58",
"zh:d04d9b177747bfd66b4a45b5d911a2a7822aa8451f5e35621971fb7a4206b530",
"zh:e6d9c924475283e90833450a14a732f4deb6d9bb131db8f86ab856e894270836",
"zh:ebcab0c8a1334c86ed7cfa53f571a17ad6d27e9901f27a8854ea622a74b54bb6",
"zh:ef9c757bb2c83d2103811a3d86b6ec5be06b0ffc337b84db1582d023bce7cdcd",
]
}

View File

@ -9,97 +9,250 @@ terraform {
source = "hashicorp/kubernetes"
version = "~> 2.26"
}
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
provider "kubernetes" {
config_path = "/home/jv/.kube/config"
config_path = var.kubeconfig_path
}
provider "helm" {
kubernetes {
config_path = "/home/jv/.kube/config"
config_path = var.kubeconfig_path
}
}
resource "helm_release" "cilium" {
name = "cilium"
repository = "https://helm.cilium.io/"
chart = "cilium"
namespace = "kube-system"
set {
name = "operator.replicas"
value = "1"
}
resource "helm_release" "calico_crds" {
name = "calico-crds"
repository = var.calico.repository
chart = "crd.projectcalico.org.v1"
version = var.calico.version
namespace = var.calico.namespace
create_namespace = true
}
resource "helm_release" "longhorn" {
depends_on = [helm_release.cilium]
name = "longhorn"
repository = "https://charts.longhorn.io"
chart = "longhorn"
namespace = "longhorn-system"
resource "helm_release" "calico" {
depends_on = [helm_release.calico_crds]
name = "calico"
repository = var.calico.repository
chart = "tigera-operator"
version = var.calico.version
namespace = var.calico.namespace
create_namespace = true
timeout = 600
set {
name = "csi.attacherReplicaCount"
value = "1"
values = [
yamlencode({
manageCRDs = false
apiServer = {
enabled = false
}
set {
name = "csi.provisionerReplicaCount"
value = "1"
goldmane = {
enabled = false
}
set {
name = "csi.resizerReplicaCount"
value = "1"
whisker = {
enabled = false
}
set {
name = "csi.snapshotterReplicaCount"
value = "1"
installation = {
controlPlaneReplicas = 1
cni = {
type = "Calico"
}
set {
name = "defaultSettings.defaultReplicaCount"
value = "1"
calicoNetwork = {
bgp = "Disabled"
ipPools = [
{
cidr = var.pod_network_cidr
encapsulation = "VXLAN"
}
]
}
}
})
]
}
resource "null_resource" "calico_ready" {
depends_on = [helm_release.calico]
triggers = {
kubeconfig_path = var.kubeconfig_path
calico_version = var.calico.version
pod_network_cidr = var.pod_network_cidr
}
set {
name = "global.tolerations[0].key"
value = "node-role.kubernetes.io/control-plane"
provisioner "local-exec" {
interpreter = ["/bin/bash", "-lc"]
command = <<EOT
set -euo pipefail
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n calico-system rollout status daemonset/calico-node --timeout=600s
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n calico-system rollout status deployment/calico-kube-controllers --timeout=600s
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" wait --for=condition=Ready nodes --all --timeout=600s
EOT
}
set {
name = "global.tolerations[0].operator"
value = "Exists"
}
resource "helm_release" "openebs" {
depends_on = [null_resource.calico_ready]
name = "openebs"
repository = var.openebs.repository
chart = "openebs"
version = var.openebs.version
namespace = var.openebs.namespace
create_namespace = true
timeout = 600
values = [
yamlencode({
engines = {
local = {
lvm = {
enabled = false
}
set {
name = "global.tolerations[0].effect"
value = "NoSchedule"
zfs = {
enabled = false
}
}
replicated = {
mayastor = {
enabled = false
}
}
}
loki = {
enabled = false
}
alloy = {
enabled = false
}
})
]
}
resource "kubernetes_storage_class_v1" "openebs_hostpath_retain" {
depends_on = [helm_release.openebs]
metadata {
name = var.openebs.retain_storage_class
annotations = {
"openebs.io/cas-type" = "local"
"cas.openebs.io/config" = yamlencode([{ name = "StorageType", value = "hostpath" }, { name = "BasePath", value = var.openebs.base_path }])
"storageclass.kubernetes.io/is-default-class" = "false"
}
}
storage_provisioner = "openebs.io/local"
reclaim_policy = "Retain"
volume_binding_mode = "WaitForFirstConsumer"
allow_volume_expansion = true
}
resource "helm_release" "argocd" {
depends_on = [helm_release.longhorn]
depends_on = [helm_release.openebs]
name = "argocd"
repository = "https://argoproj.github.io/argo-helm"
repository = var.argocd.repository
chart = "argo-cd"
namespace = "argocd"
version = var.argocd.version
namespace = var.argocd.namespace
create_namespace = true
timeout = 600
}
resource "kubernetes_secret_v1" "argocd_private_repo" {
resource "null_resource" "argocd_ready" {
depends_on = [helm_release.argocd]
metadata {
name = "laptop-bootstrap-repo-secret"
namespace = "argocd"
labels = {
"argocd.argoproj.io/secret-type" = "repository"
}
triggers = {
kubeconfig_path = var.kubeconfig_path
namespace = var.argocd.namespace
version = var.argocd.version
}
data = {
type = "git"
url = "ssh://jv@192.168.100.68/home/jv/git-server/my-homelab-configs.git"
sshPrivateKey = file("/home/jv/.ssh/id_ed25519")
provisioner "local-exec" {
interpreter = ["/bin/bash", "-lc"]
command = <<EOT
set -euo pipefail
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" wait --for=condition=Established --timeout=180s crd/applications.argoproj.io
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" rollout status deployment/argocd-repo-server --timeout=300s
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" rollout status deployment/argocd-server --timeout=300s
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" rollout status statefulset/argocd-application-controller --timeout=300s
EOT
}
}
resource "null_resource" "argocd_private_repo" {
depends_on = [null_resource.argocd_ready]
triggers = {
kubeconfig_path = var.kubeconfig_path
namespace = var.argocd.namespace
secret_name = var.argocd.repo_secret_name
repo_url = var.gitops_repo_url
ssh_key_path = var.gitops_ssh_key_path
}
provisioner "local-exec" {
interpreter = ["/bin/bash", "-lc"]
command = <<EOT
set -euo pipefail
repo_url="${self.triggers.repo_url}"
repo_target="$${repo_url#ssh://}"
repo_target="$${repo_target#*@}"
repo_target="$${repo_target%%/*}"
repo_host="$${repo_target%%:*}"
if [ -z "$${repo_host}" ]; then
echo "Could not determine GitOps SSH host from $${repo_url}" >&2
exit 1
fi
known_hosts_file="$(mktemp)"
known_hosts_sorted="$(mktemp)"
trap 'rm -f "$${known_hosts_file}" "$${known_hosts_sorted}"' EXIT
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" get configmap argocd-ssh-known-hosts-cm \
-o jsonpath='{.data.ssh_known_hosts}' > "$${known_hosts_file}" 2>/dev/null || true
ssh-keyscan -H "$${repo_host}" >> "$${known_hosts_file}" 2>/dev/null
sort -u "$${known_hosts_file}" > "$${known_hosts_sorted}"
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" create configmap argocd-ssh-known-hosts-cm \
--from-file=ssh_known_hosts="$${known_hosts_sorted}" \
--dry-run=client -o yaml | kubectl --kubeconfig "${self.triggers.kubeconfig_path}" apply -f -
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" create secret generic "${self.triggers.secret_name}" \
--from-literal=type=git \
--from-literal=url="${self.triggers.repo_url}" \
--from-file=sshPrivateKey="${self.triggers.ssh_key_path}" \
--dry-run=client -o yaml | kubectl --kubeconfig "${self.triggers.kubeconfig_path}" apply -f -
kubectl --kubeconfig "${self.triggers.kubeconfig_path}" -n "${self.triggers.namespace}" label secret "${self.triggers.secret_name}" \
argocd.argoproj.io/secret-type=repository --overwrite
EOT
}
}
resource "helm_release" "extra_tools" {
for_each = var.extra_helm_releases
depends_on = [null_resource.calico_ready]
name = each.key
repository = each.value.repository
chart = each.value.chart
version = each.value.version != "" ? each.value.version : null
namespace = each.value.namespace
create_namespace = each.value.create_namespace
timeout = each.value.timeout
values = each.value.values_yaml != "" ? [each.value.values_yaml] : []
dynamic "set" {
for_each = each.value.set_values
content {
name = set.key
value = set.value
}
}
}

View File

@ -0,0 +1,82 @@
variable "kubeconfig_path" {
type = string
default = "/home/jv/.kube/config"
}
variable "pod_network_cidr" {
type = string
default = "10.244.0.0/16"
}
variable "gitops_repo_url" {
type = string
default = "ssh://jv@192.168.100.68/home/jv/git-server/my-homelab-configs.git"
}
variable "gitops_ssh_key_path" {
type = string
default = "/home/jv/.ssh/id_ed25519"
}
variable "calico" {
type = object({
repository = string
version = string
namespace = string
})
default = {
repository = "https://docs.tigera.io/calico/charts"
version = "v3.32.0"
namespace = "tigera-operator"
}
}
variable "openebs" {
type = object({
repository = string
version = string
namespace = string
retain_storage_class = string
base_path = string
})
default = {
repository = "https://openebs.github.io/openebs"
version = "4.3.3"
namespace = "openebs"
retain_storage_class = "openebs-hostpath-retain"
base_path = "/var/openebs/local"
}
}
variable "argocd" {
type = object({
repository = string
version = string
namespace = string
repo_secret_name = string
})
default = {
repository = "https://argoproj.github.io/argo-helm"
version = "8.5.8"
namespace = "argocd"
repo_secret_name = "homelab-configs-repo"
}
}
variable "extra_helm_releases" {
type = map(object({
repository = string
chart = string
version = string
namespace = string
create_namespace = bool
timeout = number
values_yaml = string
set_values = map(string)
}))
default = {}
}

306
lab.sh
View File

@ -1,117 +1,263 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILDX_CONFIG="/tmp/buildx-config.toml"
KUBECONFIG_PATH="${KUBECONFIG_PATH:-${TF_VAR_kubeconfig_path:-/home/jv/.kube/config}}"
trap 'rm -f "${BUILDX_CONFIG}"' EXIT
run_tofu_stack() {
local stack="$1"
tofu -chdir="${REPO_ROOT}/${stack}" init
tofu -chdir="${REPO_ROOT}/${stack}" apply -auto-approve
}
cleanup_calico_links() {
ip link show | awk -F: '/^[0-9]+: cali/ {print $2}' | cut -d@ -f1 | xargs -r -n1 sudo ip link delete 2>/dev/null || true
sudo ip link delete vxlan.calico 2>/dev/null || true
sudo ip link delete tunl0 2>/dev/null || true
sudo ip link delete cni0 2>/dev/null || true
sudo ip link delete kube-ipvs0 2>/dev/null || true
ip netns list | awk '/^(cni-|calico)/ {print $1}' | xargs -r -n1 sudo ip netns delete 2>/dev/null || true
}
cleanup_iptables() {
sudo iptables -F || true
sudo iptables -X || true
sudo iptables -t nat -F || true
sudo iptables -t nat -X || true
sudo iptables -t mangle -F || true
sudo iptables -t mangle -X || true
sudo iptables -t raw -F || true
sudo iptables -t raw -X || true
if command -v ipvsadm >/dev/null 2>&1; then
sudo ipvsadm --clear || true
fi
}
cleanup_mounts() {
if command -v findmnt >/dev/null 2>&1; then
while IFS= read -r mountpoint; do
sudo umount -f "${mountpoint}" 2>/dev/null || sudo umount -l "${mountpoint}" 2>/dev/null || true
done < <(findmnt -Rno TARGET /var/lib/kubelet /var/lib/containerd 2>/dev/null | sort -r)
fi
while IFS= read -r mountpoint; do
sudo umount -f "${mountpoint}" 2>/dev/null || sudo umount -l "${mountpoint}" 2>/dev/null || true
done < <(find /var/lib/kubelet/pods -mindepth 2 -maxdepth 5 -type d 2>/dev/null || true)
sudo umount -f /var/lib/containerd/srun/* 2>/dev/null || sudo umount -l /var/lib/containerd/srun/* 2>/dev/null || true
}
cleanup_node() {
sudo kubeadm reset --force || true
sudo systemctl stop kubelet 2>/dev/null || true
sudo systemctl stop containerd 2>/dev/null || true
sudo killall containerd-shim-runc-v2 2>/dev/null || true
cleanup_mounts
sudo rm -rf \
/etc/kubernetes/ \
/var/lib/etcd/ \
/var/lib/kubelet/ \
/var/lib/cni/ \
/etc/cni/net.d \
/run/flannel \
/run/calico \
/var/run/calico \
/var/lib/calico \
/var/log/calico \
/var/lib/containerd/* \
/run/containerd/* \
/etc/containerd/certs.d \
/etc/containerd/config.toml
sudo rm -f /opt/cni/bin/calico /opt/cni/bin/calico-ipam
cleanup_iptables
cleanup_calico_links
sudo mkdir -p /etc/containerd/certs.d
sudo systemctl reset-failed kubelet containerd 2>/dev/null || true
sudo systemctl start containerd 2>/dev/null || true
}
website_registry_endpoint() {
local image
image="$(awk '$1 == "image:" && $2 ~ /php-website/ {print $2; exit}' "${REPO_ROOT}/apps/website/web-app.yaml")"
if [[ -z "${image}" || "${image}" != */* ]]; then
echo "Could not determine website registry endpoint from apps/website/web-app.yaml" >&2
exit 1
fi
printf '%s\n' "${image%%/*}"
}
up() {
local registry_endpoint
registry_endpoint="$(website_registry_endpoint)"
export TF_VAR_registry_endpoint="${TF_VAR_registry_endpoint:-${registry_endpoint}}"
export TF_VAR_kubeconfig_path="${TF_VAR_kubeconfig_path:-${KUBECONFIG_PATH}}"
export KUBECONFIG="${TF_VAR_kubeconfig_path}"
if [[ "${TF_VAR_registry_endpoint}" != "${registry_endpoint}" ]]; then
echo "TF_VAR_registry_endpoint must match apps/website/web-app.yaml (${registry_endpoint})" >&2
exit 1
fi
echo "Deploying the homelab infrastructure..."
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
cat <<EOF > /tmp/buildx-config.toml
cat <<EOF > "${BUILDX_CONFIG}"
[registry."${registry_endpoint}"]
http = true
insecure = true
[registry."127.0.0.1:30500"]
http = true
insecure = true
[registry."localhost:30500"]
http = true
insecure = true
EOF
docker buildx rm lab-builder 2>/dev/null || true
docker buildx create --name lab-builder --driver docker-container --driver-opt network=host --config /tmp/buildx-config.toml --use
docker buildx create --name lab-builder --driver docker-container --driver-opt network=host --config "${BUILDX_CONFIG}" --use
docker buildx inspect --bootstrap
cd bootstrap/cluster
tofu init
tofu apply -auto-approve
run_tofu_stack "bootstrap/cluster"
run_tofu_stack "bootstrap/platform"
run_tofu_stack "bootstrap/apps"
cd ../platform
tofu init
tofu apply -auto-approve
cd ../apps
tofu init
tofu apply -auto-approve
cd ../..
until kubectl get deployment local-registry -n container-registry -o jsonpath='{.status.availableReplicas}' 2>/dev/null | grep -q '^[1-9]'; do
echo "Waiting for local-registry pods to initialize..."
sleep 5
done
kubectl --kubeconfig "${KUBECONFIG}" -n container-registry rollout status deployment/local-registry --timeout=300s
docker buildx build \
--network host \
--platform linux/amd64,linux/arm64 \
-t "127.0.0.1:30500/php-website:latest" \
-f apps/website/Dockerfile \
apps/website/ \
-t "${registry_endpoint}/php-website:latest" \
-f "${REPO_ROOT}/apps/website/Dockerfile" \
"${REPO_ROOT}/apps/website/" \
--push
kubectl patch application php-web-app -n argocd --type merge -p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"sync"}}}'
kubectl --kubeconfig "${KUBECONFIG}" patch application website-production -n argocd --type merge -p '{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"sync"}}}'
echo "Deployment successfully completed!"
echo "Deployment successfully completed."
}
nuke() {
local worker_ssh_targets
local worker_targets
local target
echo "Brutally nuking the homelab infrastructure..."
worker_ssh_targets="${WORKER_SSH_TARGETS-jv@192.168.100.89}"
read -r -a worker_targets <<< "${worker_ssh_targets}"
echo "--> Terminating local OpenTofu tasks..."
killall tofu terraform 2>/dev/null || true
echo "--> Eviscerating local Kubernetes components (Laptop)..."
sudo kubeadm reset --force || true
sudo systemctl stop containerd 2>/dev/null || true
sudo killall containerd-shim-runc-v2 2>/dev/null || true
echo "--> Eviscerating local Kubernetes components..."
cleanup_node
sudo rm -f "${KUBECONFIG_PATH}"
sudo umount /var/lib/containerd/srun/* 2>/dev/null || true
sudo rm -rf /var/lib/containerd/* /run/containerd/*
sudo rm -rf /etc/kubernetes/ /var/lib/kubelet/ /var/lib/cni/ /etc/cni/net.d /home/jv/.kube/
for target in "${worker_targets[@]}"; do
echo "--> Eviscerating remote Kubernetes components (${target})..."
if ! ssh -o ConnectTimeout=5 "${target}" "bash -s" <<'EOF'
set -euo pipefail
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X
sudo ip link delete cilium_host 2>/dev/null || true
sudo ip link delete cilium_net 2>/dev/null || true
sudo ip link delete cilium_vxlan 2>/dev/null || true
sudo systemctl start containerd
echo "--> Eviscerating remote Kubernetes components (Raspberry Pi)..."
ssh -o ConnectTimeout=5 jv@192.168.100.89 << 'EOF' 2>/dev/null || true
# 1. Force reset kubeadm configurations
sudo kubeadm reset --force || true
# 2. Halt the container runtime engine to drop file descriptor and socket locks
sudo systemctl stop containerd 2>/dev/null || true
sudo killall containerd-shim-runc-v2 2>/dev/null || true
# 3. Unmount any lingering ephemeral pod volumes, secrets, or token rings
sudo umount -f /var/lib/kubelet/pods/*/*/*/* 2>/dev/null || true
# 4. Completely wipe the cluster file configurations and runtime data tracks
sudo rm -rf /etc/kubernetes/ /var/lib/kubelet/ /var/lib/cni/ /etc/cni/net.d
sudo rm -rf /var/lib/containerd/* /run/containerd/*
# 5. Reset network routing policies left over by the CNI
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X
# 6. Bring the container engine back online with a completely clean state slate
sudo systemctl start containerd
EOF
docker buildx rm lab-builder 2>/dev/null || true
rm -f /tmp/buildx-config.toml || true
echo "--> Deleting OpenTofu tracking state files..."
rm -rf bootstrap/cluster/terraform.tfstate*
rm -rf bootstrap/cluster/.terraform/
rm -rf bootstrap/cluster/.terraform.lock.hcl
rm -rf bootstrap/platform/terraform.tfstate*
rm -rf bootstrap/platform/.terraform/
rm -rf bootstrap/platform/.terraform.lock.hcl
rm -rf bootstrap/apps/terraform.tfstate*
rm -rf bootstrap/apps/.terraform/
rm -rf bootstrap/apps/.terraform.lock.hcl
echo "Destruction complete! Your hardware is completely sanitized."
cleanup_calico_links() {
ip link show | awk -F: '/^[0-9]+: cali/ {print $2}' | cut -d@ -f1 | xargs -r -n1 sudo ip link delete 2>/dev/null || true
sudo ip link delete vxlan.calico 2>/dev/null || true
sudo ip link delete tunl0 2>/dev/null || true
sudo ip link delete cni0 2>/dev/null || true
sudo ip link delete kube-ipvs0 2>/dev/null || true
ip netns list | awk '/^(cni-|calico)/ {print $1}' | xargs -r -n1 sudo ip netns delete 2>/dev/null || true
}
case "$1" in
cleanup_iptables() {
sudo iptables -F || true
sudo iptables -X || true
sudo iptables -t nat -F || true
sudo iptables -t nat -X || true
sudo iptables -t mangle -F || true
sudo iptables -t mangle -X || true
sudo iptables -t raw -F || true
sudo iptables -t raw -X || true
if command -v ipvsadm >/dev/null 2>&1; then
sudo ipvsadm --clear || true
fi
}
cleanup_mounts() {
if command -v findmnt >/dev/null 2>&1; then
while IFS= read -r mountpoint; do
sudo umount -f "${mountpoint}" 2>/dev/null || sudo umount -l "${mountpoint}" 2>/dev/null || true
done < <(findmnt -Rno TARGET /var/lib/kubelet /var/lib/containerd 2>/dev/null | sort -r)
fi
while IFS= read -r mountpoint; do
sudo umount -f "${mountpoint}" 2>/dev/null || sudo umount -l "${mountpoint}" 2>/dev/null || true
done < <(find /var/lib/kubelet/pods -mindepth 2 -maxdepth 5 -type d 2>/dev/null || true)
sudo umount -f /var/lib/containerd/srun/* 2>/dev/null || sudo umount -l /var/lib/containerd/srun/* 2>/dev/null || true
}
sudo kubeadm reset --force || true
sudo systemctl stop kubelet 2>/dev/null || true
sudo systemctl stop containerd 2>/dev/null || true
sudo killall containerd-shim-runc-v2 2>/dev/null || true
cleanup_mounts
sudo rm -rf \
/etc/kubernetes/ \
/var/lib/etcd/ \
/var/lib/kubelet/ \
/var/lib/cni/ \
/etc/cni/net.d \
/run/flannel \
/run/calico \
/var/run/calico \
/var/lib/calico \
/var/log/calico \
/var/lib/containerd/* \
/run/containerd/* \
/etc/containerd/certs.d \
/etc/containerd/config.toml
sudo rm -f /opt/cni/bin/calico /opt/cni/bin/calico-ipam
cleanup_iptables
cleanup_calico_links
sudo mkdir -p /etc/containerd/certs.d
sudo systemctl reset-failed kubelet containerd 2>/dev/null || true
sudo systemctl start containerd 2>/dev/null || true
EOF
then
echo "Remote cleanup failed for ${target}; not deleting OpenTofu state." >&2
exit 1
fi
done
docker buildx rm lab-builder 2>/dev/null || true
docker rm -f buildx_buildkit_lab-builder0 2>/dev/null || true
rm -f "${BUILDX_CONFIG}" || true
echo "--> Deleting OpenTofu tracking state files..."
rm -rf "${REPO_ROOT}"/bootstrap/cluster/terraform.tfstate*
rm -f "${REPO_ROOT}"/bootstrap/cluster/.terraform.tfstate.lock.info
rm -rf "${REPO_ROOT}"/bootstrap/cluster/.terraform/
rm -rf "${REPO_ROOT}"/bootstrap/platform/terraform.tfstate*
rm -f "${REPO_ROOT}"/bootstrap/platform/.terraform.tfstate.lock.info
rm -rf "${REPO_ROOT}"/bootstrap/platform/.terraform/
rm -rf "${REPO_ROOT}"/bootstrap/apps/terraform.tfstate*
rm -f "${REPO_ROOT}"/bootstrap/apps/.terraform.tfstate.lock.info
rm -rf "${REPO_ROOT}"/bootstrap/apps/.terraform/
echo "Destruction complete. Retained data under /var/openebs/local was left intact."
}
case "${1:-}" in
up)
up
;;