initial scaffold: emdash catalog, helm chart, woodpecker pipeline, ddev

- app/: Emdash scaffold (Astro 6, node target) with cmses/plugins/pages collections
- app/seed/seed.json: WordPress→Emdash parity for kotkanagrilli.fi (~30 entries)
- Dockerfile + docker/entrypoint.sh: multi-stage build, single PVC at /app/state
- deploy/helm/: chart mirroring emdash-kotkanagrilli (single-replica, sqlite, kotkan)
- deploy/fleet-overlay/: HelmRelease/source/image-automation templates for
  anton-helm-workloads (staging + production)
- .woodpecker/container.yaml: arm64 build, three OCI tags per push
  (immutable 0.1.<pipeline> + floating <branch> + <branch>-latest)
- .ddev/: local dev with nginx proxy to emdash on :4321
- README/DEPLOYMENT/ARCHITECTURE/CLAUDE: docs covering the three-repo
  pipeline (cms-plugins + anton-helm-workloads + Gitea OCI registry)
This commit is contained in:
Oleks
2026-05-20 11:19:00 +03:00
commit 67b07634ae
52 changed files with 2856 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.DS_Store
.git/
.gitignore
*.swp
*.tmp
*.bak
*.orig
README.md
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v2
name: cms-plugins
description: CMS plugins catalog — Emdash-based catalog of WordPress→Emdash plugin parity
type: application
version: 0.1.0
appVersion: "0.1.0"
keywords:
- emdash
- cms
- astro
- catalog
home: https://git.oleks.space/oleks/cms-plugins
maintainers:
- name: oleks
+34
View File
@@ -0,0 +1,34 @@
{{/* Expand the name of the chart. */}}
{{- define "cms-plugins.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/* Create a default fully qualified app name. */}}
{{- define "cms-plugins.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/* Common labels. Flux appends `+<sha>` to chart versions for GitRepository
sources; `+` is illegal in k8s labels, so we replace it with `_`. */}}
{{- define "cms-plugins.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/name: {{ include "cms-plugins.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/* Selector labels */}}
{{- define "cms-plugins.selectorLabels" -}}
app.kubernetes.io/name: {{ include "cms-plugins.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+100
View File
@@ -0,0 +1,100 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "cms-plugins.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "cms-plugins.labels" . | nindent 4 }}
spec:
# SQLite is single-writer; do not scale beyond 1.
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
{{- include "cms-plugins.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "cms-plugins.selectorLabels" . | nindent 8 }}
app.kubernetes.io/version: {{ .Values.image.tag | quote }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: cms-plugins
# When `image.digest` is provided, pin by digest so a floating
# tag (staging, production) doesn't confuse Helm into a no-op
# upgrade when the underlying image changes. Tag stays as a
# human-readable hint via the imagePullPolicy fallback path.
image: "{{ .Values.image.repository }}{{- if .Values.image.digest -}}@{{ .Values.image.digest }}{{- else -}}:{{ .Values.image.tag }}{{- end }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
# EMDASH_SITE_URL gates the CSRF check on plugin POST routes.
# Astro inside the pod sees http://localhost:4321/, so without
# this any browser request from https://<ingress.host>/ trips
# the same-origin check. Derived from the ingress host so we
# don't need to set it per-environment.
{{- if and .Values.ingress.enabled .Values.ingress.host }}
- name: EMDASH_SITE_URL
value: "https://{{ .Values.ingress.host }}"
{{- end }}
envFrom:
- secretRef:
name: {{ .Values.existingSecret | default (printf "%s-secrets" (include "cms-plugins.fullname" .)) }}
volumeMounts:
- name: state
mountPath: {{ .Values.persistence.mountPath }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: state
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "cms-plugins.fullname" . }}-state
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
+27
View File
@@ -0,0 +1,27 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "cms-plugins.fullname" . -}}
# Plain Ingress object (no TLS) — TLS terminates at the Caddy reverse-proxy
# at the cluster edge (matches the woodpecker / emdash-kotkanagrilli pattern).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace }}
annotations:
caddy.oleks.space/ingress: "true"
labels:
{{- include "cms-plugins.labels" . | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className | default "kube-system-traefik" }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $fullName }}
port:
number: {{ .Values.service.port }}
{{- end }}
+21
View File
@@ -0,0 +1,21 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "cms-plugins.fullname" . }}-state
namespace: {{ .Release.Namespace }}
labels:
{{- include "cms-plugins.labels" . | nindent 4 }}
annotations:
# Holds the single-writer SQLite DB and uploaded media. Keep it on
# `helm uninstall` / chart-name changes — losing it is unrecoverable
# data loss, not a redeployable artifact.
helm.sh/resource-policy: keep
spec:
accessModes:
- ReadWriteOnce
storageClassName: {{ .Values.persistence.storageClass | quote }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "cms-plugins.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "cms-plugins.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
selector:
{{- include "cms-plugins.selectorLabels" . | nindent 4 }}
+19
View File
@@ -0,0 +1,19 @@
# Production overrides — applied via the FluxCD HelmRelease (or directly with
# `helm upgrade -f values-production.yaml`).
image:
tag: production-latest
ingress:
host: cms-plugins-production.kotkanagrilli.fi
persistence:
size: 10Gi
resources:
requests:
cpu: 100m
memory: 384Mi
limits:
cpu: "1"
memory: 1Gi
+17
View File
@@ -0,0 +1,17 @@
# Staging overrides — applied via the FluxCD HelmRelease (or directly with
# `helm upgrade -f values-staging.yaml`).
image:
tag: staging-latest
ingress:
host: cms-plugins-staging.kotkanagrilli.fi
# Slimmer staging — non-critical workload, can run lean.
resources:
requests:
cpu: 50m
memory: 192Mi
limits:
cpu: 500m
memory: 768Mi
+88
View File
@@ -0,0 +1,88 @@
# Defaults for the cms-plugins chart.
# Per-env overrides come from values-staging.yaml / values-production.yaml
# and from the FluxCD HelmRelease's `values:` block.
image:
repository: git.oleks.space/oleks/cms-plugins
tag: develop-latest
# The tag is a mutable floating pointer (CI retags <branch>-latest onto
# each new build), so kubelet must always re-pull — IfNotPresent would
# pin the node to whatever digest it cached first and never roll.
pullPolicy: Always
service:
port: 4321
ingress:
enabled: true
host: cms-plugins.kotkanagrilli.fi
# TLS terminates at the Caddy reverse-proxy at the cluster edge
# (matches the woodpecker / emdash-kotkanagrilli pattern). The
# Ingress object is plain — no inline TLS, no cert-manager Certificate.
className: kube-system-traefik
# SQLite is single-writer — pin to one node so the local-path PV is sticky.
# kotkan hosts the kotkanagrilli subdomain pool, matching the
# anton-helm-workloads convention (hello-kotkan, kotkanagrilli, etc.).
nodeSelector:
kubernetes.io/hostname: kotkan
tolerations: []
affinity: {}
persistence:
enabled: true
storageClass: local-path
size: 5Gi
# Mounted at /app/state. The image symlinks /app/data.db and /app/uploads
# into this volume, so a single PVC covers SQLite + uploaded media.
mountPath: /app/state
# Plain env values (non-secret).
env:
HOST: "0.0.0.0"
PORT: "4321"
NODE_ENV: production
DEPLOY_TARGET: node
STATE_DIR: /app/state
EMDASH_ALLOWED_ORIGINS: ""
# All secrets project from one Secret. Keys expected:
# - EMDASH_ENCRYPTION_KEY (required)
existingSecret: cms-plugins-secrets
imagePullSecrets:
- name: gitea-registry-creds
probes:
liveness:
# /_emdash/api/health requires auth (401 to unauthenticated requests),
# so kubelet probes fail and the pod gets killed. The site root is
# public and a 200 from it is a reasonable proxy for "the server is up".
path: /
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
readiness:
path: /
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
podSecurityContext:
fsGroup: 1001
containerSecurityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]