my-homelab-configs/apps/heimdall/web-app.yaml

352 lines
10 KiB
YAML

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