From bf40bd7f1560ae51c1650adbdbb66cd5964bd19d Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sat, 4 Apr 2026 18:43:44 -0500 Subject: [PATCH] feat: add Listmonk newsletter platform on Legion k3s - Namespace, ExternalSecrets (Vault), DB init job, deployment, service, PVC, ingress - Routes newsletter.harmonic-framework.com via existing Cloudflare tunnel - Uses shared platform postgres with dedicated listmonk DB/user - Credentials stored at secret/data/listmonk in Vault --- servers/legion/apps/listmonk.yaml | 20 +++ servers/legion/k8s/listmonk/db-init-job.yaml | 56 ++++++++ servers/legion/k8s/listmonk/deployment.yaml | 121 ++++++++++++++++++ .../legion/k8s/listmonk/external-secrets.yaml | 56 ++++++++ servers/legion/k8s/listmonk/ingress.yaml | 26 ++++ servers/legion/k8s/listmonk/pvc.yaml | 12 ++ servers/legion/namespaces.tf | 10 ++ 7 files changed, 301 insertions(+) create mode 100644 servers/legion/apps/listmonk.yaml create mode 100644 servers/legion/k8s/listmonk/db-init-job.yaml create mode 100644 servers/legion/k8s/listmonk/deployment.yaml create mode 100644 servers/legion/k8s/listmonk/external-secrets.yaml create mode 100644 servers/legion/k8s/listmonk/ingress.yaml create mode 100644 servers/legion/k8s/listmonk/pvc.yaml diff --git a/servers/legion/apps/listmonk.yaml b/servers/legion/apps/listmonk.yaml new file mode 100644 index 0000000..2715b45 --- /dev/null +++ b/servers/legion/apps/listmonk.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: listmonk + namespace: argocd +spec: + project: default + source: + repoURL: https://git.neuralplatform.ai/will/infrastructure.git + targetRevision: main + path: servers/legion/k8s/listmonk + destination: + server: https://kubernetes.default.svc + namespace: listmonk + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/servers/legion/k8s/listmonk/db-init-job.yaml b/servers/legion/k8s/listmonk/db-init-job.yaml new file mode 100644 index 0000000..deccd72 --- /dev/null +++ b/servers/legion/k8s/listmonk/db-init-job.yaml @@ -0,0 +1,56 @@ +--- +# One-time job to create the listmonk database and user in the shared postgres. +# Safe to re-run (DO $$ guards prevent duplicate creation). +# Argo CD will re-apply if deleted; ttlSecondsAfterFinished cleans it up automatically. +apiVersion: batch/v1 +kind: Job +metadata: + name: listmonk-db-init + namespace: listmonk +spec: + ttlSecondsAfterFinished: 300 + template: + spec: + restartPolicy: OnFailure + containers: + - name: psql + image: postgres:16-alpine + command: + - sh + - -c + - | + PG_URI="postgresql://postgres:${POSTGRES_PASSWORD}@postgres-postgresql.platform.svc.cluster.local:5432/postgres" + # Create user if missing + psql "$PG_URI" -c "DO \$\$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${DB_USER}') THEN + EXECUTE format('CREATE USER %I WITH PASSWORD %L', '${DB_USER}', '${DB_PASSWORD}'); + END IF; + END \$\$;" + # Create DB if missing + psql "$PG_URI" -c "SELECT 'CREATE DATABASE ${DB_NAME} OWNER ${DB_USER}'" \ + | grep -q 'CREATE DATABASE' && \ + psql "$PG_URI" -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" || true + # Ensure privileges + psql "$PG_URI" -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};" + echo "DB init complete." + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-superuser + key: password + - name: DB_USER + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_user + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_password + - name: DB_NAME + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_name diff --git a/servers/legion/k8s/listmonk/deployment.yaml b/servers/legion/k8s/listmonk/deployment.yaml new file mode 100644 index 0000000..d780e05 --- /dev/null +++ b/servers/legion/k8s/listmonk/deployment.yaml @@ -0,0 +1,121 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: listmonk + namespace: listmonk +spec: + replicas: 1 + selector: + matchLabels: + app: listmonk + template: + metadata: + labels: + app: listmonk + spec: + initContainers: + # Run `--install` on first boot to create the schema; no-op on subsequent starts + - name: listmonk-install + image: listmonk/listmonk:v4.1.0 + args: ["--install", "--yes", "--idempotent"] + env: + - name: LISTMONK_db__host + value: "postgres-postgresql.platform.svc.cluster.local" + - name: LISTMONK_db__port + value: "5432" + - name: LISTMONK_db__name + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_name + - name: LISTMONK_db__user + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_user + - name: LISTMONK_db__password + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_password + - name: LISTMONK_db__ssl_mode + value: "disable" + - name: LISTMONK_app__admin_username + value: "admin" + - name: LISTMONK_app__admin_password + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: admin_password + containers: + - name: listmonk + image: listmonk/listmonk:v4.1.0 + ports: + - containerPort: 9000 + env: + - name: LISTMONK_db__host + value: "postgres-postgresql.platform.svc.cluster.local" + - name: LISTMONK_db__port + value: "5432" + - name: LISTMONK_db__name + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_name + - name: LISTMONK_db__user + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_user + - name: LISTMONK_db__password + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: db_password + - name: LISTMONK_db__ssl_mode + value: "disable" + - name: LISTMONK_app__admin_username + value: "admin" + - name: LISTMONK_app__admin_password + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: admin_password + - name: LISTMONK_app__secret_key + valueFrom: + secretKeyRef: + name: listmonk-secrets + key: app_key + volumeMounts: + - name: uploads + mountPath: /listmonk/uploads + readinessProbe: + httpGet: + path: /health + port: 9000 + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: uploads + persistentVolumeClaim: + claimName: listmonk-uploads +--- +apiVersion: v1 +kind: Service +metadata: + name: listmonk + namespace: listmonk +spec: + selector: + app: listmonk + ports: + - port: 9000 + targetPort: 9000 diff --git a/servers/legion/k8s/listmonk/external-secrets.yaml b/servers/legion/k8s/listmonk/external-secrets.yaml new file mode 100644 index 0000000..13bcbb3 --- /dev/null +++ b/servers/legion/k8s/listmonk/external-secrets.yaml @@ -0,0 +1,56 @@ +--- +# listmonk-secrets — DB credentials and admin password from Vault +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: listmonk-secrets + namespace: listmonk +spec: + refreshInterval: 1h + secretStoreRef: + name: vault + kind: ClusterSecretStore + target: + name: listmonk-secrets + creationPolicy: Owner + data: + - secretKey: db_password + remoteRef: + key: secret/data/listmonk + property: db_password + - secretKey: db_user + remoteRef: + key: secret/data/listmonk + property: db_user + - secretKey: db_name + remoteRef: + key: secret/data/listmonk + property: db_name + - secretKey: admin_password + remoteRef: + key: secret/data/listmonk + property: admin_password + - secretKey: app_key + remoteRef: + key: secret/data/listmonk + property: app_key +--- +# postgres superuser password — needed for DB init job +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: postgres-superuser + namespace: listmonk +spec: + refreshInterval: 1h + secretStoreRef: + name: vault + kind: ClusterSecretStore + target: + name: postgres-superuser + creationPolicy: Owner + data: + - secretKey: password + remoteRef: + key: secret/data/legion-db + property: postgres_password diff --git a/servers/legion/k8s/listmonk/ingress.yaml b/servers/legion/k8s/listmonk/ingress.yaml new file mode 100644 index 0000000..9c49b3b --- /dev/null +++ b/servers/legion/k8s/listmonk/ingress.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: listmonk + namespace: listmonk + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: traefik + tls: + - hosts: + - newsletter.harmonic-framework.com + secretName: listmonk-tls + rules: + - host: newsletter.harmonic-framework.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: listmonk + port: + number: 9000 diff --git a/servers/legion/k8s/listmonk/pvc.yaml b/servers/legion/k8s/listmonk/pvc.yaml new file mode 100644 index 0000000..e9ffd55 --- /dev/null +++ b/servers/legion/k8s/listmonk/pvc.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: listmonk-uploads + namespace: listmonk +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/servers/legion/namespaces.tf b/servers/legion/namespaces.tf index c840944..b99225c 100644 --- a/servers/legion/namespaces.tf +++ b/servers/legion/namespaces.tf @@ -47,3 +47,13 @@ resource "kubernetes_namespace" "harmonic_wordpress" { } } } + +resource "kubernetes_namespace" "listmonk" { + metadata { + name = "listmonk" + labels = { + managed-by = "terraform" + tier = "apps" + } + } +}