feat(devtools): deploy Penpot + MCP server, wildcard TLS via DNS-01

Penpot (designer.sunbeam.pt):
- Frontend/backend/exporter deployments with OIDC-only auth via Hydra
- VSO-managed DB, S3, and app secrets from OpenBao
- PostgreSQL user/db in CNPG postInitSQL
- Hydra Maester enabledNamespaces extended to devtools

Penpot MCP server (mcp-designer.sunbeam.pt):
- Pre-built Node.js image pushed to Gitea registry
- Auth-gated via Pingora auth_request → Hydra /userinfo
- WebSocket path for browser plugin connection

Wildcard TLS:
- Switched cert-manager from HTTP-01 (per-SAN) to DNS-01 via Scaleway webhook
- Certificate collapsed to *.sunbeam.pt + sunbeam.pt
- Added scaleway-certmanager-webhook Helm chart
- VSO secret for Scaleway DNS API credentials in cert-manager namespace
- Added cert-manager to OpenBao VSO auth role
This commit is contained in:
2026-04-04 12:53:27 +01:00
parent 97628b0f6f
commit fcb80f1f37
13 changed files with 486 additions and 40 deletions

View File

@@ -3,6 +3,7 @@ kind: Kustomization
resources:
- namespace.yaml
- scaleway-dns-secret.yaml
helmCharts:
# helm repo add jetstack https://charts.jetstack.io
@@ -13,3 +14,11 @@ helmCharts:
namespace: cert-manager
valuesFile: values.yaml
includeCRDs: true
# helm repo add scaleway https://helm.scw.cloud/
# DNS-01 webhook for Scaleway DNS — enables wildcard certs.
- name: scaleway-certmanager-webhook
repo: https://helm.scw.cloud/
version: "0.4.1"
releaseName: scaleway-certmanager-webhook
namespace: cert-manager

View File

@@ -0,0 +1,37 @@
# Scaleway API credentials for DNS-01 ACME challenges.
# Synced from OpenBao KV path: secret/scaleway-s3 (same API key used for S3 + DNS).
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: vso-auth
namespace: cert-manager
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: vso
serviceAccount: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: scaleway-dns-credentials
namespace: cert-manager
spec:
vaultAuthRef: vso-auth
mount: secret
type: kv-v2
path: scaleway-s3
refreshAfter: 30s
destination:
name: scaleway-secret
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
SCW_ACCESS_KEY:
text: "{{ index .Secrets \"access-key-id\" }}"
SCW_SECRET_KEY:
text: "{{ index .Secrets \"secret-access-key\" }}"

View File

@@ -44,6 +44,8 @@ spec:
- CREATE DATABASE hive_db OWNER hive;
- CREATE USER find WITH LOGIN;
- CREATE DATABASE find_db OWNER find;
- CREATE USER penpot WITH LOGIN;
- CREATE DATABASE penpot_db OWNER penpot;
storage:
size: 10Gi

View File

@@ -9,6 +9,10 @@ resources:
- gitea-theme-cm.yaml
- gitea-servicemonitor.yaml
- gitea-alertrules.yaml
- beam-design.yaml
- penpot.yaml
- penpot-oidc.yaml
- penpot-mcp.yaml
helmCharts:
# helm repo add gitea-charts https://dl.gitea.com/charts/

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine
RUN npm install -g pnpm@latest @penpot/mcp@latest && \
cd /usr/local/lib/node_modules/@penpot/mcp && \
pnpm -r install && \
pnpm run build
ENV PENPOT_MCP_REMOTE_MODE=true \
PENPOT_MCP_SERVER_HOST=0.0.0.0 \
PENPOT_MCP_SERVER_PORT=4401 \
PENPOT_MCP_WEBSOCKET_PORT=4402
EXPOSE 4401 4402
WORKDIR /usr/local/lib/node_modules/@penpot/mcp
CMD ["pnpm", "run", "start"]

View File

@@ -0,0 +1,56 @@
# Penpot MCP server — bridges AI clients to Penpot via the MCP plugin.
# Port 4401: HTTP/SSE for MCP clients (Claude, etc.)
# Port 4402: WebSocket for the Penpot browser plugin
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: penpot-mcp
namespace: devtools
spec:
replicas: 1
selector:
matchLabels:
app: penpot-mcp
template:
metadata:
labels:
app: penpot-mcp
spec:
containers:
- name: penpot-mcp
image: src.sunbeam.pt/studio/penpot-mcp:latest
ports:
- name: http
containerPort: 4401
- name: ws
containerPort: 4402
env:
- name: PENPOT_MCP_REMOTE_MODE
value: "true"
- name: PENPOT_MCP_SERVER_HOST
value: "0.0.0.0"
- name: PENPOT_MCP_SERVER_ADDRESS
value: "mcp-designer.DOMAIN_SUFFIX"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: penpot-mcp
namespace: devtools
spec:
selector:
app: penpot-mcp
ports:
- name: http
port: 4401
targetPort: http
- name: ws
port: 4402
targetPort: ws

View File

@@ -0,0 +1,20 @@
# Penpot OIDC client — Hydra Maester creates Secret "oidc-penpot" in devtools
# with CLIENT_ID and CLIENT_SECRET keys.
apiVersion: hydra.ory.sh/v1alpha1
kind: OAuth2Client
metadata:
name: penpot
namespace: devtools
spec:
clientName: Penpot
grantTypes:
- authorization_code
- refresh_token
responseTypes:
- code
scope: openid email profile
redirectUris:
- https://designer.DOMAIN_SUFFIX/api/auth/oidc/callback
tokenEndpointAuthMethod: client_secret_post
secretName: oidc-penpot
skipConsent: true

205
base/devtools/penpot.yaml Normal file
View File

@@ -0,0 +1,205 @@
# Penpot — open-source design tool (frontend + backend + exporter).
# OIDC-only auth via Hydra; assets on SeaweedFS; DB on shared CNPG postgres.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: penpot-config
namespace: devtools
data:
PENPOT_PUBLIC_URI: "https://designer.DOMAIN_SUFFIX"
PENPOT_DATABASE_URI: "postgresql://postgres-rw.data.svc.cluster.local:5432/penpot_db"
PENPOT_DATABASE_USERNAME: "penpot"
PENPOT_REDIS_URI: "redis://valkey.data.svc.cluster.local:6379/3"
PENPOT_ASSETS_STORAGE_BACKEND: "assets-s3"
PENPOT_STORAGE_ASSETS_S3_ENDPOINT: "http://seaweedfs-filer.storage.svc.cluster.local:8333"
PENPOT_STORAGE_ASSETS_S3_BUCKET: "penpot"
PENPOT_OIDC_BASE_URI: "https://auth.DOMAIN_SUFFIX/"
PENPOT_TELEMETRY_ENABLED: "false"
PENPOT_FLAGS: "enable-login-with-oidc disable-login-with-password disable-email-verification disable-registration enable-backend-api-doc enable-auto-file-snapshot enable-tiered-file-data-storage enable-webhooks enable-access-tokens enable-cors"
---
# ── Frontend (nginx SPA) ─────────────────────────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: penpot-frontend
namespace: devtools
spec:
replicas: 1
selector:
matchLabels:
app: penpot-frontend
template:
metadata:
labels:
app: penpot-frontend
spec:
containers:
- name: penpot-frontend
image: penpotapp/frontend:latest
ports:
- name: http
containerPort: 8080
env:
- name: PENPOT_FLAGS
valueFrom:
configMapKeyRef:
name: penpot-config
key: PENPOT_FLAGS
- name: PENPOT_BACKEND_URI
value: "http://penpot-backend:6060"
- name: PENPOT_EXPORTER_URI
value: "http://penpot-exporter:6061"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: penpot-frontend
namespace: devtools
spec:
selector:
app: penpot-frontend
ports:
- name: http
port: 8080
targetPort: http
---
# ── Backend (JVM API + websockets) ───────────────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: penpot-backend
namespace: devtools
spec:
replicas: 1
selector:
matchLabels:
app: penpot-backend
template:
metadata:
labels:
app: penpot-backend
spec:
containers:
- name: penpot-backend
image: penpotapp/backend:latest
ports:
- name: http
containerPort: 6060
envFrom:
- configMapRef:
name: penpot-config
env:
- name: PENPOT_SECRET_KEY
valueFrom:
secretKeyRef:
name: penpot-app-secrets
key: secret-key
- name: PENPOT_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: penpot-db-credentials
key: password
- name: PENPOT_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: oidc-penpot
key: CLIENT_ID
- name: PENPOT_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oidc-penpot
key: CLIENT_SECRET
- name: PENPOT_STORAGE_ASSETS_S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: penpot-s3-credentials
key: access-key
- name: PENPOT_STORAGE_ASSETS_S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: penpot-s3-credentials
key: secret-key
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: penpot-backend
namespace: devtools
spec:
selector:
app: penpot-backend
ports:
- name: http
port: 6060
targetPort: http
---
# ── Exporter (headless Chromium for PDF/SVG) ─────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: penpot-exporter
namespace: devtools
spec:
replicas: 1
selector:
matchLabels:
app: penpot-exporter
template:
metadata:
labels:
app: penpot-exporter
spec:
containers:
- name: penpot-exporter
image: penpotapp/exporter:latest
ports:
- name: http
containerPort: 6061
env:
- name: PENPOT_SECRET_KEY
valueFrom:
secretKeyRef:
name: penpot-app-secrets
key: secret-key
- name: PENPOT_PUBLIC_URI
valueFrom:
configMapKeyRef:
name: penpot-config
key: PENPOT_PUBLIC_URI
- name: PENPOT_REDIS_URI
valueFrom:
configMapKeyRef:
name: penpot-config
key: PENPOT_REDIS_URI
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: penpot-exporter
namespace: devtools
spec:
selector:
app: penpot-exporter
ports:
- name: http
port: 6061
targetPort: http

View File

@@ -62,6 +62,84 @@ spec:
"secret-key":
text: "{{ index .Secrets \"secret-key\" }}"
---
# Penpot DB credentials from OpenBao database secrets engine (static role, 24h rotation).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: penpot-db-credentials
namespace: devtools
spec:
vaultAuthRef: vso-auth
mount: database
path: static-creds/penpot
allowStaticCreds: true
refreshAfter: 5m
rolloutRestartTargets:
- kind: Deployment
name: penpot-backend
destination:
name: penpot-db-credentials
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
password:
text: "{{ index .Secrets \"password\" }}"
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: penpot-s3-credentials
namespace: devtools
spec:
vaultAuthRef: vso-auth
mount: secret
type: kv-v2
path: seaweedfs
refreshAfter: 30s
rolloutRestartTargets:
- kind: Deployment
name: penpot-backend
destination:
name: penpot-s3-credentials
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
"access-key":
text: "{{ index .Secrets \"access-key\" }}"
"secret-key":
text: "{{ index .Secrets \"secret-key\" }}"
---
# Penpot app secrets (secret-key for internal auth between backend/exporter).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: penpot-app-secrets
namespace: devtools
spec:
vaultAuthRef: vso-auth
mount: secret
type: kv-v2
path: penpot
refreshAfter: 30s
rolloutRestartTargets:
- kind: Deployment
name: penpot-backend
- kind: Deployment
name: penpot-exporter
destination:
name: penpot-app-secrets
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
"secret-key":
text: "{{ index .Secrets \"secret-key\" }}"
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:

View File

@@ -172,6 +172,29 @@ data:
host_prefix = "find"
backend = "http://find-backend.lasuite.svc.cluster.local:8000"
[[routes]]
host_prefix = "design"
backend = "http://beam-design.devtools.svc.cluster.local:80"
[[routes]]
host_prefix = "designer"
backend = "http://penpot-frontend.devtools.svc.cluster.local:8080"
websocket = true
[[routes]]
host_prefix = "mcp-designer"
backend = "http://penpot-mcp.devtools.svc.cluster.local:4401"
websocket = true
[[routes.paths]]
prefix = "/"
backend = "http://penpot-mcp.devtools.svc.cluster.local:4401"
auth_request = "http://hydra-public.ory.svc.cluster.local:4444/userinfo"
[[routes.paths]]
prefix = "/ws"
backend = "http://penpot-mcp.devtools.svc.cluster.local:4402"
[[routes]]
host_prefix = "src"
backend = "http://gitea-http.devtools.svc.cluster.local:3000"

View File

@@ -61,6 +61,7 @@ hydra-maester:
- lasuite
- matrix
- monitoring
- devtools
# ServiceMonitor created as standalone resource (hydra-servicemonitor.yaml) —
# chart's built-in ServiceMonitor requires .Capabilities.APIVersions which

View File

@@ -1,9 +1,7 @@
# cert-manager issuers and certificate for production TLS.
# cert-manager issuers and wildcard certificate for production TLS.
#
# WORKFLOW: start with letsencrypt-staging to verify the HTTP-01 challenge
# flow works without burning production rate limits. Once the staging cert
# is issued successfully, flip the Certificate issuerRef to letsencrypt-production
# and delete the old Secret so cert-manager re-issues with a trusted cert.
# Uses DNS-01 via Scaleway DNS webhook for wildcard support.
# No more per-subdomain SANs — *.DOMAIN_SUFFIX covers everything.
#
# ACME_EMAIL is substituted by sunbeam apply.
---
@@ -19,12 +17,19 @@ spec:
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
serviceType: ClusterIP
- dns01:
webhook:
groupName: acme.scaleway.com
solverName: scaleway
config:
accessKeySecretRef:
key: SCW_ACCESS_KEY
name: scaleway-secret
secretKeySecretRef:
key: SCW_SECRET_KEY
name: scaleway-secret
---
# Let's Encrypt production — trusted cert, strict rate limits.
# Switch to this once staging confirms challenges resolve correctly.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
@@ -36,13 +41,19 @@ spec:
privateKeySecretRef:
name: letsencrypt-production-account-key
solvers:
- http01:
ingress:
serviceType: ClusterIP
- dns01:
webhook:
groupName: acme.scaleway.com
solverName: scaleway
config:
accessKeySecretRef:
key: SCW_ACCESS_KEY
name: scaleway-secret
secretKeySecretRef:
key: SCW_SECRET_KEY
name: scaleway-secret
---
# Certificate covering all proxy subdomains.
# Start with letsencrypt-staging. Once verified, change issuerRef.name to
# letsencrypt-production and delete the pingora-tls Secret to force re-issue.
# Wildcard certificate covering all subdomains.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
@@ -55,27 +66,4 @@ spec:
kind: ClusterIssuer
dnsNames:
- DOMAIN_SUFFIX
- docs.DOMAIN_SUFFIX
- meet.DOMAIN_SUFFIX
- drive.DOMAIN_SUFFIX
- mail.DOMAIN_SUFFIX
- messages.DOMAIN_SUFFIX
- people.DOMAIN_SUFFIX
- src.DOMAIN_SUFFIX
- auth.DOMAIN_SUFFIX
- s3.DOMAIN_SUFFIX
- metrics.DOMAIN_SUFFIX
- systemmetrics.DOMAIN_SUFFIX
- systemlogs.DOMAIN_SUFFIX
- systemtracing.DOMAIN_SUFFIX
- admin.DOMAIN_SUFFIX
- integration.DOMAIN_SUFFIX
- livekit.DOMAIN_SUFFIX
- cal.DOMAIN_SUFFIX
- projects.DOMAIN_SUFFIX
- id.DOMAIN_SUFFIX
- hydra.DOMAIN_SUFFIX
- search.DOMAIN_SUFFIX
- vault.DOMAIN_SUFFIX
- find.DOMAIN_SUFFIX
- call.DOMAIN_SUFFIX
- "*.DOMAIN_SUFFIX"

View File

@@ -58,7 +58,7 @@ done
echo "==> Setting postgres user passwords..."
PG_POD=$(kubectl $CTX -n data get pods -l cnpg.io/cluster=postgres,role=primary -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -n "$PG_POD" ]]; then
for user in kratos hydra gitea hive docs meet drive messages conversations people find; do
for user in kratos hydra gitea hive docs meet drive messages conversations people find penpot; do
kubectl $CTX -n data exec "$PG_POD" -c postgres -- \
psql -U postgres -c "ALTER USER $user WITH PASSWORD '$DB_PASSWORD';" 2>/dev/null || true
done
@@ -94,6 +94,16 @@ create_secret devtools gitea-s3-credentials \
--from-literal=access-key="$S3_ACCESS_KEY" \
--from-literal=secret-key="$S3_SECRET_KEY"
create_secret devtools penpot-db-credentials \
--from-literal=password="$DB_PASSWORD"
create_secret devtools penpot-s3-credentials \
--from-literal=access-key="$S3_ACCESS_KEY" \
--from-literal=secret-key="$S3_SECRET_KEY"
create_secret devtools penpot-app-secrets \
--from-literal=secret-key="penpot-local-secret-key-not-for-production"
# Storage namespace
ensure_ns storage
create_secret storage seaweedfs-s3-credentials \
@@ -181,6 +191,7 @@ else
bao kv put secret/seaweedfs access-key='$S3_ACCESS_KEY' secret-key='$S3_SECRET_KEY'
bao kv put secret/hive db-url='postgresql://hive:${DB_PASSWORD}@postgres-rw.data.svc.cluster.local:5432/hive_db' oidc-client-id='hive-local' oidc-client-secret='hive-local-secret'
bao kv put secret/people db-password='$DB_PASSWORD' django-secret-key='local-dev-people-django-secret-key-not-for-production'
bao kv put secret/penpot db-password='$DB_PASSWORD' secret-key='penpot-local-secret-key-not-for-production'
bao kv put secret/livekit api-key='$LIVEKIT_API_KEY' api-secret='$LIVEKIT_API_SECRET'
" 2>/dev/null
echo " Done."