diff --git a/README.md b/README.md index e4f704a..4405930 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ accidentally modify the cluster. - registers Argo CD Applications from the `applications` map - passes the website image produced by the build step into Argo CD as a Kustomize image override - - default apps are `container-registry`, `website-production`, and - `demos-static` + - default apps are `container-registry`, `website-production`, + `demos-static`, and `heimdall` 5. `bootstrap/edge` - connects to the OCI jump box @@ -151,6 +151,7 @@ kubectl -n argocd get applications kubectl -n container-registry get pods kubectl -n website-production get pods -o wide kubectl -n demos-static get pods -o wide +kubectl -n heimdall get pods -o wide ssh jv@192.168.100.89 'cd /opt/homelab-gitea && sudo docker compose ps' @@ -263,7 +264,8 @@ tailscale_nodeport_access = { target_port = 8080 } -tailscale_nodeport_extra_ports = [30081] +tailscale_nodeport_extra_ports = [30081, 30082, 30083, 30084, 30085, 30086] +tailscale_nodeport_extra_target_ports = [80, 3000, 9090, 9093] ``` For `./lab.sh nuke`, set `WORKER_SSH_TARGETS` to a space-separated list of @@ -355,6 +357,14 @@ Add Kubernetes manifests under `apps/` and register them in `bootstrap/apps`'s `applications` map. Argo CD will own sync, pruning, and self-healing for the app. +The `heimdall` app is intentionally waited on at the end of `./lab.sh apps`. +It runs the LinuxServer.io Heimdall dashboard, persists `/config` on +OpenEBS, and seeds tiles for the website, demo apps, Gitea, Grafana, +Prometheus, Alertmanager, Argo CD, the local registry, and Heimdall itself. +Because Heimdall does not support reverse-proxy subfolder hosting cleanly, it +is exposed through NodePort `30082`; the dashboard's internal tool links use +the Raspberry Pi Tailscale NodePort address. + ## Storage OpenEBS provides the platform storage provisioner. Stateful Kubernetes apps use diff --git a/apps/heimdall/kustomization.yaml b/apps/heimdall/kustomization.yaml new file mode 100644 index 0000000..e524ce6 --- /dev/null +++ b/apps/heimdall/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - web-app.yaml + - ui-nodeports.yaml diff --git a/apps/heimdall/namespace.yaml b/apps/heimdall/namespace.yaml new file mode 100644 index 0000000..c042b6b --- /dev/null +++ b/apps/heimdall/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: heimdall diff --git a/apps/heimdall/ui-nodeports.yaml b/apps/heimdall/ui-nodeports.yaml new file mode 100644 index 0000000..f189b18 --- /dev/null +++ b/apps/heimdall/ui-nodeports.yaml @@ -0,0 +1,62 @@ +apiVersion: v1 +kind: Service +metadata: + name: grafana-nodeport + namespace: monitoring +spec: + type: NodePort + ports: + - name: http + port: 80 + targetPort: 3000 + nodePort: 30083 + selector: + app.kubernetes.io/instance: prometheus-stack + app.kubernetes.io/name: grafana +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus-nodeport + namespace: monitoring +spec: + type: NodePort + ports: + - name: http + port: 9090 + targetPort: 9090 + nodePort: 30084 + selector: + app.kubernetes.io/name: prometheus + operator.prometheus.io/name: prometheus-stack-kube-prom-prometheus +--- +apiVersion: v1 +kind: Service +metadata: + name: alertmanager-nodeport + namespace: monitoring +spec: + type: NodePort + ports: + - name: http + port: 9093 + targetPort: 9093 + nodePort: 30085 + selector: + app.kubernetes.io/name: alertmanager + alertmanager: prometheus-stack-kube-prom-alertmanager +--- +apiVersion: v1 +kind: Service +metadata: + name: argocd-server-nodeport + namespace: argocd +spec: + type: NodePort + ports: + - name: https + port: 443 + targetPort: 8080 + nodePort: 30086 + selector: + app.kubernetes.io/name: argocd-server diff --git a/apps/heimdall/web-app.yaml b/apps/heimdall/web-app.yaml new file mode 100644 index 0000000..bc322b5 --- /dev/null +++ b/apps/heimdall/web-app.yaml @@ -0,0 +1,285 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: heimdall-config + namespace: heimdall +spec: + accessModes: + - ReadWriteOnce + storageClassName: openebs-hostpath-retain + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: heimdall-link-seed + namespace: heimdall +data: + links.json: | + [ + { + "title": "Website", + "url": "https://lab2025.duckdns.org/", + "description": "Public portfolio website", + "colour": "#0f766e", + "class": "Website" + }, + { + "title": "Demo Apps", + "url": "https://lab2025.duckdns.org/demo-apps/", + "description": "Static browser-side demo catalog", + "colour": "#2563eb", + "class": "Demos" + }, + { + "title": "Gitea", + "url": "https://lab2025.duckdns.org/git/", + "description": "External Git service on the Raspberry Pi", + "colour": "#609926", + "class": "Gitea" + }, + { + "title": "Grafana", + "url": "http://100.77.80.72:30083/", + "description": "Monitoring dashboards", + "colour": "#f97316", + "class": "Grafana" + }, + { + "title": "Prometheus", + "url": "http://100.77.80.72:30084/", + "description": "Prometheus query UI", + "colour": "#dc2626", + "class": "Prometheus" + }, + { + "title": "Alertmanager", + "url": "http://100.77.80.72:30085/", + "description": "Alert routing and silences", + "colour": "#b91c1c", + "class": "Alertmanager" + }, + { + "title": "Argo CD", + "url": "https://100.77.80.72:30086/", + "description": "GitOps application sync status", + "colour": "#0ea5e9", + "class": "ArgoCD" + }, + { + "title": "Container Registry", + "url": "http://100.77.80.72:30500/v2/_catalog", + "description": "Local image registry catalog endpoint", + "colour": "#334155", + "class": "Docker" + }, + { + "title": "Heimdall", + "url": "http://100.77.80.72:30082/", + "description": "Homelab service dashboard", + "colour": "#7c3aed", + "class": "Heimdall" + } + ] + seed.py: | + import json + import os + import sqlite3 + import time + from datetime import datetime, timezone + + DB_CANDIDATES = ( + "/config/www/app.sqlite", + "/config/app.sqlite", + ) + LINKS_PATH = "/seed/links.json" + + def find_database(): + while True: + for path in DB_CANDIDATES: + if os.path.exists(path): + return path + time.sleep(5) + + def table_exists(conn, name): + row = conn.execute( + "select name from sqlite_master where type = 'table' and name = ?", + (name,), + ).fetchone() + return row is not None + + def columns(conn, table): + return {row[1] for row in conn.execute(f"pragma table_info({table})")} + + def wait_for_items_table(db_path): + while True: + try: + conn = sqlite3.connect(db_path) + if table_exists(conn, "items"): + return conn + conn.close() + except sqlite3.Error: + pass + time.sleep(5) + + def upsert_links(conn, links): + item_columns = columns(conn, "items") + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + for order, link in enumerate(links): + existing = conn.execute( + "select id from items where title = ?", + (link["title"],), + ).fetchone() + values = { + "title": link["title"], + "url": link["url"], + "description": link.get("description"), + "colour": link.get("colour"), + "pinned": 1, + "order": order, + "type": 0, + "user_id": 1, + "class": link.get("class"), + "appid": None, + "appdescription": link.get("description"), + "created_at": now, + "updated_at": now, + "deleted_at": None, + } + + if existing: + update_columns = [ + name for name in values + if name in item_columns and name not in ("id", "title", "created_at") + ] + assignments = ", ".join(f"{name} = ?" for name in update_columns) + params = [values[name] for name in update_columns] + params.append(existing[0]) + conn.execute(f"update items set {assignments} where id = ?", params) + continue + + insert_columns = [ + name for name in values + if name in item_columns and name != "deleted_at" + ] + placeholders = ", ".join("?" for _ in insert_columns) + conn.execute( + f"insert into items ({', '.join(insert_columns)}) values ({placeholders})", + [values[name] for name in insert_columns], + ) + + conn.commit() + + with open(LINKS_PATH, encoding="utf-8") as links_file: + links = json.load(links_file) + + db_path = find_database() + connection = wait_for_items_table(db_path) + try: + upsert_links(connection, links) + finally: + connection.close() + + while True: + time.sleep(3600) +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: heimdall + namespace: heimdall + labels: + app: heimdall +spec: + replicas: 1 + selector: + matchLabels: + app: heimdall + template: + metadata: + labels: + app: heimdall + spec: + nodeSelector: + kubernetes.io/os: linux + homelab.dev/workload-class: platform + securityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + containers: + - name: heimdall + image: lscr.io/linuxserver/heimdall:version-2.7.6 + imagePullPolicy: IfNotPresent + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: TZ + value: America/Mexico_City + ports: + - containerPort: 80 + name: http + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: + cpu: 25m + memory: 128Mi + limits: + memory: 512Mi + volumeMounts: + - name: heimdall-config + mountPath: /config + - name: link-seeder + image: python:3.12-alpine + imagePullPolicy: IfNotPresent + command: + - python + - /seed/seed.py + resources: + requests: + cpu: 5m + memory: 32Mi + limits: + memory: 96Mi + volumeMounts: + - name: heimdall-config + mountPath: /config + - name: link-seed + mountPath: /seed + readOnly: true + volumes: + - name: heimdall-config + persistentVolumeClaim: + claimName: heimdall-config + - name: link-seed + configMap: + name: heimdall-link-seed +--- +apiVersion: v1 +kind: Service +metadata: + name: heimdall + namespace: heimdall +spec: + type: NodePort + ports: + - port: 80 + targetPort: http + nodePort: 30082 + selector: + app: heimdall diff --git a/bootstrap/apps/variables.tf b/bootstrap/apps/variables.tf index 561a59d..76410f7 100644 --- a/bootstrap/apps/variables.tf +++ b/bootstrap/apps/variables.tf @@ -57,5 +57,14 @@ variable "applications" { self_heal = true create_namespace = true } + heimdall = { + project = "default" + path = "apps/heimdall" + namespace = "heimdall" + target_revision = "main" + prune = true + self_heal = true + create_namespace = true + } } } diff --git a/bootstrap/cluster/variables.tf b/bootstrap/cluster/variables.tf index f57685c..88ebc2f 100644 --- a/bootstrap/cluster/variables.tf +++ b/bootstrap/cluster/variables.tf @@ -93,10 +93,10 @@ variable "tailscale_nodeport_access" { variable "tailscale_nodeport_extra_ports" { type = list(number) - default = [30081] + default = [30081, 30082, 30083, 30084, 30085, 30086] } variable "tailscale_nodeport_extra_target_ports" { type = list(number) - default = [] + default = [80, 3000, 9090, 9093] } diff --git a/lab.sh b/lab.sh index 277706e..32aad07 100755 --- a/lab.sh +++ b/lab.sh @@ -172,6 +172,14 @@ adopt_apps_existing_resources() { "argoproj.io/v1alpha1" \ "Application" \ "demos-static" + adopt_tofu_kubernetes_manifest \ + "${stack}" \ + 'kubernetes_manifest.argocd_application["heimdall"]' \ + "${namespace}" \ + "applications.argoproj.io" \ + "argoproj.io/v1alpha1" \ + "Application" \ + "heimdall" } ensure_homelab_node_labels() { @@ -2690,6 +2698,11 @@ apps() { write_demos_image_state "${demos_image_state_file}" "${demos_source_hash}" "${demos_platforms}" "${demos_image_ref}" fi + refresh_argocd_application heimdall + wait_for_namespace heimdall heimdall 300 + wait_for_namespaced_resource heimdall deployment heimdall heimdall 300 + wait_for_deployment_ready heimdall heimdall heimdall 300 + echo "Application deployment successfully completed." }