diff --git a/README.md b/README.md index 016e0bb..15dc377 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # titan-iac -Flux-managed Kubernetes cluster for bstein.dev services. See `AGENTS.md` for contributor guidance and service-specific manifests under `services/` and `infrastructure/`. +Flux-managed Kubernetes cluster for bstein.dev services. diff --git a/clusters/atlas/flux-system/platform/gitops-ui/kustomization.yaml b/clusters/atlas/flux-system/platform/gitops-ui/kustomization.yaml new file mode 100644 index 0000000..7c83b22 --- /dev/null +++ b/clusters/atlas/flux-system/platform/gitops-ui/kustomization.yaml @@ -0,0 +1,19 @@ +# clusters/atlas/flux-system/platform/gitops-ui/kustomization.yaml +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: gitops-ui + namespace: flux-system +spec: + interval: 10m + path: ./services/gitops-ui + prune: true + sourceRef: + kind: GitRepository + name: flux-system + namespace: flux-system + targetNamespace: flux-system + dependsOn: + - name: helm + - name: traefik + wait: true diff --git a/clusters/atlas/flux-system/platform/kustomization.yaml b/clusters/atlas/flux-system/platform/kustomization.yaml index 59d2032..040e478 100644 --- a/clusters/atlas/flux-system/platform/kustomization.yaml +++ b/clusters/atlas/flux-system/platform/kustomization.yaml @@ -5,5 +5,6 @@ resources: - core/kustomization.yaml - helm/kustomization.yaml - traefik/kustomization.yaml + - gitops-ui/kustomization.yaml - monitoring/kustomization.yaml - longhorn-ui/kustomization.yaml diff --git a/infrastructure/sources/helm/kustomization.yaml b/infrastructure/sources/helm/kustomization.yaml new file mode 100644 index 0000000..7f5db9d --- /dev/null +++ b/infrastructure/sources/helm/kustomization.yaml @@ -0,0 +1,11 @@ +# infrastructure/sources/helm/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - grafana.yaml + - hashicorp.yaml + - jetstack.yaml + - mailu.yaml + - prometheus.yaml + - victoria-metrics.yaml + - weave-gitops.yaml diff --git a/infrastructure/sources/helm/weave-gitops.yaml b/infrastructure/sources/helm/weave-gitops.yaml new file mode 100644 index 0000000..bca57fe --- /dev/null +++ b/infrastructure/sources/helm/weave-gitops.yaml @@ -0,0 +1,9 @@ +# infrastructure/sources/helm/weave-gitops.yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: weave-gitops + namespace: flux-system +spec: + interval: 1h + url: https://charts.gitops.weave.works diff --git a/scripts/mailu_sync.py b/scripts/mailu_sync.py index bb6e261..ee8aa18 100644 --- a/scripts/mailu_sync.py +++ b/scripts/mailu_sync.py @@ -24,7 +24,7 @@ KC_CLIENT_ID = os.environ["KEYCLOAK_CLIENT_ID"] KC_CLIENT_SECRET = os.environ["KEYCLOAK_CLIENT_SECRET"] MAILU_DOMAIN = os.environ["MAILU_DOMAIN"] -MAILU_DEFAULT_QUOTA = int(os.environ.get("MAILU_DEFAULT_QUOTA", "1000000000")) +MAILU_DEFAULT_QUOTA = int(os.environ.get("MAILU_DEFAULT_QUOTA", "20000000000")) DB_CONFIG = { "host": os.environ["MAILU_DB_HOST"], diff --git a/services/gitops-ui/helmrelease.yaml b/services/gitops-ui/helmrelease.yaml new file mode 100644 index 0000000..3e18e39 --- /dev/null +++ b/services/gitops-ui/helmrelease.yaml @@ -0,0 +1,49 @@ +# services/gitops-ui/helmrelease.yaml +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: weave-gitops + namespace: flux-system +spec: + interval: 30m + chart: + spec: + chart: weave-gitops + version: 4.0.36 + sourceRef: + kind: HelmRepository + name: weave-gitops + namespace: flux-system + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + remediateLastFailure: true + cleanupOnFail: true + values: + adminUser: + create: true + createClusterRole: true + createSecret: true + username: admin + # bcrypt hash for temporary password "G1tOps!2025" (rotate after login) + passwordHash: "$2y$12$wDEOzR1Gc2dbvNSJ3ZXNdOBVFEjC6YASIxnZmHIbO.W1m0fie/QVi" + ingress: + enabled: true + className: traefik + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + hosts: + - host: cd.bstein.dev + paths: + - path: / + pathType: Prefix + tls: + - secretName: gitops-ui-tls + hosts: + - cd.bstein.dev + metrics: + enabled: true diff --git a/services/gitops-ui/kustomization.yaml b/services/gitops-ui/kustomization.yaml new file mode 100644 index 0000000..b5d985d --- /dev/null +++ b/services/gitops-ui/kustomization.yaml @@ -0,0 +1,6 @@ +# services/gitops-ui/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: flux-system +resources: + - helmrelease.yaml diff --git a/services/keycloak/deployment.yaml b/services/keycloak/deployment.yaml index af7839f..c4ffcda 100644 --- a/services/keycloak/deployment.yaml +++ b/services/keycloak/deployment.yaml @@ -48,6 +48,20 @@ spec: runAsGroup: 0 fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch + imagePullSecrets: + - name: zot-regcred + initContainers: + - name: mailu-http-listener + image: registry.bstein.dev/sso/mailu-http-listener:0.1.0 + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + cp /plugin/mailu-http-listener-0.1.0.jar /providers/ + cp -r /plugin/src /providers/src + volumeMounts: + - name: providers + mountPath: /providers containers: - name: keycloak image: quay.io/keycloak/keycloak:26.0.7 @@ -104,6 +118,10 @@ spec: secretKeyRef: name: keycloak-admin key: password + - name: KC_EVENTS_LISTENERS + value: jboss-logging,mailu-http + - name: KC_SPI_EVENTS_LISTENER_MAILU-HTTP_ENDPOINT + value: http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events ports: - containerPort: 8080 name: http @@ -126,7 +144,11 @@ spec: volumeMounts: - name: data mountPath: /opt/keycloak/data + - name: providers + mountPath: /opt/keycloak/providers volumes: - name: data persistentVolumeClaim: claimName: keycloak-data + - name: providers + emptyDir: {} diff --git a/services/mailu/helmrelease.yaml b/services/mailu/helmrelease.yaml index 8469c47..c8b0975 100644 --- a/services/mailu/helmrelease.yaml +++ b/services/mailu/helmrelease.yaml @@ -32,9 +32,16 @@ spec: enabled: true dkim: enabled: true + externalRelay: + host: "[email-smtp.us-east-2.amazonaws.com]:587" + existingSecret: mailu-ses-relay + usernameKey: relay-username + passwordKey: relay-password timezone: Etc/UTC subnet: 10.42.0.0/16 existingSecret: mailu-secret + tls: + outboundLevel: encrypt externalDatabase: enabled: true type: postgresql @@ -209,6 +216,10 @@ spec: logLevel: DEBUG nodeSelector: hardware: rpi4 + overrides: + smtp_use_tls: "yes" + smtp_tls_security_level: "encrypt" + smtp_sasl_security_options: "noanonymous" redis: enabled: true architecture: standalone diff --git a/services/mailu/kustomization.yaml b/services/mailu/kustomization.yaml index b3248a9..2df7440 100644 --- a/services/mailu/kustomization.yaml +++ b/services/mailu/kustomization.yaml @@ -11,6 +11,8 @@ resources: - serverstransport.yaml - ingressroute.yaml - mailu-sync-job.yaml + - mailu-sync-cronjob.yaml + - mailu-sync-listener.yaml configMapGenerator: - name: mailu-sync-script diff --git a/services/mailu/mailu-sync-cronjob.yaml b/services/mailu/mailu-sync-cronjob.yaml new file mode 100644 index 0000000..268680f --- /dev/null +++ b/services/mailu/mailu-sync-cronjob.yaml @@ -0,0 +1,77 @@ +# services/mailu/mailu-sync-cronjob.yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: mailu-sync-nightly + namespace: mailu-mailserver +spec: + schedule: "30 4 * * *" + concurrencyPolicy: Forbid + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: mailu-sync + image: python:3.11-alpine + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + pip install --no-cache-dir requests psycopg2-binary passlib >/tmp/pip.log \ + && python /app/sync.py + env: + - name: KEYCLOAK_BASE_URL + value: http://keycloak.sso.svc.cluster.local + - name: KEYCLOAK_REALM + value: atlas + - name: MAILU_DOMAIN + value: bstein.dev + - name: MAILU_DEFAULT_QUOTA + value: "20000000000" + - name: MAILU_DB_HOST + value: postgres-service.postgres.svc.cluster.local + - name: MAILU_DB_PORT + value: "5432" + - name: MAILU_DB_NAME + valueFrom: + secretKeyRef: + name: mailu-db-secret + key: database + - name: MAILU_DB_USER + valueFrom: + secretKeyRef: + name: mailu-db-secret + key: username + - name: MAILU_DB_PASSWORD + valueFrom: + secretKeyRef: + name: mailu-db-secret + key: password + - name: KEYCLOAK_CLIENT_ID + valueFrom: + secretKeyRef: + name: mailu-sync-credentials + key: client-id + - name: KEYCLOAK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: mailu-sync-credentials + key: client-secret + volumeMounts: + - name: sync-script + mountPath: /app/sync.py + subPath: sync.py + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + volumes: + - name: sync-script + configMap: + name: mailu-sync-script + defaultMode: 0444 diff --git a/services/mailu/mailu-sync-job.yaml b/services/mailu/mailu-sync-job.yaml index 14d9adb..7230c1d 100644 --- a/services/mailu/mailu-sync-job.yaml +++ b/services/mailu/mailu-sync-job.yaml @@ -25,7 +25,7 @@ spec: - name: MAILU_DOMAIN value: bstein.dev - name: MAILU_DEFAULT_QUOTA - value: "1000000000" + value: "20000000000" - name: MAILU_DB_HOST value: postgres-service.postgres.svc.cluster.local - name: MAILU_DB_PORT diff --git a/services/mailu/mailu-sync-listener.yaml b/services/mailu/mailu-sync-listener.yaml new file mode 100644 index 0000000..04e8070 --- /dev/null +++ b/services/mailu/mailu-sync-listener.yaml @@ -0,0 +1,154 @@ +# services/mailu/mailu-sync-listener.yaml +apiVersion: v1 +kind: Service +metadata: + name: mailu-sync-listener + namespace: mailu-mailserver +spec: + selector: + app: mailu-sync-listener + ports: + - name: http + port: 8080 + targetPort: 8080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mailu-sync-listener + namespace: mailu-mailserver + labels: + app: mailu-sync-listener +spec: + replicas: 1 + selector: + matchLabels: + app: mailu-sync-listener + template: + metadata: + labels: + app: mailu-sync-listener + spec: + restartPolicy: Always + containers: + - name: listener + image: python:3.11-alpine + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + pip install --no-cache-dir requests psycopg2-binary passlib >/tmp/pip.log \ + && python /app/listener.py + env: + - name: KEYCLOAK_BASE_URL + value: http://keycloak.sso.svc.cluster.local + - name: KEYCLOAK_REALM + value: atlas + - name: MAILU_DOMAIN + value: bstein.dev + - name: MAILU_DEFAULT_QUOTA + value: "20000000000" + - name: MAILU_DB_HOST + value: postgres-service.postgres.svc.cluster.local + - name: MAILU_DB_PORT + value: "5432" + - name: MAILU_DB_NAME + valueFrom: + secretKeyRef: + name: mailu-db-secret + key: database + - name: MAILU_DB_USER + valueFrom: + secretKeyRef: + name: mailu-db-secret + key: username + - name: MAILU_DB_PASSWORD + valueFrom: + secretKeyRef: + name: mailu-db-secret + key: password + - name: KEYCLOAK_CLIENT_ID + valueFrom: + secretKeyRef: + name: mailu-sync-credentials + key: client-id + - name: KEYCLOAK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: mailu-sync-credentials + key: client-secret + volumeMounts: + - name: sync-script + mountPath: /app/sync.py + subPath: sync.py + - name: listener-script + mountPath: /app/listener.py + subPath: listener.py + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + volumes: + - name: sync-script + configMap: + name: mailu-sync-script + defaultMode: 0444 + - name: listener-script + configMap: + name: mailu-sync-listener + defaultMode: 0444 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mailu-sync-listener + namespace: mailu-mailserver +data: + listener.py: | + import http.server + import json + import os + import subprocess + import threading + + from time import time + + # Simple debounce to avoid hammering on bursts + MIN_INTERVAL_SECONDS = 10 + last_run = 0.0 + lock = threading.Lock() + + def trigger_sync(): + global last_run + with lock: + now = time() + if now - last_run < MIN_INTERVAL_SECONDS: + return + last_run = now + # Fire and forget; output to stdout + subprocess.Popen(["python", "/app/sync.py"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) if length else b"" + try: + json.loads(body or b"{}") + except json.JSONDecodeError: + self.send_response(400) + self.end_headers() + return + trigger_sync() + self.send_response(202) + self.end_headers() + + def log_message(self, fmt, *args): + # Quiet logging + return + + if __name__ == "__main__": + server = http.server.ThreadingHTTPServer(("", 8080), Handler) + server.serve_forever()