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://demos.lab2025.duckdns.org/", "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": "https://grafana.lab2025.duckdns.org/", "description": "Monitoring dashboards", "colour": "#f97316", "class": "Grafana" }, { "title": "Prometheus", "url": "https://prometheus.lab2025.duckdns.org/", "description": "Prometheus query UI", "colour": "#dc2626", "class": "Prometheus" }, { "title": "Alertmanager", "url": "https://alertmanager.lab2025.duckdns.org/", "description": "Alert routing and silences", "colour": "#b91c1c", "class": "Alertmanager" }, { "title": "Argo CD", "url": "https://argocd.lab2025.duckdns.org/", "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": "https://heimdall.lab2025.duckdns.org/", "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 quote_identifier(name): return '"' + name.replace('"', '""') + '"' def ensure_public_dashboard(conn, user_id): if not table_exists(conn, "users"): return user_columns = columns(conn, "users") if "public_front" not in user_columns: return conn.execute( 'update "users" set "public_front" = 1 where "id" = ?', (user_id,), ) def ensure_dashboard_tag(conn, item_id, tag_id, now): if not table_exists(conn, "item_tag"): return item_tag_columns = columns(conn, "item_tag") if not {"item_id", "tag_id"}.issubset(item_tag_columns): return existing = conn.execute( 'select 1 from "item_tag" where "item_id" = ? and "tag_id" = ?', (item_id, tag_id), ).fetchone() if existing: return values = { "item_id": item_id, "tag_id": tag_id, "created_at": now, "updated_at": now, } insert_columns = [name for name in values if name in item_tag_columns] placeholders = ", ".join("?" for _ in insert_columns) quoted_insert_columns = ", ".join(quote_identifier(name) for name in insert_columns) conn.execute( f'insert into "item_tag" ({quoted_insert_columns}) values ({placeholders})', [values[name] for name in insert_columns], ) def home_dashboard_tag_id(conn): item_columns = columns(conn, "items") if not {"id", "title"}.issubset(item_columns): return 0 row = conn.execute( 'select "id" from "items" where "title" = ? order by "id" limit 1', ("app.dashboard",), ).fetchone() if row: return row[0] return 0 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") seed_user_id = 1 home_dashboard_tag = home_dashboard_tag_id(conn) now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") ensure_public_dashboard(conn, seed_user_id) 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": seed_user_id, "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"{quote_identifier(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) ensure_dashboard_tag(conn, existing[0], home_dashboard_tag, now) continue insert_columns = [ name for name in values if name in item_columns and name != "deleted_at" ] placeholders = ", ".join("?" for _ in insert_columns) quoted_insert_columns = ", ".join(quote_identifier(name) for name in insert_columns) conn.execute( f'insert into "items" ({quoted_insert_columns}) values ({placeholders})', [values[name] for name in insert_columns], ) new_item_id = conn.execute("select last_insert_rowid()").fetchone()[0] ensure_dashboard_tag(conn, new_item_id, home_dashboard_tag, now) conn.commit() with open(LINKS_PATH, encoding="utf-8") as links_file: links = json.load(links_file) while True: db_path = find_database() connection = wait_for_items_table(db_path) try: upsert_links(connection, links) time.sleep(3600) except sqlite3.Error as error: print(f"Failed to seed Heimdall links: {error}", flush=True) time.sleep(10) finally: connection.close() --- apiVersion: apps/v1 kind: Deployment metadata: name: heimdall namespace: heimdall labels: app: heimdall spec: replicas: 1 strategy: type: Recreate 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:v2.7.6-ls347 imagePullPolicy: IfNotPresent env: - name: PUID value: "1000" - name: PGID value: "1000" - name: TZ value: America/Mexico_City - name: APP_URL value: "https://heimdall.lab2025.duckdns.org" - name: ASSET_URL value: "https://heimdall.lab2025.duckdns.org" 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: ports: - port: 80 targetPort: http selector: app: heimdall