16 Commits

Author SHA1 Message Date
Sten
375fbff4b6 fix(apps): drop duplicate keycloak-client secret (chart owns it)
The forteapp chart renders Secret/keycloak-client-forte-drop from
auth.registration values (verified: the live secret is tracked by
the forte-drop Application and carries the correct
drop.forteapps.net redirect). The overlay copy gives the secret two
owners — enterprise-apps and forte-drop self-heal against each
other in a sync loop (the Slack spam). Remove the overlay copy;
the chart is the single source.
2026-06-05 12:55:45 +02:00
275ec675da fix(apps): drop dangling namespace.yaml ref (enterprise-apps ComparisonError) (#19)
0a98674 deleted `namespace.yaml` but `apps/overlays/upc-dev/forte-drop/kustomization.yaml` still lists it → `kustomize build` fails → the **enterprise-apps** app-of-apps has a ComparisonError and the whole overlay stopped syncing. Visible symptom: `secret "forte-drop-secrets" not found` on all forte-drop pods (the SealedSecret no longer applies).

One-line fix: remove the dangling resource entry. The namespace itself is fine — the forte-drop Application has `CreateNamespace=true`.

@danijel.simeunovic — pairs with your cleanup; after this merges the secret re-applies and the pods only need the right image tag (helm-prod-values PR #4: `buildcache` → `v20260604-200105-1316f7a`, buildcache is the buildx cache manifest, not a runnable image).

Co-authored-by: Sten <sten@Sten-sin-MacBook-Pro.local>
Reviewed-on: #19
Reviewed-by: Danijel Simeunovic <danijel.simeunovic@fortedigital.com>
2026-06-05 08:44:56 +00:00
0a98674a27 not needed 2026-06-05 00:05:56 +02:00
b713ec853c feat(apps): forte-drop web + mcp argocd apps (prod) (#18)
## Summary

ArgoCD Applications + Keycloak clients + sealed secret for forte-drop **web + mcp** (PROD).

## What changed

- **forte-drop** + **forte-drop-mcp** ArgoCD Applications (two-source: forte-helm chart + helm-prod-values).
- **namespace.yaml** — explicit `forte-drop` Namespace at sync-wave -1, `Prune=false` (avoids first-sync race for namespaced resources; doesn't cascade-delete on base removal).
- **keycloak-client-forte-drop** + **keycloak-client-forte-drop-mcp** — labeled config Secrets; the registrar creates the OIDC clients in the `forte` realm within ~2 min.
- **forte-drop-secrets** SealedSecret — UpCloud S3 creds (existing drops bucket) + PG creds + PASSWORD_GATE_SECRET. Consumed by both deployments + the pg-backup CronJob.
- **forte-drop-web PDB** — minAvailable 1 (selector verified against the live forteapp chart's pod labels).
- Wired into `apps/overlays/upc-dev` (NOT base → stays out of upc-prod).

## Post-merge manual step (one-time)

`auth-oidc` SealedSecret for the web sidecar is still commented out — it needs the `client-secret` the Keycloak registrar writes to `forte-drop-oidc-credentials` after first sync:

```bash
CLIENT_SECRET=$(kubectl -n forte-drop get secret forte-drop-oidc-credentials -o jsonpath='{.data.client-secret}' | base64 -d)
kubectl create secret generic auth-oidc -n forte-drop \
  --from-literal=client-secret="$CLIENT_SECRET" \
  --from-literal=cookie-secret="$(openssl rand -hex 32)" \
  --dry-run=client -o yaml > private/auth-oidc.yaml
kubeseal --format=yaml --controller-name=sealed-secrets-controller --controller-namespace=kube-system \
  < private/auth-oidc.yaml > apps/base/forte-drop/auth-oidc-sealed.yaml
# uncomment in kustomization, commit, push
```

## Depends on

- launchpad PR #17 (postgres + namespace via CreateNamespace).
- helm-prod-values forte-drop PR (values).

## Review

- [x] codex: namespace first-sync race → fixed (explicit namespace, sync-wave -1).
- [x] Keycloak registrar unblocked (stale chibisafe/minio config secrets removed; registrar green).

🤖 Generated with Claude Code

Co-authored-by: Sten <sten@Sten-sin-MacBook-Pro.local>
Co-authored-by: Sten <sten@Mac.domain_not_set.invalid>
Co-authored-by: Danijel Simeunovic <danijel.simeunovic@fortedigital.com>
Reviewed-on: #18
Reviewed-by: Danijel Simeunovic <danijel.simeunovic@fortedigital.com>
2026-06-04 18:47:08 +00:00
dffb9c43f0 dbunk delete 2026-06-03 20:16:37 +02:00
33f0463c1f upc dev spec 2026-06-03 20:14:21 +02:00
a997a6b81e kc cleanup 2026-06-03 17:41:10 +02:00
071f57f1d3 kc cleanup 2026-06-03 17:39:02 +02:00
ecf871f0e4 kc fix 2026-06-03 17:36:29 +02:00
376d81a5ac keycloak client cleanup 2026-06-03 17:28:08 +02:00
428de7af78 tofu config and docs 2026-05-31 20:48:25 +02:00
24c59256c9 tofu+tools 2026-05-31 19:53:26 +02:00
e319295f62 bunker host 2026-05-29 22:06:08 +02:00
a7106bc8f4 new tls wildcard 2026-05-29 21:58:34 +02:00
6d874111da tenantID 2026-05-29 21:51:27 +02:00
a8cc103e4c dns01 2026-05-29 21:48:32 +02:00
31 changed files with 1383 additions and 27 deletions

View File

@@ -47,6 +47,14 @@ resource "upcloud_kubernetes_node_group" "workers" {
node_count = var.node_count node_count = var.node_count
plan = var.node_plan plan = var.node_plan
anti_affinity = var.node_count > 1 anti_affinity = var.node_count > 1
dynamic "cloud_native_plan" {
for_each = var.storage_size != null ? [1] : []
content {
storage_size = var.storage_size
}
}
labels = { labels = {
prefix = var.prefix prefix = var.prefix
cluster = var.cluster_name cluster = var.cluster_name

View File

@@ -38,6 +38,12 @@ variable "control_plane_ip_filter" {
default = ["0.0.0.0/0"] default = ["0.0.0.0/0"]
} }
variable "storage_size" {
description = "Storage size in GB for worker nodes (overrides plan default via cloud_native_plan block)"
type = number
default = null
}
variable "tags" { variable "tags" {
description = "Labels to apply to resources" description = "Labels to apply to resources"
type = map(string) type = map(string)

View File

@@ -1,10 +1,72 @@
# =============================================================================
# UpCloud Workload Cluster
# =============================================================================
# A lean UCS cluster for running application workloads. No managed data
# services — those live on the platform cluster. ArgoCD (on the platform
# cluster) deploys apps to this cluster via the app-of-apps pattern.
#
# Platform components deployed by deploy-workload.sh:
# nginx-ingress, cert-manager, external-dns, external-secrets, alloy
#
# Usage:
# tofu init && tofu plan && tofu apply
# ./sync-tofu-outputs.sh --env upcloud-workload
# ./deploy-workload.sh --env upcloud-workload
# =============================================================================
variable "prefix" {
description = "Prefix for resource names"
type = string
default = "clst-workload"
}
variable "zone" {
description = "UpCloud zone"
type = string
default = "no-svg1"
}
variable "node_plan" {
description = "UpCloud server plan for worker nodes"
type = string
default = "2xCPU-4GB"
}
variable "node_count" {
description = "Number of worker nodes"
type = number
default = 2
}
variable "network_cidr" {
description = "CIDR block for the private network"
type = string
default = "10.110.0.0/24"
}
variable "control_plane_ip_filter" {
description = "CIDRs allowed to access the K8s API"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "tags" {
description = "Labels to apply to resources"
type = map(string)
default = {
Environment = "workload"
ManagedBy = "tofu"
}
}
module "cluster" { module "cluster" {
source = "../modules/cluster" source = "../modules/cluster"
prefix = "clst" prefix = "clst-prod"
zone = "de-fra1" zone = "no-svg1"
node_plan = "4xCPU-8GB" node_plan = "CLOUDNATIVE-4xCPU-8GB"
node_count = 3 node_count = 4
storage_size = 30
network_cidr = "10.100.0.0/24" network_cidr = "10.100.0.0/24"
control_plane_ip_filter = ["0.0.0.0/0"] # TODO: restrict to known CIDRs control_plane_ip_filter = ["0.0.0.0/0"] # TODO: restrict to known CIDRs
@@ -14,3 +76,45 @@ module "cluster" {
ManagedBy = "tofu" ManagedBy = "tofu"
} }
} }
# ─── Networking ───────────────────────────────────────────────────────
resource "upcloud_router" "kubernetes" {
name = "${var.prefix}-workload-router"
}
resource "upcloud_gateway" "kubernetes" {
name = "${var.prefix}-workload-gateway"
zone = var.zone
features = ["nat"]
router {
id = upcloud_router.kubernetes.id
}
}
resource "upcloud_network" "kubernetes" {
name = "${var.prefix}-workload-network"
zone = var.zone
router = upcloud_router.kubernetes.id
ip_network {
address = var.network_cidr
dhcp = true
dhcp_default_route = true
family = "IPv4"
gateway = cidrhost(var.network_cidr, 1)
}
depends_on = [upcloud_gateway.kubernetes]
}
# ─── Kubernetes Cluster ───────────────────────────────────────────────
resource "upcloud_kubernetes_cluster" "main-prod" {
name = "${var.prefix}-workload"
zone = var.zone
network = upcloud_network.kubernetes.id
control_plane_ip_filter = var.control_plane_ip_filter
private_node_groups = true
}

View File

@@ -23,7 +23,7 @@ variable "prefix" {
variable "zone" { variable "zone" {
description = "UpCloud zone" description = "UpCloud zone"
type = string type = string
default = "fi-hel1" default = "no-svg1"
} }
variable "node_plan" { variable "node_plan" {

View File

@@ -0,0 +1,37 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forte-drop-mcp
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
notifications.argoproj.io/subscribe.on-sync-succeeded.slack: ""
notifications.argoproj.io/subscribe.on-sync-failed.slack: ""
notifications.argoproj.io/subscribe.on-degraded.slack: ""
labels:
app.kubernetes.io/name: forte-drop-mcp
app.kubernetes.io/part-of: apps
app.kubernetes.io/managed-by: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
sources:
- repoURL: ssh://git@git.forteapps.net:2222/Forte/forte-helm.git
path: forteapp
targetRevision: HEAD
helm:
valueFiles:
- $values/forte-drop-mcp/values.yaml
- repoURL: ssh://git@git.forteapps.net:2222/Forte/helm-prod-values.git
targetRevision: HEAD
ref: values
destination:
server: https://kubernetes.default.svc
namespace: forte-drop
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- forte-drop-mcp.yaml
# No keycloak-client config + no auth-oidc Secret for mcp mode. The chart's
# auth.type: mcp auto-registers the MCP client; the sidecar is an RFC 9728
# resource server that validates tokens (no client-secret of its own).
# forte-drop-secrets (shared with web) covers PG + S3 creds.

View File

@@ -0,0 +1,143 @@
# forte-drop Postgres — backup & restore runbook
## What gets backed up
A CronJob (`forte-drop-pg-backup`, namespace `forte-drop`) runs nightly at **02:00 UTC**:
1. `pg_dump` of the `drops` database → gzip.
2. Upload to **UpCloud Managed Object Storage**: `s3://drops/_pgbackups/forte-drop-<TS>.sql.gz`
(the `_pgbackups/` prefix is collision-proof: app slugs match `/^[a-z0-9][a-z0-9-]{0,62}$/`
and can never start with `_`).
3. Retention: dumps older than **30 days** are pruned.
S3 creds come from the `forte-drop-secrets` Secret (`S3_ENDPOINT` / `S3_KEY` / `S3_SECRET`).
Postgres creds from `forte-drop-pg-creds` (`pgusername` / `pgpassword`).
> **Object storage is the durable tier.** App data + DB backups both live in UpCloud
> Managed Object Storage (replicated by UpCloud). The in-cluster Postgres PVC is the
> live working copy; the nightly dump is the recovery point. The PVC carries
> `Prune=false,Delete=false` so ArgoCD never deletes it.
## Prerequisites
```bash
export KUBECONFIG=~/Downloads/dev-fd-no-svg1_kubeconfig.yaml
# Confirm the namespace + DB pod are up:
kubectl -n forte-drop get pods -l app.kubernetes.io/name=postgresql
```
## List available backups
```bash
# Run an ephemeral mc pod with the app's S3 creds:
kubectl -n forte-drop run mc-list --rm -it --restart=Never \
--image=quay.io/minio/mc:RELEASE.2024-11-21T17-21-54Z \
--overrides='{"spec":{"containers":[{"name":"mc","image":"quay.io/minio/mc:RELEASE.2024-11-21T17-21-54Z","command":["sh","-c","mc alias set obj \"$S3_ENDPOINT\" \"$S3_KEY\" \"$S3_SECRET\" >/dev/null && mc ls obj/drops/_pgbackups/"],"envFrom":[{"secretRef":{"name":"forte-drop-secrets"}}]}]}}'
```
## Manually trigger a backup (before risky changes)
```bash
kubectl -n forte-drop create job --from=cronjob/forte-drop-pg-backup pg-backup-manual-$(date +%s)
# Watch:
kubectl -n forte-drop get jobs -l app.kubernetes.io/component=backup
kubectl -n forte-drop logs -l app.kubernetes.io/component=backup --tail=40
```
## Restore a dump
> **Destructive.** This overwrites the live `drops` database. Take a fresh manual
> backup first (above) and confirm with whoever owns the data before proceeding.
### 1. Pick the dump to restore
List backups (above), choose `forte-drop-<TS>.sql.gz`.
### 2. Run a restore pod that pulls the dump and pipes it into Postgres
```bash
DUMP="forte-drop-20260530T020000Z.sql.gz" # <-- set to the chosen file
kubectl -n forte-drop run pg-restore --rm -it --restart=Never \
--image=postgres:16-alpine \
--overrides='{
"spec": {
"containers": [{
"name": "restore",
"image": "postgres:16-alpine",
"command": ["sh","-c","set -euo pipefail; \
apk add --no-cache curl >/dev/null; \
# download via mc is simpler — use a 2-step instead (see note). \
echo placeholder"],
"envFrom": [
{"secretRef":{"name":"forte-drop-pg-creds"}},
{"secretRef":{"name":"forte-drop-secrets"}}
]
}]
}
}'
```
**Simpler 2-pod approach (recommended — avoids cramming mc + psql in one image):**
```bash
DUMP="forte-drop-20260530T020000Z.sql.gz"
# (a) Download the dump from object storage to a local file:
kubectl -n forte-drop run mc-get --rm -it --restart=Never \
--image=quay.io/minio/mc:RELEASE.2024-11-21T17-21-54Z \
--overrides='{"spec":{"containers":[{"name":"mc","image":"quay.io/minio/mc:RELEASE.2024-11-21T17-21-54Z","command":["sh","-c","mc alias set obj \"$S3_ENDPOINT\" \"$S3_KEY\" \"$S3_SECRET\" >/dev/null && mc cat obj/drops/_pgbackups/'"$DUMP"'"],"envFrom":[{"secretRef":{"name":"forte-drop-secrets"}}]}]}}' \
> /tmp/$DUMP
# (b) Pipe it into the live Postgres via the service:
gunzip -c /tmp/$DUMP | kubectl -n forte-drop run pg-restore --rm -i --restart=Never \
--image=postgres:16-alpine \
--overrides='{"spec":{"containers":[{"name":"psql","image":"postgres:16-alpine","stdin":true,"command":["sh","-c","PGPASSWORD=\"$pgpassword\" psql -h forte-drop-postgresql.forte-drop.svc -U \"$pgusername\" -d drops"],"env":[{"name":"pgusername","valueFrom":{"secretKeyRef":{"name":"forte-drop-pg-creds","key":"pgusername"}}},{"name":"pgpassword","valueFrom":{"secretKeyRef":{"name":"forte-drop-pg-creds","key":"pgpassword"}}}]}]}}'
```
> The app's schema is created idempotently on boot (`CREATE TABLE IF NOT EXISTS` +
> `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` in `src/repo/pg.ts`), and `pg_dump`
> output includes the data. For a clean restore into a fresh DB this just works.
> To restore over an existing DB with conflicting rows, drop/recreate the `drops`
> database first (coordinate downtime — scale the web Deployment to 0 during the
> restore so the app isn't writing).
### 3. Verify
```bash
kubectl -n forte-drop run pg-check --rm -it --restart=Never \
--image=postgres:16-alpine \
--env="PGPASSWORD=$(kubectl -n forte-drop get secret forte-drop-pg-creds -o jsonpath='{.data.pgpassword}' | base64 -d)" \
--command -- psql -h forte-drop-postgresql.forte-drop.svc -U drops -d drops \
-c "SELECT count(*) AS drops FROM drops;" -c "SELECT count(*) AS view_hits FROM view_hits;"
```
### 4. Bring the app back
```bash
# If you scaled web to 0 for the restore:
kubectl -n forte-drop scale deploy/forte-drop --replicas=2
```
## Object data (uploaded drop files)
Drop files live in `s3://drops/<slug>/...` in the same managed bucket. They are
**not** part of the pg backup (the dump only holds metadata). Object storage is
UpCloud-managed/replicated, so no separate file backup is configured. If a
file-level backup is later required, mirror the bucket to a second bucket/region:
```bash
mc mirror --overwrite obj/drops/ backup-target/drops-mirror/
```
(Exclude `_pgbackups/` from the app-data mirror if you split them.)
## Disaster scenarios
| Scenario | Recovery |
|---|---|
| Postgres pod crash / reschedule | StatefulSet reattaches the PVC; ~12 min downtime; no data loss. |
| PVC lost / corrupted | Recreate StatefulSet, restore latest nightly dump (above). Data since last dump is lost. |
| Accidental `drops` table data loss | Restore latest dump; or `pg_restore` a single table from a dump. |
| Namespace deleted | PVC has `Prune=false,Delete=false`; recreate Applications, PVC re-binds, app recovers. Backups in object storage are independent. |
| Object storage bucket lost | UpCloud-managed (replicated). If the IAM key is rotated, update `forte-drop-secrets` (re-seal). |

View File

@@ -0,0 +1,40 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forte-drop-postgresql
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
labels:
app.kubernetes.io/name: forte-drop-postgresql
app.kubernetes.io/part-of: apps
app.kubernetes.io/managed-by: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: ssh://git@git.forteapps.net:2222/Forte/launchpad.git
targetRevision: HEAD
path: apps/overlays/upc-dev/forte-drop-postgresql/resources
destination:
server: https://kubernetes.default.svc
namespace: forte-drop
syncPolicy:
automated:
prune: true
selfHeal: true
allowEmpty: false
syncOptions:
- CreateNamespace=true
- Validate=true
- ServerSideApply=true
ignoreDifferences:
- group: apps
kind: StatefulSet
jsonPointers:
- /spec/volumeClaimTemplates

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- forte-drop-postgresql.yaml

View File

@@ -0,0 +1,14 @@
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: forte-drop-pg-creds
namespace: forte-drop
spec:
encryptedData:
pgpassword: AgBYokuQRCTmPGC8soB7n8W39nmSAZDTV97i77NmdMh5ndaZNtCtXxhMcpM2z8kaGv2tCWIh47dr38a5tGPZ0TsmeEQOF9Rbbtq1fyR7pJwT8S+N2Z2zu354wNWQlltEAVvTvthe2wTer/BpyRofeSZprxihmfNpuHUP8rLsnIXln5tOWDzJ8hnRWoYZFITaihC2qrJj/kFE0Rfdcmzt1tSq3jCB/rWijVaJF9XSh4rzQoqZDiUNjDPUjyERILw59JWU4zf9OKcqNHDnmpXBR4LSjLhd9waN6ElEzO4gGcVaHISKrTwewX1ONwPHDnw6lqkQObyBPx8aUsGzxLkUhNDtvIYDkB4BKWP5Qu4bNcSztIbrxi6l7Lr/DWC9qTbKm1/p83rc6r8VqRMURUyQg8/vBlCHOIUbZ8DM1OfNlMd8gvcSkaxEVIdCDUjguCvE3cGyG4cqv2unllZQ+9417WwLNJecT6x1EL3nQyAlK5c9vUIbcVyaFlbSUcGB7xmPgZrZ6/3RDyOH6Tmew1ssV9gLvdaehscUE0fjnnFnJpczkwdyxIOSNLIkjlWetCKEbhowJbzk05h3M2p6XQQOuNTnsYjAADMGD72GUgAQlY8KXmDELtv09KELcXbeYS4gABPpMrVmvZymq8lqQ13Py8o+cIqbrU5V86WxASTfQ5gMo/ymYabuhTBIapcnaKR1dFCCfu8deh5f2HJ6/1NjdWR+XvEshg+EF5OkTUInukX4vA==
pgusername: AgCs6vyQ8CIv5OneP/jMltIPGdZQbpq/BFmQM1mkBD61Ve+anzve5K0Gkg+zsNfbZf0pOPAXtu4C4aL1Lwv7gqpoe4Hp/UEb/X9uLfJ1b8ZitmM1XsPmmSiCskHjrc2BLkAvfrVIXkHc3LOY2uZ/E5stc6Ss2WFE8/uzzVXW0B8fdEK0criludQ8iwR1gypulEcDNomXgkK/1gmmCWosUcVv4jDMDhqBD+b9WYnBB6J73gUclWVMvYDFdNas2PuoRzu5Twc9TAZrTxN5lvLOXAonOo0YiUbUhEC83sfMWYDT5/9OxqcJhAxtgFe9j83MpCwLSwfeLZm7UsUapWDb60MxPJLGvoGD/ZOhkeYt/YCZYROa57TMslVIL5YU1KCiNWvtRjIqnvdiBxI7MRvPUfAoawS4ktT5PDhTTfrixFbaF95jul2kKBXV+OYB1UNsFhcCgZx9rzYRt4lNmBv4m4HeXIp3EYY8VlGLQ45BVVqjJ4QkISvb7ifQWH1aPMQllj+J3GwW0KJN0dEgsh1LT+C7W5I5mq461NOTF1eih/XRBeuPoLlgApxiGXvFCTx8lji2/JIdOaqcg29hdabSprxa0YMStChi2pbtHhRzAuFCp8mInGt8Q406vu67Y4/51yuwI40YeDVu0lf010TB+/v2Zy3OrNyjlqrD5JNynsLuRl3UhuAKC14Xhg/MiDLvTzfsYE8aog==
template:
metadata:
name: forte-drop-pg-creds
namespace: forte-drop

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- postgresql.yaml
- forte-drop-pg-creds-sealed.yaml
- pg-backup-cronjob.yaml

View File

@@ -0,0 +1,93 @@
# Nightly logical backup of the forte-drop Postgres → UpCloud Managed Object Storage.
# Dumps to s3://drops/_pgbackups/ (the `_` prefix is collision-proof: app slugs match
# /^[a-z0-9][a-z0-9-]{0,62}$/ and can never start with `_`). Retains 30 days.
#
# Pod shape: initContainer pg_dump → shared emptyDir → mc upload + retention prune.
# Both images pinned. S3 creds reuse forte-drop-secrets (the app's UpCloud user has
# s3:* on the drops bucket). PG creds from forte-drop-pg-creds.
apiVersion: batch/v1
kind: CronJob
metadata:
name: forte-drop-pg-backup
namespace: forte-drop
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
app.kubernetes.io/component: backup
spec:
schedule: "0 2 * * *" # 02:00 UTC daily
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
app.kubernetes.io/component: backup
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
volumes:
- name: work
emptyDir: {}
initContainers:
- name: dump
image: postgres:16-alpine
command:
- sh
- -c
- |
set -euo pipefail
TS=$(date -u +%Y%m%dT%H%M%SZ)
echo "dumping to /work/forte-drop-${TS}.sql.gz"
PGPASSWORD="$PGPASSWORD" pg_dump \
-h forte-drop-postgresql.forte-drop.svc \
-p 5432 -U "$PGUSER" -d drops \
--no-owner --no-privileges \
| gzip -9 > "/work/forte-drop-${TS}.sql.gz"
echo "dump complete: $(ls -lh /work/)"
env:
- name: PGUSER
valueFrom:
secretKeyRef: { name: forte-drop-pg-creds, key: pgusername }
- name: PGPASSWORD
valueFrom:
secretKeyRef: { name: forte-drop-pg-creds, key: pgpassword }
volumeMounts:
- name: work
mountPath: /work
containers:
- name: upload
image: quay.io/minio/mc:RELEASE.2024-11-21T17-21-54Z
command:
- sh
- -c
- |
set -euo pipefail
mc alias set obj "$S3_ENDPOINT" "$S3_KEY" "$S3_SECRET"
mc cp /work/*.sql.gz "obj/${S3_BUCKET}/_pgbackups/"
echo "uploaded. pruning backups older than 30d:"
mc rm --recursive --force --older-than 30d "obj/${S3_BUCKET}/_pgbackups/" || true
echo "backup retention pass complete"
env:
- name: S3_ENDPOINT
valueFrom:
secretKeyRef: { name: forte-drop-secrets, key: S3_ENDPOINT }
- name: S3_BUCKET
value: "drops"
- name: S3_KEY
valueFrom:
secretKeyRef: { name: forte-drop-secrets, key: S3_KEY }
- name: S3_SECRET
valueFrom:
secretKeyRef: { name: forte-drop-secrets, key: S3_SECRET }
volumeMounts:
- name: work
mountPath: /work

View File

@@ -0,0 +1,105 @@
apiVersion: v1
kind: Service
metadata:
name: forte-drop-postgresql
namespace: forte-drop
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
app.kubernetes.io/component: database
spec:
type: ClusterIP
ports:
- name: tcp-postgresql
port: 5432
targetPort: tcp-postgresql
selector:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: forte-drop-postgresql
namespace: forte-drop
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
app.kubernetes.io/component: database
spec:
serviceName: forte-drop-postgresql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
template:
metadata:
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: forte-drop
app.kubernetes.io/component: database
spec:
containers:
- name: postgresql
image: postgres:16-alpine
# NOTE: no securityContext. The official postgres image's entrypoint must
# start as root to chown a fresh /var/lib/postgresql/data, then drops to
# the postgres user (uid 70 in alpine) via gosu. Forcing runAsNonRoot here
# breaks initdb on a fresh PVC. Matches the vaultwarden-postgresql pattern.
ports:
- name: tcp-postgresql
containerPort: 5432
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: forte-drop-pg-creds
key: pgusername
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: forte-drop-pg-creds
key: pgpassword
- name: POSTGRES_DB
value: drops
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d drops
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d drops
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumeClaimTemplates:
- metadata:
name: data
annotations:
argocd.argoproj.io/sync-options: Prune=false,Delete=false
spec:
accessModes:
- ReadWriteOnce
storageClassName: upcloud-block-storage-maxiops
resources:
requests:
storage: 5Gi

View File

@@ -0,0 +1,24 @@
# Keep at least 1 web pod up during voluntary disruptions (node drain, upgrade).
# Pairs with replicaCount: 2 so a drain can evict one pod while the other serves.
#
# Selector verified against live forteapp-chart deployments (mcp10x, argocd-mcp):
# the chart's pod selector is {app.kubernetes.io/instance, app.kubernetes.io/name,
# component: app} where instance/name == the ArgoCD Application (Helm release) name.
# Using all three labels also disambiguates the web pods from the forte-drop-mcp
# deployment that shares the forte-drop namespace (its instance/name == forte-drop-mcp).
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: forte-drop-web
namespace: forte-drop
labels:
app.kubernetes.io/name: forte-drop
app.kubernetes.io/part-of: apps
app.kubernetes.io/managed-by: argocd
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/instance: forte-drop
app.kubernetes.io/name: forte-drop
component: app

View File

@@ -0,0 +1,24 @@
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: forte-drop-secrets
namespace: forte-drop
spec:
encryptedData:
BASE_DOMAIN: AgAFybdBryVb2AQuGQC8REXzW0YZlyycJp/KeXnROkW71UjDe4qMAWkWszrJWxZMvAPO/tXmibp7jEol6aB5GKG0k3tswWoprTFXLd9CMR2U9SWR3ZCol4npPXo7uOxhBcNSVt+cDXyejSiFTi6goY2oOtbKAJSF9Nv7Z5ePaqhhFni3ntcmM0S1Ad1l3QR7VvyazHFBXfO0b8Z9NgYsUNbGrXWDwoSAZIv3ly3wx90AXn+dXX5FNPtl9CtyAVhHsl3liwQdhEwS2krZZjj7NiQTCfNXp7BSB9ZETpo9KkoV4AZNy1zupd3HpeXHsyhHjq/JqXIAF3iFU0tZTWjhcwnehYdEU5oduwfLCWym5PYgpiQAGiazpkm1Ss3/PYpZYnR2nWv60b1Pa5i79ZiPNi4GL67AiWoJDw6QxV0Kbzi0AvUkZI1E2PeIJvv1w9NKdMRo49xK8LUx2qSTpWeqRP+1kzklHqclTuNVxiWtR2wUgdoLzvU7p5ETu7kPEmaoE8rYw4dKgQvHlMok2Ky2JsELGBkCiYjUN75T+yNlGs5dzbiwtWOja/r0dJ3ZGBQjcK4/BbTLiMYsrxmJTPPF/2zhCOlFY6cfcRMmc7Mwr68mK9m2rTOJQNjBMDoASiqVMmeSqfRSln7JNb1pAeq4xcz9YJMBJhPy2XNiBvRJK3pGIjVcNST0jSpic1X01NJTy7aFbcniZzYnsKJV61AQb+daGEsB1Ib3GnJ+Rv8+9NfvWg==
PASSWORD_GATE_SECRET: AgDIiAPb7bCpTeORoZXIn7Is/yKT7Qm+qSexgmXuXpe+WaLCJDWM5uOlvxp5tbtK2KD3SNtUDrOJQYfWYBOAPrBMBnSpDr8Ie7/wlJBxXCfROk1TldOU3s5O+5OnFfTDS9rAdcWdZdJ+Bt0aktnpuHJpjdurFca5o8ne5SRwYtGk6mNinCYRcnwMApZ9Y7IxvC9xzOi1QIhoOepMrotRkVJtbrXiclN5cI+P/nU8LaGwM4AEJtO3L8zY5QK30SZ/Tsz20Qo8HHGdIBLhHbhTALrFe8+n+Egda66Vr1DkKgDERW59PeWBd6xHCRGNuAk2ZzUmuiN5DJmWgacAGy9QblMMjhOIGKgiqsd4jx/8wC8FoyxBSstGFajahL2C6oFzCpbp5NiS185znN0ohJXqQzkp5RbcBliSYUZgHC8D1jDGxzcTksD2Hgh2NIAlJOStsfB3fUb6hWIFzxim0lyP9kFM5y92Sf5B1/h6PeHKOi1CMC5RFAEKhoyM4W1W+NxASJeT/NptTehcrjBPxLVKBPh/qgMcuNYNk34EB0asELalYBJ5cxzt31LLiLE/uDxCydXiSk7ACho7LxNffcUUakEwwsJDmjFiCscu83TfZq2vw5/2bgOzUng4XGDBgYwwr/KEHueGX4Keg255R3KqxyiMSKDi4CUEtYShjSsSYwU62rdogOQu20N3HGVn+/CksqKky7GoD958d4PwT5MGJwQhp8EjemBomDxTKNsO+C4moMNZpAEBtPlP65Lc1cfcaLlaFrwP4VtYHLXXBIvMzCC+
PGDATABASE: AgCCkY7AUaoy2V+fRXrEeLODlHOTXWXmWCW6MBWEY1J32QrN0bWUmwDdTxv5KHchbhH8lra9bABNOGfRcz59GIg8fH3fwTWOE8luh9cD50QkphjZPe9eGXc6YzheG0CG5hDnUoiJjnP9/l5cZc6sRxAo7for/bbLa0zja3VMzI+NMhVVRZT01G5R/Aoyf+B/TGm4mYbMyWAIgEEfjl50yIkc4WscfQrRkbxAtBF2qFuS3OL95TYSRULBAt0f3Z2WpCdS2b/pUyHJuuoi8aTo1a9QLFMrUdxVi6ydIKaMcx8xR8DlXQLOOdtIQu4TrgzGX2MgHwrbWuf/SFO5IWB/6/JI3yubo+i88M9LwPKsGeslcquoBG3Ibqlnmw8pcPrXUwBq2BowjAqpGsxdiR5XIi2j8QnpA8dTmFARX9CWKHoXFH5+uxx7SSTPG+izlGtVspsu5xx3F8MZ6eStInCyBimTVrn74+IvMPEBrj7wThO6Bl9EA3POkTRf6AmjuPItTgZK8lfXY/t0qDN5zApeB051+jSGZ0/o4PaY414vCm/+OteuCAI4ooCpDOwG3QD52VtpmoAvGjtuWExrvCHMW+hTIxsNbYJ+2SE1oQy8n8J5kKK+AU/SnYf/VEpbawHrpU48g6sDUHQzzPhoLN41j67qopmfRZAo1r5Tb4n7MnEOF3Yi7aT3lE6JvB3gZUSRqnDYSWObTg==
PGHOST: AgCWDPCQ4P+vPKQXmzNspODUJYVidwQDzFRxY8JDj5JYW1u+2xK8LqE2uWWYCW3YjF+H6yJoH6gu35KqOtjm0E23zMlknwZwkK+L93J/nrnNZHCznoub9jEzaAsTRhytvuIUBPQnFulP/t9Q04RjmR9c14puS7VS/tM57/cLq5zq/BiSYL3Wx2lblE7Xn6k44+8robPP3YkmlOuqziM1li/qevB/jxRV9YFjIpCz6D7WG6Kkx9CA6fzgalMJ5ErUVTJOuLDDty3qKd+9mg4WCLahjyzhPq/rIPUFE77EBDCulnnlMyvOSPSP7pMzkaNR1Ce3cOxOYAZ5mO2u5Y5pXt34V8iPe87N85KHQNZTu1Mu5ELMLdhEYOlO5ZX2x/RsdEuriWz1NW8MC57pEUGE/XrFhDe6x80eHi++qDRat2d04rTBOtlceZMmPVxs8tTa3VaD4PepekSogddLVooscLL7lYIpyR0Q6qOA1NdF2uOpx7hf6QLY1HeD4V8RSVVxV9uK5lUTrnBjtU/aUwnGs0xKxVNdBEKwG5Kh7qdsgyBiEa9V4Y2ElteGHave4J7xzimdASrbgMnlgIfcMuo94eX/0M0BAmpD4bEByMCWbcFgAfNnIOpXLZvoEfZ3DxVysmCzD+0nq+CEatdOjKp11EKbtf3H4PgnKqWDe2p8FNPXhev60CMhFvXS/QYg9fe2YJt/wqcN5MstCej4IuiOJkuFJ0UhZ/F5760xhuPYCUWP1suIVZM=
PGPASSWORD: AgBH32G+EUtc3jzGCA9bf27TCbzgK9xz+r4dqd0QQJL9xHbqgOARGVVaQ88AOkWV5VgYjqc/GFp51jLzVOHxgLdkqO/oCBuX9ajQEoGfq24AxFnaB7fh+Vlc3/N9yhT8lWoxHmHjyMVeX75g/9KvhNaRKBgiWQNHlt1C2FNh1h3U/aMfWVJIENmKKH2A5sxWe5haB7nynZc9r1QXBQKa7XVpuxAFXDHz3j3cFyR5Qflp+ac2APEM1/xbiaZDgkBtBd6dsDoCP56Dr1m91kaRGgbeX6WmRJ/Y89WAp4yt3QVfa8uGL1+DrBBMcfB1nAQKA45eZjPE6zTOEHxgTETCcmXJQiOzttDmBHRkIClOLLipgGDJwMqtgQoEMoJKjXMC0rsRy0NVRmibZa310R3PQjuHrQXxRD9ZAXkYg3opwLKeKi07b/7mvLHr7hU81fkBGnqNm/6heOSAqDZfRdregbBbcI/go72aypn2vQ5R+ozCdfwcp1tGla8FGpkI+zAdBKihp5Yo21VZ83FlIMq2JHF2+tv58C+LFeyqL1nr6BUmGKUQ+lEOnRzGYo1sbO5wBChc6yP3ZbzZYfxfvXAdfDY7vZUsareOC4uyR1wDnIiJgQ4kqmAKf7HulJJatKNgsvbmukj7c6lHLsfFRg5pwLO6iese9TZgtima2wkdcRpHSdt4ycnyHbwrEZ4kepfFlN1pGUl573/3l2cOdzO+WLCqV96P5myL6OOmCTxOaLdSyA==
PGPORT: AgCXyGjhlTcq3ffD+ZZZxe5gvqRLQl2MYGl7SfJDjw4LMdr3iojAeQNdYZIeZN4Bec9thhz1DI/Bc5Iiw5TiB3MAAvl/XtQM/T23JrSlUsfqECuHKQGw5kkgeVCds4j4d/IHFt9yBv4W4+ogcL5TZIGGgMiKa/99T77xgE4OqjeqqC+bpFuLObiKt5sYcqcHl2DDshTy4xeukfdA2yI3N9Tq7zfbLpZUKHWWe1gE+FbA0s+QA1ZCYv9uEtu/E5BWOYxE0u4GKhMDfMpguks0CNnIoRpovY3vmOxzFtqa7RpPoQrQtEN2Xtizu/G8K8p+mZLQ0cf3KX0rwp8tAsVKWrp8FYOy6+2OLYlhmDl1hK+M15czaL60DVBqiRIlQFdj9K+s5KQ0qfsG9m4v2WPSeCLEKwcsyLyq9vzXbDKwcMN2DOMOan8VDo04UZzkfdZa3lztfw7MWIdpvB0+cpgK3mdbbPCqSIQ8UTjeSVBgEz4EPUds81W8gqAgmeVKpUQplgFuLNwCTnCkuMSNiA93D3dgGccXzXiDrIBIcLzxHAWr0XEjISyD/pmNEowbDvXB5yRlNi1ZB5AHsRPurUYs4XtZN5ar+sxyex78tG18OkcBbABatBp3ol28Rs1LEN+HJupzNl4XDUx35/j3t/FPcw9PkcF+hzaQL7aWj4EDEWfjlzz5FEBlU5N6sd078tB79TpasuIk
PGUSER: AgDH9oSzu61NVOUlK7Evr278VuOx7ZxZgdsk+kdJmwpBGX7r5gmPrdomh/mqLYTuLokkanFiTfchRWHc76FvjbA/KxqwCq1qsZbW+dXrRtx/z1wQApKxNUJ7JolwMwP7tHE6QlGzO2mWj6RUROnhKpNybJXVvC3E5sSyz2QWC9hjamQP997RGA9yiiT/OShC7I6drFYR5cRDtpjW7Sy46qhMwlCRppiKh3wOV7qIAa0aPQE3Rfcg2WpK2ugRL1N+SiVnM+wPQwYVLiDaVF40vP40Kari99hIgmhcbjPeGG3kGX5VLww9KGm7iryrW3Yx45L/CCh1arUUpjkK2FGLVKtb3+YmDadnOA/I8Rr5kebhoMc93E3U3+mDfQA3cO/23xgpOJEGRCQwBlN9mazqkdq4zQkb4+nuxsdyQcxYtncgxhfCcZ0mXnbX2aW2kYcxKqa/jNjBcEpGMvos7dq6QzNq2nHrITo15S74M0292CAje2NFvKURA/KZnT26dDw3e5xa74E1nI/tBJEHWrUwRXpPu7naCZ2sZMxQV6ixQMuDakx3YamXZmMwgFO2FZ6ZL9BDDsbV4+JAsNwEaHGIIaTbE28R/xPIcUqcxrQV4ZWmHnJXFGyJ0XXxJ57GGjs7QwvvzAm+9WGYtlSC6H/8rX8uZIQLr3llVbJMuLpIv45i3p0Nkx8jyxGSG3rNQ4l3K0rjly2qZg==
S3_BUCKET: AgBcx2UvWafkVQ4fc+Xuc+gCLm5O5DceYSYBslL1bd8p4hKI/2hJF9z9UoLyAedyyrx8pHizcugkcIccrV1iyfQod2lDrivVJCy3qHz0mrQ6ZCOAk9ApNJTYUn+x/AYZL9EKVj/vfEzSwOTqlri6H53aUrZr8lv01TqLcrZDeG+jv64psydYKmSESkPl0iQ9DOx2sGkowNYbiTjux+ED9yXDMDdzojhMepbuUHhjNr7V7Z2NpX8+pNVz2o8o/oIA0zAJs3+C02018N3cyJ9P9BP2/qR6N31aykyI4P6GU5WlL0CDGKaheYy9ukM5x8SoU87yZBHLUxN0MhrMVKoFswU6CwTG/A8Gjkp84ce2cvlnuXP6JEMlqgSb+O+SeCH8+kwL9A87xmb42OOXSGokS188/2/13b6S733WL81B0RX1X1pOfWybpJzDhgiOC3Pjn18ItVHjKw3FlY6kwk1+P1vbZoRl343avRdyQyJam28A9UvZeTG+ac7drRTDndKYKGP8IWg/A32MEFSiKHsvnRQoYE7KORyzJxp610L1+w46KOUF5VF2afch02Jt4uLcmCOyUb6LdcWln3Jiz+wydJI1HK1RPio5oUT5d5se3K3DCa+GffbPMorHe/SdzqaBH2+uBcQn+wZO7JC1ei7hz4U1xdV41gkG1uq7Xz49ymW6P7G6qUr65MtvD73iMRlnJt/mdkw5UQ==
S3_ENDPOINT: AgDA5n96LeW1spJfmm7f9lcus5Ndv/91HAlhOfv0YwP+9R8rQhKF1sEL9Oi7Fpd4VJCQi9vDbeWYjGOMtsMqsLJivcZK6367rdSqL/YpKax9SkDgU+UG5Bpr5SDv4p5C3/J4+GPkZV+BduJQlNVPycTrdM80VBK8aALCNs7gGxy68v6mMgI90MpTp84ZeWgtJ7ZpTySivpwgycwcezldJTYDDjOvVfbK79trsRzDtjbAZzbDn0M8At4LYAfY7JTqcdCRHSGnzyg+81CX2jjyMOH0K55SsIqPEBdXqvh+VmC+jPyFMzfAN5hA8+otWTXfI7VucxAMZtRUiLU3I4/kIO60OkVjEf/SWrJv3nKb85aVfvRZ9k/A7kJ/ysKTXz2/iSAHU1DHTJlMW7SfhXciotx1p3BWOREF0QAQvrNCURFP+eZogBCIuiP38fD0gHkhG2BZkAY/OVZTr17a3F02kwemAuJ85hgUf0iwFV+/LD6X2OiqM85lnac/3B5Qp7UQf+Enn5RTqd90Pk/7QtAf/zTDmfxiWTkwpKXeQ62xcnHL9dQnUmDDs16eJZd04AJGgT8yrC//2lVOht7u6KuyG/+JXRxLBXAkGkr68lHBBuGmeVZ/t7ChL2Asyfu/uUTl9E1BOUfNvZXxwyLgqueL0Uw/MGLgswBsP6pYNGraMF+K5v7dse8cUxpyqk3s1C1zqoAhkehsCBtTjI+hF90G9Nh6mVSocoH12znt7fgUnwNbpg==
S3_KEY: AgAiciTM2ZNVlU1M1CNNXLkhCEYYbO7q5+Mp/DoC4OHBgIDKVfHurH39Dniuwxe6DcvE3vG2glRyTxQEg/ASLcwa7HBwBAwXe3wl1tRGM5Bp40/Vq935BXpkhcdp2fSoup8lPEbKS8q+L5LOqUlx7jmnkHXbI1tasz63KE8O9RUFDdQ8Gxy3nn/u4xkvibYxwmo60ApLKYgOu/ODPEETrWBcITHAVFUxbA8Kr9X9mPm3VpfrnFcUlxsCFr/zZwE/Y01eWdi8GGafb+apDPKMd7mAsLHFcPIQlpkHVT1M21qwwntZg9yV0RBACNu5BVPUgbmtUOQeWYMXn3FE+NJ7ajfdKAUCcEUV/f4s00b0S7jJTJwOUixDquMKSfu00AwDRCs8UcouikZe110uWnfEF3tVE0xQGF/3ItLni9VugBz7wQv7ACvmwnHmX5ZcjE0hxYcIS7ABWgHOZxgWoRWPao8eNAATipafcVIG1szl5ZMNTmAqHFyp2dlNU3zaiW6fz4q4CU7SrlhsrtqYM788qHvpJvDpFdF/i6oitH9CgpwmdCpH6YbBXxatnkWq9bqjEFcSZGDfDyT+iZaoPwhiOfaEoCyKlZ9RLLaK3E8zFcCDRXHnvnkqPtP/+VG30xz9pIat2EVB1N4b/kVIrr+fIM28mwk0vkC/tU8T55GF5BZr7VaYedHM9DVcQ4OJl7Ctrc9Ki8PXrne8gywyomA0F+YY9lxdDw==
S3_REGION: AgAAHTbNQ3gGnvg67ck2N5zSKKhMwR1j6pi/tZzYMEPK8jSnDTBkLYAt6ZVRtdsO+dG9kjnMsc/xTUMxJspbvQkgLSd9mG5FzJ37rBn+azCCSTDYBKq2ddGK1Yf/9w7MxOgN8aCyD4QCFOzR0EI49GbVYQynxDD5BwYuf7y4t2xCYt5wRGsjyNAmH3202Z90XKUkts8Na1hyD+xrrtrAtNfugyZqKo2WUSsHu82TD+cu5xZI51oQ9w9Mh9LaH3nfn/X5S+t17TjYvI7/c6hOwCVv10OdoaZa+SzqOvy7XxnqAShAYkPqJKfWhjecE1b9c/Cun32X4MRI0GBWyA02z4nR+WBbaVmasicx6hchVn8/wZeKMIl68F5LE7184MKKPNNwqsYslwFhuWq8dEw3TaVvnWgx3+cSMiX15SBwcLtE2UzJp+jjN/dpQ2MM0+6uV1GK4mNso5JAwGpUUUi2i+V1Ng7uXipI+5J9w6K0IMg1puGqFyahby42saSuH6vuPnjSx+2dXQTlgbl2SvCBoCgyOOJs4q5IafEvupAmRCNzx2HHv8/z6CFbSQZITQ3plmyNGLFXjynVw/6Q1PD4z3Pows4uVcYOEPbC0UoaXVgwgdBWa2N44BUhpdbqJUCyKuapLigpjKujG43jmdLzuk6gaPeH7SJTZKr624vs9hrGhzQYKdl6FZOQhmCFRDKXVCUiH2Z2pfwd3oo=
S3_SECRET: AgCcVQ7YtBAGpKBm+rE/hQBHrFlX5O0JO94xkZeoAppA9Tf8YR/PguZRGBWgLdNEJRI8C08lRRCUX3PY68jTySyjamb32iQkslOXGjAfnULeNGoGg05nLY2ZDYCEom6ieL8cc2xfbrV3yHoPQ7yVz9vcLjh1vATxyfdkqMapl8FpvQf0k0Zecmw3rLWE9y6vAn6Gb+/CWTnuhcW/8uDykmjIBTDQQddWshaZi+HosHyDbNxlnGj4U8mie68wytpS+Unp1gIWWE0hvelqO/3OUEEBB1OYMLV2DW8v86HXAE1Ix9jiCpSbyB+UzjOlrE/p4fJpeG4FtUC+/5ibRSxxgQRQYklKFJmdRDYWUnOngjgcT/Ewe41mTrpCUvb+jtir68pYLmVrLoha7S60w1YQHNkDAN2GftOyBjkkt6MtUDNzvNkfnKqKGUWyDSC27yfJdE/9k/4lDxQs0Sp20kIuz66/culBpg/s/oPSNs4SolCqG3GVLlKL775uqwLLuDN3txlPLb+Ex5vZAUapke+rn2zXzJVc1qlPfI/96vSEy6cx58LXdBadmBXn6c4Uy2MDa66EwsxOMXxzGLTd7AGkd5oeQVYfVPdTfGV5zx1AdzQhP3u/DD5FhKeWGDOr21iYB2jNm/P/hw0nFP2pf83W4/jLzPvuth1LF/WLF8cjclnGbcep2Kxrh/Xq0LmufofuVJyEI9/fl6onl5KIa6ZnVBJ8TsQesXJtNEKt9cPHiCvBKfLj5C+a4FlY
template:
metadata:
name: forte-drop-secrets
namespace: forte-drop

View File

@@ -0,0 +1,37 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forte-drop
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
notifications.argoproj.io/subscribe.on-sync-succeeded.slack: ""
notifications.argoproj.io/subscribe.on-sync-failed.slack: ""
notifications.argoproj.io/subscribe.on-degraded.slack: ""
labels:
app.kubernetes.io/name: forte-drop
app.kubernetes.io/part-of: apps
app.kubernetes.io/managed-by: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
sources:
- repoURL: ssh://git@git.forteapps.net:2222/Forte/forte-helm.git
path: forteapp
targetRevision: HEAD
helm:
valueFiles:
- $values/forte-drop/values.yaml
- repoURL: ssh://git@git.forteapps.net:2222/Forte/helm-prod-values.git
targetRevision: HEAD
ref: values
destination:
server: https://kubernetes.default.svc
namespace: forte-drop
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- forte-drop.yaml
- forte-drop-pdb.yaml
- forte-drop-secrets-sealed.yaml

View File

@@ -2,7 +2,12 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
resources: resources:
- ../../base - ../../base
- dbunk-demo - forte-drop-postgresql
- forte-drop
- forte-drop-mcp
# No patches needed — base already has "upc-dev" paths # No patches needed — base apps already default to "upc-dev" value paths
# upc-dev is the default/base cluster # (upc-dev is the default/base cluster).
# forte-drop (postgres + web + mcp) and dbunk-demo are upc-dev-only apps — their
# values hardcode upc-dev hosts (drop.forteapps.net etc.) and must not sync to
# upc-prod, so they live here in the overlay rather than in apps/base/.

View File

@@ -0,0 +1,15 @@
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
creationTimestamp: null
name: azuredns-config
namespace: cert-manager
spec:
encryptedData:
client-secret: AgBPCix6yTt8gXV2pMfRx6weWtLWUeMa/cBwuaDBZkqZE4CTSDxjQWWK8ul5OtndlEX7+gXV2HpuAmHRhGC8P1z39yWRxIDbKf+AH4JOGSIfu57tnrGvzAwjanKtqeFCEM67Y42Kz1rahCEtdwJj2YIL9oVdCtRPkpAJrAJclqmrCvJZtqQYcDeEn5ONK5Gchxc6x2/J0U0LTjLz/MP74CEKw3CiZ+9VKA4ppvPqjqoE4yyAXSglSnhYTqtMZpFg5sr+at6rAB1ufmtiS70Vfks46OL0/ZJjdS6h5wjIZhl/1DgIFXqc1yoAjuEoMRzMeW9ji1PnjRqas4lU19tuEOf9/Eq65BYOqSIqJ3saG4I/+z013coCGCalo4ghuufmu5pcsi6ywcszz+g20N/PZcVcLbzZbMnKcXsaE007MDLxGY1QD89aribAvsypDUNEuNh7w2n/OAyQdw+nRDGIi8Oz3FoJUmC0HBxin2hstuBnrAXtxdqRX1NU25HfR2s3qt/yQ33TFx4xHA3NAll8Riyg2rvgqAKo9x9rmlcnzML011vlNu5oLKAuCqJlH8W/Nnnh6LNcZJy8Cj+fqXPeWHS4Qk7nEAbYJN9/sNAUg/VzsP0yYejPfjwzoDDaPLvTHROwv9nZ+Lr/U5epHr231jc5+i3x8dLuBtg+aa5PHoS/Ml1a4811w3Bxj3u36q8UPJGnszQXLKCpucynVVstAj4ufhXhhNXJdK/U31Zrc6j3Skw4zgF8Ddv0
template:
metadata:
creationTimestamp: null
name: azuredns-config
namespace: cert-manager

View File

@@ -12,10 +12,25 @@ spec:
privateKeySecretRef: privateKeySecretRef:
name: letsencrypt-staging-key name: letsencrypt-staging-key
solvers: solvers:
- dns01:
azureDNS:
subscriptionID: 1b52bc03-6815-4574-b579-60745dce544d
resourceGroupName: forteapps-domain
hostedZoneName: forteapps.net
environment: AzurePublicCloud
tenantID: 063afd9e-5fcb-48d2-a769-ca31b0f5b443
clientID: 3b7a4ebf-894c-4f5d-9b1e-2b61312f8e74
clientSecretSecretRef:
name: azuredns-config
key: client-secret
selector:
dnsNames:
- '*.forteapps.net'
- 'forteapps.net'
# HTTP-01 fallback for non-wildcard certificates
- http01: - http01:
ingress: ingress:
class: traefik class: traefik
--- ---
# Production ClusterIssuer for browser-trusted certificates # Production ClusterIssuer for browser-trusted certificates
apiVersion: cert-manager.io/v1 apiVersion: cert-manager.io/v1
@@ -30,6 +45,147 @@ spec:
privateKeySecretRef: privateKeySecretRef:
name: letsencrypt-prod-key name: letsencrypt-prod-key
solvers: solvers:
# DNS-01 solver for wildcard certificates (*.forteapps.net)
- dns01:
azureDNS:
subscriptionID: 1b52bc03-6815-4574-b579-60745dce544d
resourceGroupName: forteapps-domain
hostedZoneName: forteapps.net
environment: AzurePublicCloud
tenantID: 063afd9e-5fcb-48d2-a769-ca31b0f5b443
clientID: 3b7a4ebf-894c-4f5d-9b1e-2b61312f8e74
clientSecretSecretRef:
name: azuredns-config
key: client-secret
selector:
dnsNames:
- '*.forteapps.net'
- 'forteapps.net'
# HTTP-01 fallback for non-wildcard certificates
- http01: - http01:
ingress: ingress:
class: traefik class: traefik
# =============================================================================
# CONFIGURATION INSTRUCTIONS FOR AZURE DNS WITH WILDCARD CERTIFICATES
# =============================================================================
#
# PREREQUISITES IN AZURE DNS PORTAL:
# ----------------------------------
# 1. Ensure you have an Azure DNS Zone for "forteapps.net" created in your
# Azure subscription. If not, create it in Azure Portal:
# - Search for "DNS zones" → Create → Zone name: forteapps.net
# - Note the Resource Group where you create it (e.g., "dns-zones-rg")
#
# 2. Configure NS records at your domain registrar to point to Azure DNS:
# - In Azure Portal → DNS zones → forteapps.net
# - Note the 4 NS records shown (e.g., ns1-04.azure-dns.com, etc.)
# - Go to your domain registrar and update the NS records to these values
#
# AUTHENTICATION (Service Principal - Required for UpCloud/non-Azure clusters):
# ----------------------------------------------------------------------------
# Since your cluster runs on UpCloud (not AKS), you must use Service Principal
# authentication. Managed Identity only works with Azure-hosted resources.
#
# =============================================================================
# SETUP: Service Principal for UpCloud Clusters
# =============================================================================
#
# 1. Create Azure AD App Registration:
# az ad sp create-for-rbac --name cert-manager-dns --sdk-auth
# # Save the JSON output - you'll need appId (clientID) and password (clientSecret)
#
# 2. Assign DNS Zone Contributor role:
# az role assignment create \
# --role "DNS Zone Contributor" \
# --assignee <SERVICE_PRINCIPAL_CLIENT_ID> \
# --scope /subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<DNS_RESOURCE_GROUP>/providers/Microsoft.Network/dnszones/forteapps.net
#
# 3. Create Kubernetes secret for the service principal:
# kubectl create secret generic azuredns-config \
# --namespace cert-manager \
# --from-literal=client-secret=YOUR_CLIENT_SECRET
#
# 4. Update the ClusterIssuer above with:
# - subscriptionID: Your Azure subscription ID
# - resourceGroupName: The resource group containing your DNS zone
# - clientID: The Service Principal appId/clientID
# - clientSecretSecretRef: References the secret created in step 3
#
# =============================================================================
# ALTERNATIVE DNS PROVIDERS (for reference):
# =============================================================================
# -----------------------------------------------------------------------------
# Cloudflare (original configuration)
# -----------------------------------------------------------------------------
# Create secret with: kubectl create secret generic cloudflare-api-token-secret \
# --from-literal=api-token=YOUR_CLOUDFLARE_API_TOKEN -n cert-manager
#
# dns01:
# cloudflare:
# email: your-cloudflare-email@example.com
# apiTokenSecretRef:
# name: cloudflare-api-token-secret
# key: api-token
# -----------------------------------------------------------------------------
# AWS Route53
# -----------------------------------------------------------------------------
# Create secret with: kubectl create secret generic route53-credentials \
# --from-literal=secret-access-key=YOUR_SECRET_KEY -n cert-manager
#
# dns01:
# route53:
# region: us-east-1
# hostedZoneID: ZXXXXXXXXXXXXX
# accessKeyID: YOUR_ACCESS_KEY_ID
# secretAccessKeySecretRef:
# name: route53-credentials
# key: secret-access-key
# -----------------------------------------------------------------------------
# Google Cloud DNS
# -----------------------------------------------------------------------------
# Create secret with service account JSON key:
# kubectl create secret generic clouddns-service-account \
# --from-file=service-account.json=path/to/key.json -n cert-manager
#
# dns01:
# cloudDNS:
# project: YOUR_GCP_PROJECT_ID
# hostedZoneName: example-com
# serviceAccountSecretRef:
# name: clouddns-service-account
# key: service-account.json
# -----------------------------------------------------------------------------
# GoDaddy
# -----------------------------------------------------------------------------
# Requires external webhook: https://github.com/snowdrop/godaddy-webhook
#
# dns01:
# webhook:
# groupName: acme.yourcompany.com
# solverName: godaddy
# config:
# apiKeySecretRef:
# name: godaddy-api-credentials
# key: api-key
# apiSecretSecretRef:
# name: godaddy-api-credentials
# key: api-secret
# -----------------------------------------------------------------------------
# Manual/Dynamic DNS (for homelab)
# -----------------------------------------------------------------------------
# Requires RFC2136 provider or external webhook
#
# dns01:
# rfc2136:
# nameserver: your-dns-server.example.com
# tsigKeyName: cert-manager-key
# tsigAlgorithm: HMACSHA256
# tsigSecretSecretRef:
# name: tsig-secret
# key: secret

View File

@@ -10,7 +10,7 @@ metadata:
policies.kyverno.io/severity: medium policies.kyverno.io/severity: medium
policies.kyverno.io/subject: Pod policies.kyverno.io/subject: Pod
policies.kyverno.io/description: >- policies.kyverno.io/description: >-
Injects an auth sidecar container into Pods annotated with policies.forteapps.io/auth: "true". Supports three auth modes controlled by the policies.forteapps.io/auth-type annotation: "token" (default), "oidc", and "mcp". In token mode the sidecar reads credentials from a mounted Secret volume. In OIDC mode the sidecar uses OpenID Connect with authority and client-id provided via required annotations (policies.forteapps.io/auth-oidc-authority and policies.forteapps.io/auth-oidc-client-id) and secrets from an auth-oidc Secret. In MCP mode the sidecar implements OAuth 2.0 for MCP servers per RFC 9728 (Protected Resource Metadata) and RFC 7591 (Dynamic Client Registration), configured via policies.forteapps.io/auth-mcp-resource and policies.forteapps.io/auth-mcp-authority annotations. The sidecar port defaults to 9001 and can be overridden via the policies.forteapps.io/auth-port annotation. A NetworkPolicy is generated to restrict ingress to the sidecar port only. Injects an auth sidecar container into Pods annotated with policies.forteapps.io/auth: "true". Supports three auth modes controlled by the policies.forteapps.io/auth-type annotation: "token" (default), "oidc", and "mcp". In token mode the sidecar reads credentials from a mounted Secret volume. In OIDC mode the sidecar uses OpenID Connect with authority and client-id provided via required annotations (policies.forteapps.io/auth-oidc-authority and policies.forteapps.io/auth-oidc-client-id) and secrets from an auth-oidc Secret. In MCP mode the sidecar implements OAuth 2.0 for MCP servers per RFC 9728 (Protected Resource Metadata); Dynamic Client Registration (RFC 7591) is handled natively by Keycloak and consumed directly by MCP clients. Configured via policies.forteapps.io/auth-mcp-resource and policies.forteapps.io/auth-mcp-authority annotations. The sidecar port defaults to 9001 and can be overridden via the policies.forteapps.io/auth-port annotation. A NetworkPolicy is generated to restrict ingress to the sidecar port only.
spec: spec:
background: false background: false
rules: rules:

View File

@@ -19,7 +19,9 @@
"dotnet-sdk@latest", "dotnet-sdk@latest",
"opentofu@1.11.6", "opentofu@1.11.6",
"_1password@latest", "_1password@latest",
"github-cli@latest" "github-cli@latest",
"upcloud-cli@3.29.0",
"awscli2@2.34.24"
], ],
"shell": { "shell": {
"init_hook": [ "init_hook": [

View File

@@ -772,7 +772,7 @@ Internet → Traefik → Service:8080 → Auth Sidecar:8080 → localhost → Yo
Three authentication modes are supported: Three authentication modes are supported:
1. **Token-based**: Static tokens (simple, good for service-to-service or internal apps) 1. **Token-based**: Static tokens (simple, good for service-to-service or internal apps)
2. **OIDC**: OpenID Connect (full SSO, good for user-facing apps) 2. **OIDC**: OpenID Connect (full SSO, good for user-facing apps)
3. **MCP**: OAuth 2.0 for MCP servers via RFC 9728 / RFC 7591 (good for MCP tool servers requiring OAuth-based access control) 3. **MCP**: OAuth 2.0 for MCP servers via RFC 9728 (Protected Resource Metadata); Keycloak provides native RFC 7591 Dynamic Client Registration (good for MCP tool servers requiring OAuth-based access control)
--- ---
@@ -1013,7 +1013,7 @@ auth:
scopes: "openid,profile,email" # OIDC scopes (optional) scopes: "openid,profile,email" # OIDC scopes (optional)
callbackPath: /auth/callback # OAuth callback path (optional) callbackPath: /auth/callback # OAuth callback path (optional)
# MCP mode configuration (RFC 9728 / RFC 7591) # MCP mode configuration (RFC 9728)
mcp: mcp:
resource: "" # Protected resource URL (required for MCP) resource: "" # Protected resource URL (required for MCP)
authority: "" # Authorization server URL (required for MCP) authority: "" # Authorization server URL (required for MCP)
@@ -1161,7 +1161,7 @@ ingress:
host: mcp-server.forteapps.net host: mcp-server.forteapps.net
``` ```
The MCP auth mode implements RFC 9728 (OAuth 2.0 Protected Resource Metadata) for authorization server discovery and RFC 7591 (OAuth 2.0 Dynamic Client Registration) for automatic client registration. MCP clients discover the authorization server and scopes from the `/.well-known/oauth-protected-resource` endpoint served by the sidecar. The MCP auth mode implements RFC 9728 (OAuth 2.0 Protected Resource Metadata) for authorization server discovery. Dynamic Client Registration (RFC 7591) is handled natively by Keycloak; MCP clients discover the authorization server and scopes from the `/.well-known/oauth-protected-resource` endpoint served by the sidecar and then register directly with Keycloak.
#### Example 4: Disabling Authentication #### Example 4: Disabling Authentication

View File

@@ -2,6 +2,12 @@
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
- [Infrastructure Provisioning (OpenTofu)](#infrastructure-provisioning-opentofu)
- [Prerequisites](#provisioning-prerequisites)
- [Provisioning a Cluster](#provisioning-a-cluster)
- [Tearing Down a Cluster](#tearing-down-a-cluster)
- [Retrieving Kubeconfig](#retrieving-kubeconfig)
- [Platform Credentials](#platform-credentials)
- [Cluster Bootstrap](#cluster-bootstrap) - [Cluster Bootstrap](#cluster-bootstrap)
- [Initial Cluster Setup](#initial-cluster-setup) - [Initial Cluster Setup](#initial-cluster-setup)
- [ArgoCD Repository Access Setup](#argocd-repository-access-setup) - [ArgoCD Repository Access Setup](#argocd-repository-access-setup)
@@ -29,6 +35,120 @@ This runbook provides operational procedures for maintaining the Kubernetes clus
--- ---
## Infrastructure Provisioning (OpenTofu)
The `.tofu/` directory contains multi-cloud Kubernetes infrastructure-as-code using [OpenTofu](https://opentofu.org/). It provisions clusters on four cloud platforms (AKS, EKS, GKE, UpCloud), each with three environment tiers: **dev**, **prod**, and **workload**.
### Provisioning Prerequisites {#provisioning-prerequisites}
- **OpenTofu** (`tofu`) installed
- **kubectl** installed
- **helm** installed
- **yq** (optional — loads cluster config from `clusters/<cluster>.yaml`)
- Platform CLI tools:
- **AKS**: `az` (Azure CLI)
- **EKS**: `aws` (AWS CLI)
- **GKE**: `gcloud` (Google Cloud SDK)
- **UPC**: `upctl` (UpCloud CLI)
### Provisioning a Cluster
```bash
# Navigate to the scripts directory
cd .tofu/scripts
# 1. Copy and fill in credentials for your platform
cp ../configs/aks.env.example ../configs/aks.env
# Edit ../configs/aks.env with your credentials
# 2. Provision cluster (interactive — prompts before applying)
./setup-cluster.sh aks-dev
# 3. Dry-run only (plan without applying)
./setup-cluster.sh aks-dev --plan
# 4. Non-interactive (skip confirmations)
./setup-cluster.sh aks-dev --auto
```
**Cluster name format**: `<platform>-<env>` — e.g., `aks-dev`, `eks-prod`, `gke-workload`, `upc-dev`
**What `setup-cluster.sh` does**:
1. Validates cluster name, extracts platform and environment
2. Checks prerequisites (tofu, kubectl, helm)
3. Loads credentials from `configs/<platform>.env`
4. Optionally loads cluster config from `clusters/<cluster>.yaml` (via yq)
5. Runs `tofu init``tofu plan` → prompts → `tofu apply`
6. Fetches and caches kubeconfig to `private/<cluster>/kubeconfig`
7. Waits for all nodes to reach Ready state (300s timeout)
8. Outputs next steps: `export KUBECONFIG` + `./bootstrap.sh`
### Tearing Down a Cluster
```bash
# Destroy cluster infrastructure
./teardown-cluster.sh aks-dev
# Equivalent to:
./setup-cluster.sh aks-dev --destroy
```
### Retrieving Kubeconfig
```bash
# Get kubeconfig for an existing cluster (uses cache or platform CLI)
./get-kubeconfig.sh aks-dev
# Cached kubeconfigs stored in: private/<cluster>/kubeconfig
```
Platform-specific retrieval fallbacks:
- **AKS**: `az aks get-credentials`
- **EKS**: `aws eks update-kubeconfig`
- **GKE**: `gcloud container clusters get-credentials`
- **UPC**: `upctl kubernetes config`
### Platform Credentials
Each platform has a `configs/<platform>.env.example` template. Copy to `.env` and populate:
| Platform | Required Variables | Optional |
|----------|--------------------|----------|
| **AKS** | `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` | `ARM_RESOURCE_GROUP` (defaults to cluster name) |
| **EKS** | `AWS_PROFILE` (default: "default"), `AWS_REGION` (default: "eu-west-1") | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
| **GKE** | `GCP_PROJECT_ID`, `GCP_REGION` (default: "europe-west4") | `GOOGLE_APPLICATION_CREDENTIALS` (SA JSON path) |
| **UPC** | `UPCLOUD_TOKEN` | `UPCLOUD_CLUSTER_ID` (set after creation) |
> **Note**: `.env` files are git-ignored. Never commit credentials.
### End-to-End Workflow
Full cluster lifecycle: provision → bootstrap → operate → teardown:
```bash
# 1. Provision infrastructure
cd .tofu/scripts
./setup-cluster.sh aks-dev
# 2. Export kubeconfig (printed by setup-cluster.sh)
export KUBECONFIG=$(pwd)/../../private/aks-dev/kubeconfig
# 3. Bootstrap GitOps (ArgoCD + App-of-Apps)
cd ../..
./bootstrap.sh aks-dev
# 4. Verify
kubectl get applications -n argocd
# ... operate cluster ...
# 5. Teardown when done
cd .tofu/scripts
./teardown-cluster.sh aks-dev
```
---
## Cluster Bootstrap ## Cluster Bootstrap
### Initial Cluster Setup ### Initial Cluster Setup
@@ -37,7 +157,7 @@ Bootstrap a new cluster from scratch:
#### Prerequisites #### Prerequisites
1. **Kubernetes cluster running** (UpCloud, AWS EKS, Azure AKS, GCP GKE, or any K8s cluster) 1. **Kubernetes cluster running** (provisioned via `.tofu/scripts/setup-cluster.sh` or manually on UpCloud, AWS EKS, Azure AKS, GCP GKE)
2. **kubectl configured** with admin access 2. **kubectl configured** with admin access
3. **Repositories cloned** locally 3. **Repositories cloned** locally
@@ -1286,14 +1406,17 @@ spec:
```bash ```bash
# 1. Provision new Kubernetes cluster # 1. Provision new Kubernetes cluster
cd .tofu/scripts
./setup-cluster.sh upc-dev # or aks-dev, eks-prod, etc.
export KUBECONFIG=$(pwd)/../../private/upc-dev/kubeconfig
# 2. Configure kubectl # 2. Verify cluster access
kubectl config use-context new-cluster
kubectl cluster-info kubectl cluster-info
kubectl get nodes
# 3. Bootstrap cluster # 3. Bootstrap cluster
cd ~/dev/k8s/launchpad cd ../..
./bootstrap.sh ./bootstrap.sh upc-dev
# 4. Wait for ArgoCD to sync all applications # 4. Wait for ArgoCD to sync all applications
kubectl get applications -n argocd -w kubectl get applications -n argocd -w

View File

@@ -3,6 +3,7 @@
## Table of Contents ## Table of Contents
- [Architecture Components](#architecture-components) - [Architecture Components](#architecture-components)
- [Repository Reference](#repository-reference) - [Repository Reference](#repository-reference)
- [OpenTofu Infrastructure Reference](#opentofu-infrastructure-reference)
- [Helm Chart Reference](#helm-chart-reference) - [Helm Chart Reference](#helm-chart-reference)
- [ArgoCD Configuration](#argocd-configuration) - [ArgoCD Configuration](#argocd-configuration)
- [Infrastructure Components](#infrastructure-components) - [Infrastructure Components](#infrastructure-components)
@@ -207,6 +208,196 @@ launchpad/
└── REFERENCE.md └── REFERENCE.md
``` ```
---
## OpenTofu Infrastructure Reference
The `.tofu/` directory provides multi-cloud Kubernetes cluster provisioning using OpenTofu.
### Directory Structure
```
.tofu/
├── configs/ # Platform credential templates (git-ignored .env files)
│ ├── aks.env.example
│ ├── eks.env.example
│ ├── gke.env.example
│ └── upc.env.example
├── platforms/ # OpenTofu modules per cloud provider
│ ├── aks/ # Azure AKS
│ │ ├── modules/cluster/ # Reusable AKS module
│ │ │ ├── main.tf # Resource group, VNet, subnet, AKS cluster
│ │ │ ├── variables.tf
│ │ │ ├── outputs.tf
│ │ │ └── providers.tf
│ │ ├── dev/ # Dev environment root
│ │ ├── prod/ # Prod environment root
│ │ └── workload/ # Workload cluster (+ external-dns identity)
│ ├── eks/ # AWS EKS (same structure)
│ ├── gke/ # GCP GKE
│ └── upc/ # UpCloud Kubernetes
└── scripts/
├── setup-cluster.sh # Provision cluster
├── teardown-cluster.sh # Destroy cluster
└── get-kubeconfig.sh # Retrieve/cache kubeconfig
```
### Three-Tier Cluster Strategy
Each platform defines three environment tiers:
| Tier | Purpose | Typical Sizing | Notes |
|------|---------|---------------|-------|
| **dev** | Development/testing | Small, economical nodes (2 nodes) | No delete locks, minimal HA |
| **prod** | Production workloads | Larger nodes, multiple AZs (3 nodes) | Delete locks, HA networking |
| **workload** | Application-only cluster | Medium nodes (2 nodes) | Includes external-DNS integration, no platform services |
### Platform Specifications
#### AKS (Azure Kubernetes Service)
| Resource | Description |
|----------|-------------|
| `azurerm_resource_group` | Container for all Azure resources |
| `azurerm_management_lock` | Optional CanNotDelete lock (prod) |
| `azurerm_virtual_network` | VPC, default `10.100.0.0/16` |
| `azurerm_subnet` | Node subnet, default `10.100.0.0/22` |
| `azurerm_kubernetes_cluster` | AKS with Azure CNI, OIDC issuer, Workload Identity |
**Dev**: Standard_B2s, 2 nodes, norwayeast, no delete lock
**Prod**: Standard_D4s_v3, 3 nodes, westeurope, delete lock enabled
**Workload**: Adds `azurerm_user_assigned_identity` + federated credential for external-dns with DNS Zone Contributor role
**Variables** (`modules/cluster/variables.tf`):
- `prefix` — resource name prefix
- `location` — Azure region
- `vnet_address_space` — default `10.100.0.0/16`
- `aks_subnet_cidr` — default `10.100.0.0/22`
- `aks_node_vm_size` — VM size (e.g., `Standard_B2s`)
- `aks_node_count` — number of nodes
- `aks_kubernetes_version``null` = latest
- `enable_delete_lock` — default `false`
#### EKS (Amazon Elastic Kubernetes Service)
| Resource | Description |
|----------|-------------|
| `aws_vpc` | VPC with DNS enabled, default `10.100.0.0/16` |
| `aws_subnet` (public) | Per-AZ, tagged `kubernetes.io/role/elb=1` |
| `aws_subnet` (private) | Per-AZ, tagged `kubernetes.io/role/internal-elb=1` |
| `aws_nat_gateway` | Single NAT (dev); prod should use one per AZ |
| `aws_eks_cluster` | EKS with public+private endpoints, OIDC issuer |
| `aws_iam_openid_connect_provider` | IRSA (IAM Roles for Service Accounts) |
| `aws_eks_node_group` | Managed nodes with auto-scaling |
**Dev**: t3.medium, 2 nodes (min 1, max 4), eu-west-1a/b, K8s 1.30
**Prod**: m5.xlarge, 3 nodes (min 3, max 6), eu-west-1a/b/c
**Workload**: Adds IRSA role for external-dns with Route53 permissions (ChangeResourceRecordSets, ListHostedZones, ListResourceRecordSets, ListTagsForResource)
**Variables**:
- `region` — AWS region
- `vpc_cidr` — default `10.100.0.0/16`
- `availability_zones` — list of AZs (23 recommended)
- `node_instance_type`, `node_count`, `node_min_count`, `node_max_count`
- `kubernetes_version` — default `1.30`
#### GKE (Google Kubernetes Engine)
| Resource | Description |
|----------|-------------|
| `google_project_service` | Enables compute and container APIs |
| `google_compute_network` | Custom VPC (no auto subnets) |
| `google_compute_subnetwork` | Primary `10.100.0.0/22`, pods `10.200.0.0/14`, services `10.204.0.0/20` |
| `google_container_cluster` | Regional cluster, VPC-native, Workload Identity |
| `google_container_node_pool` | Auto-repair, auto-upgrade, GKE_METADATA mode |
**Dev**: e2-standard-2, 2 nodes/zone, no deletion protection
**Prod**: e2-standard-4, 3 nodes/zone, deletion protection enabled
**Workload**: Adds Google SA for external-dns with `dns.admin` role + Workload Identity binding
**Variables**:
- `project_id` — GCP project (required)
- `region` — GCP region
- `node_machine_type`, `node_count`
- `kubernetes_version``null` = STABLE release channel
- `deletion_protection` — default `false`
#### UPC (UpCloud Kubernetes)
| Resource | Description |
|----------|-------------|
| `upcloud_router` | Private router for cluster network |
| `upcloud_gateway` | NAT gateway for outbound internet |
| `upcloud_network` | Private network, DHCP, default `10.100.0.0/24` |
| `upcloud_kubernetes_cluster` | Managed K8s, private node groups |
| `upcloud_kubernetes_node_group` | Anti-affinity if node_count > 1 |
**Dev**: DEV-1xCPU-2GB, 2 nodes, no-svg1
**Prod**: 4xCPU-8GB, 3 nodes, no-svg1
**Workload**: 2xCPU-4GB, 2 nodes, no-svg1, CIDR `10.110.0.0/24`
> **Note**: UpCloud has no native workload identity — external-DNS integration not available.
### Workload Identity & External-DNS
Workload clusters include keyless cloud access for external-DNS:
| Platform | Identity Mechanism | DNS Permissions |
|----------|--------------------|-----------------|
| **AKS** | Azure Workload Identity (federated credential) | DNS Zone Contributor |
| **EKS** | IRSA (OIDC federation) | Route53 ChangeResourceRecordSets, ListHostedZones |
| **GKE** | Workload Identity (K8s SA → Google SA) | dns.admin role |
| **UPC** | N/A | N/A |
### Naming Conventions
- Cluster: `<prefix>-aks` / `-eks` / `-gke` (derived from platform)
- Resource groups: `<prefix>-rg` (Azure only)
- VPCs/Networks: `<prefix>-vpc`
- Node groups: `<prefix>-nodes`
- Dev prefix: `clst-dev`, Prod prefix: `clst`, Workload prefix: `clst-workload`
### Provider Authentication
| Platform | Auth Method | Config Source |
|----------|-------------|---------------|
| **AKS** | Azure CLI or env vars (`ARM_SUBSCRIPTION_ID`, `ARM_TENANT_ID`) | `configs/aks.env` |
| **EKS** | AWS CLI profile or explicit credentials | `configs/eks.env` |
| **GKE** | Application Default Credentials or SA JSON | `configs/gke.env` |
| **UPC** | API token (`UPCLOUD_TOKEN`) | `configs/upc.env` |
### Scripts Reference
#### `setup-cluster.sh`
```bash
./setup-cluster.sh <platform>-<env> [--plan] [--destroy] [--auto]
```
| Flag | Effect |
|------|--------|
| (none) | Interactive: plan → prompt → apply |
| `--plan` | Dry-run only (tofu plan) |
| `--destroy` | Destroy infrastructure |
| `--auto` | Skip confirmation prompts |
#### `teardown-cluster.sh`
```bash
./teardown-cluster.sh <platform>-<env>
# Delegates to: setup-cluster.sh "$@" --destroy
```
#### `get-kubeconfig.sh`
```bash
./get-kubeconfig.sh <platform>-<env>
# Checks cache: private/<cluster>/kubeconfig
# Falls back to platform CLI if no cache
```
---
#### Key Files #### Key Files
**`bootstrap.sh`** **`bootstrap.sh`**
@@ -1545,7 +1736,7 @@ spec:
2. `generate-auth-oidc-secret` - Creates Secret for OIDC mode 2. `generate-auth-oidc-secret` - Creates Secret for OIDC mode
3. `inject-sidecar-token` - Injects auth sidecar for token mode 3. `inject-sidecar-token` - Injects auth sidecar for token mode
4. `inject-sidecar-oidc` - Injects auth sidecar for OIDC mode 4. `inject-sidecar-oidc` - Injects auth sidecar for OIDC mode
5. `inject-sidecar-mcp` - Injects auth sidecar for MCP OAuth mode (RFC 9728 / RFC 7591) 5. `inject-sidecar-mcp` - Injects auth sidecar for MCP OAuth mode (RFC 9728)
6. `generate-auth-network-policy` - Creates NetworkPolicy to restrict ingress 6. `generate-auth-network-policy` - Creates NetworkPolicy to restrict ingress
#### Trigger Annotation #### Trigger Annotation
@@ -1585,7 +1776,7 @@ policies.forteapps.io/auth-image: "ghcr.io/fortedigital/auth-sidecar"
policies.forteapps.io/auth-image-version: "latest" policies.forteapps.io/auth-image-version: "latest"
``` ```
**MCP Mode** (OAuth 2.0 for MCP servers, implements RFC 9728 / RFC 7591): **MCP Mode** (OAuth 2.0 for MCP servers, implements RFC 9728; MCP clients use Keycloak's native RFC 7591 endpoint for Dynamic Client Registration):
```yaml ```yaml
# Annotations (required) # Annotations (required)
policies.forteapps.io/auth: "true" policies.forteapps.io/auth: "true"
@@ -1813,7 +2004,7 @@ Pod: Auth Sidecar (port 8080)
├─ Validate credentials ├─ Validate credentials
│ • Token mode: Check Bearer token │ • Token mode: Check Bearer token
│ • OIDC mode: Validate session or redirect to IdP │ • OIDC mode: Validate session or redirect to IdP
│ • MCP mode: OAuth 2.0 via RFC 9728 discovery / RFC 7591 dynamic registration │ • MCP mode: OAuth 2.0 via RFC 9728 discovery; Keycloak handles RFC 7591 dynamic registration natively
Forward to Application (localhost:3000) Forward to Application (localhost:3000)

View File

@@ -1,9 +1,30 @@
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
resources: resources:
- ../../base - ../../base/cert-manager-application
- ../../base/cluster-resources-application
- ../../base/enterprise-apps
- ../../base/fluent-bit
- ../../base/gitea
- ../../base/gitea-actions
- ../../base/grafana
- ../../base/grafana-dashboards
- ../../base/homepage
- ../../base/karpor
- ../../base/keycloak
- ../../base/kyverno
- ../../base/kyverno-policies
- ../../base/loki
- ../../base/opencost
- ../../base/prometheus
- ../../base/renovate
- ../../base/sealedsecrets
- ../../base/tempo
- ../../base/traefik-application
- ../../base/vault
- vaultwarden-postgresql - vaultwarden-postgresql
- vaultwarden - vaultwarden
- wildcard-tls-certificate.yaml
# No patches needed — base already has "upc-dev" paths # No patches needed — base already has "upc-dev" paths
# upc-dev is the default/base cluster # upc-dev is the default/base cluster

View File

@@ -0,0 +1,38 @@
---
# Wildcard Certificate for *.forteapps.net
# This creates a certificate that covers ALL subdomains of forteapps.net
# Once created, you can use it for any app like:
# - myapp.forteapps.net
# - api.forteapps.net
# - git.forteapps.net
# - vaultwarden.forteapps.net
# - etc.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-forteapps-net
namespace: cert-manager # Can be in any namespace, cert-manager namespace is common
spec:
# The secret where the TLS certificate will be stored
# This secret can be referenced by IngressRoutes in any namespace
secretName: wildcard-forteapps-net-tls
# Use the production issuer (use letsencrypt-staging for testing)
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
# DNS names this certificate will cover
# Both wildcard AND apex domain are recommended
dnsNames:
- '*.forteapps.net' # Covers: myapp.forteapps.net, api.forteapps.net, etc.
- 'forteapps.net' # Also include apex domain explicitly
# Optional: Configure certificate duration and renewal
duration: 2160h0m0s # 90 days (Let's Encrypt default)
renewBefore: 720h0m0s # Renew 30 days before expiry
# Optional: Private key settings
privateKey:
algorithm: RSA
encoding: PKCS1
size: 4096

View File

@@ -22,7 +22,8 @@ ingress:
# TLS configuration # TLS configuration
tls: tls:
enabled: true # Set to true to enable TLS enabled: true # Set to true to enable TLS
secretName: "databunker-tls" # Name of the secret containing TLS certificate # secretName: "databunker-tls" # Name of the secret containing TLS certificate
secretName: "wildcard-forteapps-net-tls" # Name of the secret containing TLS certificate
# Pin PostgreSQL password — chart uses randAlphaNum without lookup, # Pin PostgreSQL password — chart uses randAlphaNum without lookup,
# so each ArgoCD sync would regenerate the password while PVC keeps the old one. # so each ArgoCD sync would regenerate the password while PVC keeps the old one.

View File

@@ -602,3 +602,148 @@ extraDeploy:
items: items:
- key: admin-password - key: admin-password
path: admin-password path: admin-password
# -- ServiceAccount for the client cleanup CronJob
- apiVersion: v1
kind: ServiceAccount
metadata:
name: keycloak-client-cleanup
namespace: keycloak
# -- CronJob: cleans up stale dynamically registered Keycloak clients
- apiVersion: batch/v1
kind: CronJob
metadata:
name: keycloak-client-cleanup
namespace: keycloak
spec:
schedule: "0 3 * * 0"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 3
template:
spec:
serviceAccountName: keycloak-client-cleanup
restartPolicy: Never
containers:
- name: cleanup
image: alpine:3.20
command: [ "/bin/sh", "-c" ]
args:
- |
set -e
apk add --no-cache curl jq > /dev/null 2>&1
KEYCLOAK_URL="http://keycloak:80"
REALM="forte"
ADMIN_USER="admin"
ADMIN_PASS=$(cat /secrets/admin-password)
DRY_RUN="${DRY_RUN:-true}"
MIN_AGE_DAYS="${MIN_AGE_DAYS:-7}"
if [ -z "$CLIENT_ID_PATTERN" ]; then
CLIENT_ID_PATTERN='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
fi
echo "=== Keycloak DCR client cleanup ==="
echo "Dry run: ${DRY_RUN}"
echo "Min age: ${MIN_AGE_DAYS} days"
echo "Pattern: ${CLIENT_ID_PATTERN}"
# Authenticate to Keycloak Admin API
TOKEN=$(curl -sf -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" \
-d "username=${ADMIN_USER}" \
-d "password=${ADMIN_PASS}" \
-d "grant_type=password" | jq -r '.access_token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "ERROR: Failed to authenticate to Keycloak"
exit 1
fi
NOW_SEC=$(date +%s)
MIN_AGE_SEC=$((MIN_AGE_DAYS * 86400))
# Hardcoded protected clients (never delete these)
PROTECTED_JSON='["gitea","grafana","argocd","vaultwarden","account","account-console","admin-cli","broker","realm-management","security-admin-console"]'
echo "Fetching clients from realm '${REALM}'..."
CLIENTS=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
"${KEYCLOAK_URL}/admin/realms/${REALM}/clients")
CANDIDATES=$(echo "$CLIENTS" | jq -c --argjson protected "$PROTECTED_JSON" --argjson now "$NOW_SEC" --argjson min_age "$MIN_AGE_SEC" --arg pattern "$CLIENT_ID_PATTERN" '
[
.[]
| select(.clientId as $cid | $protected | index($cid) | not)
| select(.attributes["k8s.secret.sync"] != "true")
| select(.clientId | test($pattern; "i"))
| select((.createdTimestamp // 0) / 1000 < ($now - $min_age))
]
')
COUNT=$(echo "$CANDIDATES" | jq 'length')
echo "Found ${COUNT} candidate client(s) matching pattern and age threshold"
if [ "$COUNT" -eq 0 ]; then
echo "Nothing to clean up."
exit 0
fi
while IFS= read -r CLIENT; do
CLIENT_ID=$(echo "$CLIENT" | jq -r '.clientId')
CLIENT_UUID=$(echo "$CLIENT" | jq -r '.id')
CREATED=$(echo "$CLIENT" | jq -r '.createdTimestamp')
# Check active sessions
SESSION_RESPONSE=$(curl -s -H "Authorization: Bearer ${TOKEN}" \
"${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}/session-count" || true)
SESSION_COUNT=$(echo "$SESSION_RESPONSE" | jq -r '.count // 0')
if [ "$SESSION_COUNT" -gt 0 ]; then
echo " SKIP: '${CLIENT_ID}' has ${SESSION_COUNT} active session(s)"
continue
fi
AGE_DAYS=$(( (NOW_SEC - (CREATED / 1000)) / 86400 ))
if [ "$DRY_RUN" = "true" ]; then
echo " DRY-RUN: would delete '${CLIENT_ID}' (uuid: ${CLIENT_UUID}, age: ${AGE_DAYS}d)"
else
echo " DELETE: '${CLIENT_ID}' (uuid: ${CLIENT_UUID}, age: ${AGE_DAYS}d)"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${TOKEN}" \
-X DELETE \
"${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}" || echo "000")
if [ "$HTTP_CODE" != "204" ] && [ "$HTTP_CODE" != "200" ]; then
echo " ERROR: Failed to delete '${CLIENT_ID}' (HTTP ${HTTP_CODE})"
fi
fi
done < <(echo "$CANDIDATES" | jq -c '.[]')
echo "Cleanup run complete."
env:
- name: DRY_RUN
value: "false"
- name: MIN_AGE_DAYS
value: "7"
volumeMounts:
- name: keycloak-credentials
mountPath: /secrets
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumes:
- name: keycloak-credentials
secret:
secretName: keycloak-credentials
items:
- key: admin-password
path: admin-password

View File

@@ -1,10 +1,10 @@
ingress: ingress:
enabled: true enabled: true
host: databunker.forteapps.net host: bunker.forteapps.net
annotations: annotations:
gethomepage.dev/enabled: "true" gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Databunker" gethomepage.dev/name: "Databunker"
gethomepage.dev/description: "Secure Database for PII and PCI Records" gethomepage.dev/description: "Secure Database for PII and PCI Records"
gethomepage.dev/group: "Security" gethomepage.dev/group: "Security"
gethomepage.dev/icon: "double-take" gethomepage.dev/icon: "double-take"
gethomepage.dev/href: "https://databunker.forteapps.net" gethomepage.dev/href: "https://bunker.forteapps.net"

View File

@@ -60,7 +60,7 @@ config:
description: Teknisk kompetanse fra offentlige anbud description: Teknisk kompetanse fra offentlige anbud
icon: forte icon: forte
- Forte Drop: - Forte Drop:
href: https://drop.hackathon.forteapps.net href: https://drop.forteapps.net
description: Self-hosted HTML-drops + MCP for Claude description: Self-hosted HTML-drops + MCP for Claude
icon: forte icon: forte
- Forte Feedback: - Forte Feedback: