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
+19
View File
@@ -0,0 +1,19 @@
name: cms-plugins
type: generic
docroot: ""
disable_settings_management: true
# DDEV's web container is used purely as a reverse proxy in front of
# the emdash service (defined in docker-compose.emdash.yaml). nginx
# is rewritten via .ddev/web-entrypoint.d/00-emdash-proxy.sh.
webserver_type: nginx-fpm
# No DB service — emdash uses SQLite inside its own container.
omit_containers:
- db
use_dns_when_possible: true
hooks:
post-start:
- exec: "echo 'cms-plugins up at https://${DDEV_PROJECT}.ddev.site/'"
+48
View File
@@ -0,0 +1,48 @@
# Development container for the emdash app. Bind-mounts ../app from
# the host and runs `npx emdash dev` so source edits hot-reload. The
# production image is built separately from the root Dockerfile and
# consumed by deploy/helm/ — DDEV is *not* a prod-parity smoke test.
services:
emdash:
container_name: ddev-${DDEV_SITENAME}-emdash
image: node:22-bookworm-slim
working_dir: /app
restart: "no"
expose:
- "4321"
env_file:
- ../app/.env
environment:
HOST: "0.0.0.0"
PORT: "4321"
NODE_ENV: "development"
EMDASH_ALLOWED_ORIGINS: "https://cms-plugins.ddev.site"
EMDASH_SITE_URL: "https://cms-plugins.ddev.site"
volumes:
- ../app:/app:cached
# Anonymous-ish named volume for node_modules so the host's
# (possibly empty / wrong-arch) directory doesn't shadow what
# the container installs.
- emdash-node-modules:/app/node_modules
command:
- sh
- -c
- |
set -eu
if [ ! -d node_modules ] || [ -z "$$(ls -A node_modules 2>/dev/null)" ]; then
echo "[emdash] installing deps"
apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates >/dev/null
npm install
fi
if [ ! -f data.db ]; then
echo "[emdash] running emdash init"
node_modules/.bin/emdash init
fi
exec npx emdash dev
labels:
com.ddev.site-name: ${DDEV_SITENAME}
com.ddev.approot: $DDEV_APPROOT
volumes:
emdash-node-modules:
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Runs in DDEV's web container after start.sh has copied
# /mnt/ddev_config/nginx_full/ → /etc/nginx/sites-enabled/, but before
# supervisord boots nginx. We rewrite the generated site config to
# reverse-proxy everything to the emdash service.
set -eu
cat > /etc/nginx/sites-enabled/nginx-site.conf <<'NGINX'
# Forward `Connection: upgrade` only when the client is actually
# upgrading (Vite's HMR WebSocket lives at wss://<host>/?token=...).
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
listen 443 ssl default_server;
ssl_certificate /etc/ssl/certs/master.crt;
ssl_certificate_key /etc/ssl/certs/master.key;
include /etc/nginx/monitoring.conf;
sendfile off;
error_log /dev/stdout info;
access_log /var/log/nginx/access.log;
proxy_buffering off;
proxy_http_version 1.1;
client_max_body_size 100m;
location / {
proxy_pass http://ddev-cms-plugins-emdash:4321;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
NGINX
rm -f /etc/nginx/sites-enabled/seconddocroot.conf.example \
/etc/nginx/sites-enabled/README.nginx_full.txt \
/etc/nginx/conf.d/connection-upgrade.conf
+16
View File
@@ -0,0 +1,16 @@
**/node_modules
**/.astro
**/dist
**/.env
**/.env.*
!**/.env.example
app/data.db
app/data.db-shm
app/data.db-wal
app/uploads
.git
.claude
.ddev
docs
deploy
*.md
+45
View File
@@ -0,0 +1,45 @@
# Dependencies
node_modules/
# Build output
**/dist/
**/.astro/
**/build/
# Astro generated types
**/emdash-env.d.ts
# Environment
.env
.env.*
!.env.example
!**/.env.example
# Local DB / uploads (emdash data)
**/data.db
**/data.db-shm
**/data.db-wal
**/uploads/
# DDEV
.ddev/.tarballs/
.ddev/db_snapshots/
.ddev/.global_commands/
.ddev/.imported-assets/
.ddev/import-db/
.ddev/import.yaml
.ddev/.bgsync*
.ddev/.ddev-docker-compose-base.yaml
.ddev/.ddev-docker-compose-full.yaml
.ddev/.bgsync-config.yaml
# Logs / OS / editor
*.log
npm-debug.log*
.DS_Store
.idea/
.vscode/
*.swp
# patch-package
patches/*.patch.bak
+8
View File
@@ -0,0 +1,8 @@
{
"default": true,
"MD013": false,
"MD032": false,
"MD033": false,
"MD040": false,
"MD060": false
}
+96
View File
@@ -0,0 +1,96 @@
# Build + push the cms-plugins container image.
# Triggered on push to develop / staging / production. Each push publishes
# three refs: 0.1.<pipeline> (immutable, audit), <branch> (the floating
# pointer Flux's ImagePolicy tracks → digest rewritten into the fleet
# repo → pod rolls), and <branch>-latest (same image; chart image.tag
# fallback). Only staging/production have an ImagePolicy, so only those
# move pods.
labels:
# kotkan (the deploy target) is an arm64 host, so we build natively on
# arm64 — no cross-compile needed.
arch: arm64
when:
- event: push
branch: [develop, staging, production]
clone:
- name: clone
image: woodpeckerci/plugin-git
environment:
CI_NETRC_MACHINE: git.oleks.space
CI_NETRC_USERNAME: oleks
CI_NETRC_PASSWORD:
from_secret: gitea_clone_token
PLUGIN_TAGS: "false"
PLUGIN_DEPTH: "1"
steps:
- name: build-and-push
image: git.oleks.space/oleks/nix-ci:latest-arm64
environment:
REGISTRY_TOKEN:
from_secret: registry_token
commands:
- BRANCH="$CI_COMMIT_BRANCH"
- SHA=$(echo "$CI_COMMIT_SHA" | cut -c1-12)
# Semver-shaped immutable tag, one per build. First two components
# stay 0.1 (no real semver discipline yet); patch is the pipeline
# number, monotonic across the whole repo.
- VERSION="0.1.$CI_PIPELINE_NUMBER"
- IMAGE="git.oleks.space/oleks/cms-plugins"
- 'echo "Building $IMAGE:$VERSION (branch=$BRANCH sha=$SHA linux/arm64)"'
# Wait for the in-cluster buildkit to be reachable (it can be cold).
- |
BUILDER_HOST="buildkit-rootless-arm64.infra.svc.cluster.local"
BUILDER_PORT="1234"
echo "Waiting for buildkit at $BUILDER_HOST:$BUILDER_PORT..."
for i in $(seq 1 30); do
if echo >/dev/tcp/$BUILDER_HOST/$BUILDER_PORT 2>/dev/null; then
echo "Builder ready"; break
fi
[ "$i" -eq 30 ] && echo "Builder not available" && exit 1
sleep 10
done
- echo "$REGISTRY_TOKEN" | docker login git.oleks.space -u oleks --password-stdin
- docker buildx create --name cms-plugins-builder --driver remote "tcp://$BUILDER_HOST:$BUILDER_PORT"
# Tagging scheme — every build pushes three refs:
# $VERSION — semver-shaped, one per build, immutable (audit).
# $BRANCH — floating channel pointer. THIS is what Flux's
# ImagePolicy tracks (filterTags `^staging$` /
# `^production$`, digestReflectionPolicy: Always);
# retagging it onto the new image is what makes
# ImageUpdateAutomation rewrite the pinned digest
# in the workloads repo and roll the pod.
# $BRANCH-latest — same image, kept only so the chart's cosmetic
# `image.tag` fallback (used when image.digest is
# unset) resolves to a real ref.
# All branches publish all three; only staging/production have an
# ImagePolicy, so only those actually move pods.
- |
TAGS="-t $IMAGE:$VERSION -t $IMAGE:$BRANCH -t $IMAGE:$BRANCH-latest"
docker buildx build \
--builder cms-plugins-builder \
--platform linux/arm64 \
$TAGS \
--push \
.
- 'echo "Pushed $IMAGE:$VERSION + floated $IMAGE:$BRANCH and $IMAGE:$BRANCH-latest"'
backend_options:
kubernetes:
nodeSelector:
kubernetes.io/arch: arm64
resources:
requests:
memory: 4Gi
limits:
memory: 4Gi
labels:
commit-branch: "${CI_COMMIT_BRANCH}"
commit-sha: "${CI_COMMIT_SHA}"
pipeline-number: "${CI_PIPELINE_NUMBER}"
+14
View File
@@ -0,0 +1,14 @@
extends: default
rules:
line-length: disable
truthy: disable
document-start: disable
comments-indentation: disable
braces: disable
brackets: disable
indentation: disable
ignore: |
deploy/helm/templates/
**/secrets.enc.yaml
+128
View File
@@ -0,0 +1,128 @@
# Architecture
This file is the contract between the build (Dockerfile, Woodpecker, Helm chart)
and the FluxCD reconciliation in `anton-helm-workloads`. See `DEPLOYMENT.md` for
the operational walkthrough.
## Repo shape
```
cms-plugins/
├── app/ ← Emdash/Astro source
│ ├── astro.config.mjs ← node target, sqlite() + local()
│ ├── package.json
│ ├── src/ ← pages, layouts, components
│ └── seed/seed.json ← collections + kotkanagrilli plugin entries
├── Dockerfile ← multi-stage build, single PVC at /app/state
├── docker/entrypoint.sh ← runs `emdash init` on first boot
├── deploy/
│ ├── helm/ ← chart consumed by Flux directly from this repo
│ └── fleet-overlay/ ← HelmRelease/source/image-automation templates
│ ├── cms-plugins-staging/ ← copy → anton-helm-workloads/cms-plugins-staging/
│ └── cms-plugins-production/ ← copy → anton-helm-workloads/cms-plugins-production/
├── .woodpecker/container.yaml ← build pipeline
└── .ddev/ ← local dev (DDEV nginx → emdash service on :4321)
```
## Build pipeline
A push to `develop` / `staging` / `production` runs
`.woodpecker/container.yaml`. The pipeline labels itself `arch: arm64`
because the deploy target (`kotkan`) is an arm64 node, so building
natively avoids a cross-compile step.
For each build the pipeline pushes three OCI tags to
`git.oleks.space/oleks/cms-plugins`:
| Tag | Mutability | Purpose |
|---|---|---|
| `0.1.<pipeline>` | Immutable | Audit trail. Use as a rollback target. |
| `<branch>` | Floating | Watched by FluxCD's `ImagePolicy`. Retagged on every build. |
| `<branch>-latest` | Floating | Cosmetic. Resolves the chart's `image.tag` fallback to a real ref when the digest path is unset. |
Only the `staging` and `production` branches have an `ImagePolicy`, so
only those move pods.
## Container shape
Multi-stage Dockerfile:
- `deps``node:22-bookworm-slim` + `python3 make g++` (better-sqlite3
build deps). Falls back to `npm install` if there's no committed lockfile.
- `build``npm run build`.
- `runtime``node:22-bookworm-slim` + `tini`. Runs as uid 1001. Persistent
state symlinked from `/app/data.db` and `/app/uploads``/app/state/...`
so the one PVC mounted at `/app/state` covers both SQLite and uploaded
media.
Entrypoint runs `emdash init` on first boot (idempotent), then `exec`s
the Astro node server on port 4321.
## Helm chart
`deploy/helm/` ships with the app. The chart is **not** packaged or pushed
to an OCI registry — FluxCD's `GitRepository` source pulls it directly from
the matching branch of this repo. The `ignore` rule in `source.yaml`
restricts reconciliation to `/deploy/helm`, so app-source pushes don't
trigger chart re-reconcile.
Per-env values overlay `values.yaml` via `values-staging.yaml` /
`values-production.yaml` (for `helm upgrade` direct use) and via the
`values:` block in the FluxCD `HelmRelease`.
Key shape decisions:
- **`replicas: 1`, `strategy: Recreate`.** SQLite is single-writer.
- **`nodeSelector: kotkan`.** `local-path` PV is sticky to one node; emdash
is colocated with the legacy kotkanagrilli WP install.
- **Pinned by digest, not tag.** The HelmRelease sets
`values.image.digest: "<sha256:...>" # {"$imagepolicy": ...}` so that
`helm upgrade` detects a change when the floating tag is reassigned.
Without digest pinning, `staging` stays the literal string `"staging"`
through every build and Helm sees no spec change.
- **Single PVC, `helm.sh/resource-policy: keep`.** Losing the PVC means
losing all CMS data — uninstalls do not delete it.
## Flux contract
Two repos cooperate:
- **cms-plugins** (this one) exposes the chart at `deploy/helm/` on each
branch.
- **anton-helm-workloads** runs the show: `GitRepository` points at the
matching branch of cms-plugins, `HelmRelease` references
`chart: ./deploy/helm`, `ImagePolicy` + `ImageUpdateAutomation` watch
the floating tag and rewrite the digest setter back into
`helmrelease.yaml`.
For the workloads repo to write digest commits, a `GitRepository` named
`anton-workloads-image-automation` in `flux-system` namespace must exist
with write access to `anton-helm-workloads:main`. The emdash-kotkanagrilli
analogue is `oleks-fleet-image-automation` in the personal fleet repo.
## Data model
Three Emdash collections (`app/seed/seed.json`):
- **cmses** — CMS platforms. Seeded with WordPress + Emdash.
- **plugins** — one entry per plugin. Fields: title, purpose, source_cms,
target_cms, category, status (`port`/`built-in`/`saas`/`drop`/`gated`/`done`/`proposed`),
source_repo_url, target_repo_url, notes (portableText).
- **pages** — static content (About, Submit, etc.).
The seeded plugin entries come from
`~/projects/emdash.kotkanagrilli.fi/docs/parity.md` and the
`mu-plugins/` / `plugins-custom/` directories in
`~/projects/kotkanagrilli.fi/`. New entries are added through the Emdash
admin (`/_emdash/admin`).
## What this does NOT solve
- **Cloudflare Workers target.** The kotkanagrilli sister project has
Cloudflare boundary files (`src/worker.ts`, `wrangler.jsonc`) for a
later flip; this repo skips them. They can be added back from the
emdash-kotkanagrilli pattern when needed.
- **Authentication on the admin.** Emdash's default passkey auth runs;
the first visit to `/_emdash/admin` redirects to `/setup` and creates
the first admin.
- **Multi-language.** No i18n — the catalog is English-only by default.
+92
View File
@@ -0,0 +1,92 @@
# CLAUDE.md
Guidance for Claude Code agents working in this repository.
## What this repo is
A browseable catalog of CMS plugins built on [Emdash](https://github.com/emdash-cms/emdash).
Phase 0 — scaffold + chart + pipeline are in place, no live deploy yet.
Seeded with the WordPress → Emdash plugin parity matrix from
`~/projects/kotkanagrilli.fi/` and `~/projects/emdash.kotkanagrilli.fi/docs/parity.md`.
## Linked repos
- `~/projects/kotkanagrilli.fi/` — legacy WordPress site. **Do not edit
from here.** Source of the seeded plugin entries.
- `~/projects/emdash.kotkanagrilli.fi/` — Emdash replacement for that site.
The Dockerfile, Helm chart, Woodpecker pipeline, and DDEV setup in this
repo are deliberate copies of that one. Treat it as the reference
implementation; deviations should be justified.
- `~/projects/servers/anton/anton-helm-workloads/` — where the FluxCD
`HelmRelease`s for `cms-plugins-{staging,production}.kotkanagrilli.fi`
live. `deploy/fleet-overlay/` in this repo is the template set to copy
into that repo. **Do not commit to anton-helm-workloads without
explicit confirmation** — secrets need sops-encryption with the right
age recipients.
- `~/projects/servers/fleet/` — personal fleet repo. Owns
`emdash-kotkanagrilli-{staging,production}` but NOT cms-plugins.
## Layout
- `app/` — Emdash scaffold (node target, no i18n, no Cloudflare boundary).
Three collections in `seed/seed.json`: `cmses`, `plugins`, `pages`.
- `Dockerfile` + `docker/entrypoint.sh` — production image.
- `deploy/helm/` — the chart Flux pulls from `./deploy/helm` on the
matching branch.
- `deploy/fleet-overlay/cms-plugins-{staging,production}/` — HelmRelease
+ GitRepository + image-automation + secrets templates ready to drop
into `anton-helm-workloads`.
- `.woodpecker/container.yaml` — build pipeline (arm64; deploy target is
`kotkan`).
- `.ddev/` — local dev.
- `DEPLOYMENT.md` — full pipeline walkthrough.
- `ARCHITECTURE.md` — chart / image / Flux contracts.
## Common commands
```bash
# Local dev
ddev start # https://cms-plugins.ddev.site/
# or, without DDEV:
cd app && npm install && npm run bootstrap && npx emdash dev
# Build production image
docker build -t cms-plugins:dev .
# Typecheck
cd app && npm run typecheck
```
## Architectural constraints to respect
- **One repo, one app, node target.** No Cloudflare boundary files yet;
adding them is a deliberate Phase-N call, not casual work.
- **SQLite single-writer.** One replica, pinned to `kotkan`, `local-path`
PVC. No StatefulSet, no horizontal scale.
- **Chart pulled directly from git by Flux** — no `helm push` step. Chart
changes ship in the same commit as the code that needs them.
- **Image is pinned by digest in the HelmRelease.** `ImageUpdateAutomation`
rewrites the digest setter; `helm upgrade` only sees a change because
of that. The floating `<branch>` tag alone wouldn't roll the pod.
- **The legacy WP site keeps running.** This repo doesn't migrate
kotkanagrilli.fi — it's a catalog ABOUT plugins, not the site itself.
## Emdash gotchas (from the kotkanagrilli reference)
- All content pages must be server-rendered (`output: "server"`); no
`getStaticPaths()` for CMS content.
- `entry.id` is the slug (URLs); `entry.data.id` is the database ULID
(used for API calls / cross-collection refs).
- Image fields are `{ src, alt }` objects, not strings.
- Always `Astro.cache.set(cacheHint)` on pages that query content.
- Taxonomy names in queries match `seed.json`'s `name` field exactly.
## What NOT to do
- Don't `npm install` random kotkanagrilli plugins (the e-commerce ones).
This is a catalog, not a store.
- Don't push to `anton-helm-workloads` without explicit confirmation —
unencrypted secrets would leak.
- Don't change branch promotion semantics (fast-forward only across
`develop``staging``production`). Mirroring emdash-kotkanagrilli's
flow is intentional.
+257
View File
@@ -0,0 +1,257 @@
# Deployment
How a code change in this repo becomes a running pod at
`cms-plugins-{staging,production}.kotkanagrilli.fi`.
## Repos involved
| Repo | Lives at | Owns |
|---|---|---|
| **cms-plugins** (this one) | `git.oleks.space/oleks/cms-plugins` | App source, Dockerfile, Helm chart, Woodpecker pipeline |
| **anton-helm-workloads** | `git.oleks.space/anton/helm-workloads` | The FluxCD `HelmRelease`s for each subdomain on `kotkanagrilli.fi`. The legacy WP `kotkanagrilli/` and the dev `hello-kotkan/` releases also live here. |
| Gitea OCI registry | `git.oleks.space/oleks/cms-plugins` (image) | Container images pushed by CI |
The `cms-plugins` repo and `anton-helm-workloads` repo are linked by:
1. **The Helm chart path.** The HelmRelease in `anton-helm-workloads`
references `chart: ./deploy/helm` from a `GitRepository` source that
points back at the `staging` or `production` branch of `cms-plugins`.
Flux pulls the chart directly from this repo — there is no
"publish chart" step in CI.
2. **The image policy.** A FluxCD `ImagePolicy` in `anton-helm-workloads`
watches the `staging` / `production` floating tag in the Gitea OCI
registry and rewrites the resolved digest into `helmrelease.yaml`,
which is what makes `helm upgrade` see a change when CI retags.
## Branch → environment
Three branches, three environments. Promotion is fast-forward only — no PR
ceremony.
| Branch | Environment | URL | Where it runs |
|---|---|---|---|
| `develop` | dev | `https://cms-plugins.ddev.site/` | Local DDEV |
| `staging` | staging | `https://cms-plugins-staging.kotkanagrilli.fi/` | k3s on `kotkan` |
| `production` | production | `https://cms-plugins-production.kotkanagrilli.fi/` | k3s on `kotkan` |
## What happens on a push
A push to `staging` or `production` triggers `.woodpecker/container.yaml`:
1. Spins up a remote buildkit at `buildkit-rootless-arm64.infra.svc.cluster.local:1234`.
2. Builds the root `Dockerfile`.
3. Publishes **three refs** for each build:
- `git.oleks.space/oleks/cms-plugins:0.1.<pipeline>` — immutable audit tag.
- `git.oleks.space/oleks/cms-plugins:<branch>` — floating channel pointer; this is what FluxCD's `ImagePolicy` watches.
- `git.oleks.space/oleks/cms-plugins:<branch>-latest` — cosmetic; falls back to a real ref if the chart's `image.tag` is consulted (which the digest-pinning path normally bypasses).
FluxCD then takes over (it polls every minute):
1. The `ImagePolicy` in `anton-helm-workloads/cms-plugins-<env>/image-automation.yaml` resolves the new digest behind the `<branch>` tag.
2. `ImageUpdateAutomation` writes that digest back into `helmrelease.yaml` and commits/pushes to `anton-helm-workloads:main`.
3. The `GitRepository` in `anton-helm-workloads/cms-plugins-<env>/source.yaml` syncs the `cms-plugins` branch and exposes `deploy/helm/`.
4. The `HelmRelease` reconciles: `helm upgrade` sees a new digest in `values.image.digest` and rolls the Deployment.
End-to-end deploy time: ≈4 min (build ≈3 min + Flux interval ≈1 min).
## Local development
```bash
cd ~/projects/cms-plugins
ddev start
# → https://cms-plugins.ddev.site/ (admin at /_emdash/admin)
```
This is **not** a prod-parity smoke test — DDEV runs `npx emdash dev` with
hot reload; production runs the built `node ./dist/server/entry.mjs`. The
shared artifact is the Astro source under `app/`. To smoke-test the
production image standalone:
```bash
docker build -t cms-plugins:dev .
docker run --rm -p 4321:4321 \
-e EMDASH_ENCRYPTION_KEY=$(openssl rand -hex 32) \
-v cms-plugins-state:/app/state \
cms-plugins:dev
```
## Promoting develop → staging
```bash
git switch staging
git merge --ff-only develop
git push origin staging
```
The Woodpecker pipeline triggers automatically. Watch it at the Woodpecker
UI; once green, Flux reconciles within ≤1 min.
## Promoting staging → production
After staging looks good:
```bash
git switch production
git merge --ff-only staging
git push origin production
```
If the fast-forward fails, something is out of order — investigate before
forcing.
## Rolling back
The chart pins by **digest** (set by FluxCD's
`ImageUpdateAutomation`), with `<branch>-latest` as a fallback tag. To
roll back:
1. Identify the previous good build's immutable tag from the Gitea
registry — it is `0.1.<N>` where `<N>` is the pipeline number.
2. In `anton-helm-workloads/cms-plugins-<env>/helmrelease.yaml`, hard-code
the previous digest under `values.image.digest` and remove the
`{"$imagepolicy": ...}` marker (so IUA stops overwriting it). Commit
and push.
3. Flux reconciles in ≤1 min.
To resume tracking the floating tag, restore the marker and the
auto-update resumes.
## Secrets
All secrets are sops-encrypted in `anton-helm-workloads`:
- `anton-helm-workloads/cms-plugins-staging/secrets.yaml`
- `anton-helm-workloads/cms-plugins-production/secrets.yaml`
Each env has two distinct Secrets:
| Secret | Namespace | Purpose |
|---|---|---|
| `cms-plugins-deploy-key` | `flux-system` | SSH deploy key for Flux to clone `cms-plugins` (one pair shared between staging + production — same key reads both branches). |
| `cms-plugins-<env>-secrets` | `kotkan` | Env vars the pod consumes via `existingSecret`. Required key: `EMDASH_ENCRYPTION_KEY`. |
To rotate a credential:
```bash
cd ~/projects/servers/anton/anton-helm-workloads/cms-plugins-staging
sops secrets.yaml # opens decrypted in $EDITOR
git add . && git commit -m "rotate cms-plugins staging credential"
git push # Flux re-applies in ≤1 min
```
The Deployment does NOT restart on Secret change (k8s `envFrom` doesn't
watch). Force a roll after rotating:
```bash
kubectl -n kotkan rollout restart deployment/cms-plugins-staging
```
## First-time setup checklist
This is the once-per-environment dance to bring staging or production
online.
### A. In this (cms-plugins) repo
1. Push to `git.oleks.space/oleks/cms-plugins` with three branches:
`develop`, `staging`, `production`. The initial commit lives on
`develop`; the other two are fast-forward copies.
2. Add the Woodpecker secrets at Repo → Settings → Secrets in the
Woodpecker UI (`ci.oleks.space`), scoped to event `push`:
| Secret | Purpose |
|---|---|
| `gitea_clone_token` | Gitea PAT for cloning during the build step. |
| `registry_token` | Gitea PAT with write access to `git.oleks.space/oleks/cms-plugins` (container packages). |
### B. In the anton-helm-workloads repo
1. Generate an SSH deploy key (one pair, shared across envs):
```bash
ssh-keygen -t ed25519 -f /tmp/cms-plugins-deploy -N ""
```
2. Upload `/tmp/cms-plugins-deploy.pub` to the `cms-plugins` repo on
Gitea: Settings → Deploy Keys → "cms-plugins Flux deploy",
**read-only**.
3. Generate one `EMDASH_ENCRYPTION_KEY` per env (do NOT reuse):
```bash
openssl rand -hex 32 # staging
openssl rand -hex 32 # production
```
4. Copy this repo's `deploy/fleet-overlay/cms-plugins-staging/` and
`cms-plugins-production/` directories into the root of
`anton-helm-workloads`, then edit each `secrets.yaml` to put the
real key material in, and sops-encrypt:
```bash
cd anton-helm-workloads/cms-plugins-staging
sops --encrypt --age <recipient> secrets.yaml > secrets.enc.yaml
mv secrets.enc.yaml secrets.yaml
```
5. Add both directories to `anton-helm-workloads/kustomization.yaml`:
```yaml
resources:
- hello-kotkan
- kotkanagrilli
- max-openclaw
- terminal-agent
- cms-plugins-staging # ← add
- cms-plugins-production # ← add
```
6. Ensure a writable `GitRepository` named
`anton-workloads-image-automation` exists in `flux-system` for the
`ImageUpdateAutomation` to push digest commits to
`anton-helm-workloads:main`. If it doesn't exist yet, create it
(this is a one-time setup for the whole workloads repo). The
`emdash-kotkanagrilli` analogue is `oleks-fleet-image-automation`.
7. Commit and push `anton-helm-workloads`. Flux picks it up in ≤1 min.
### C. DNS
```bash
dig +short cms-plugins-staging.kotkanagrilli.fi
dig +short cms-plugins-production.kotkanagrilli.fi
```
Both should resolve to the `kotkan` ingress IP (the same one
`emdash-staging.kotkanagrilli.fi` already uses).
### D. First deploy
1. Push to `staging` to trigger the first build. Watch Woodpecker.
2. Once the image lands, Flux reconciles. Watch the rollout:
```bash
kubectl -n kotkan get pods -w | grep cms-plugins
flux get all -n flux-system | grep cms-plugins
```
3. Once `cms-plugins-staging.kotkanagrilli.fi` returns a 200, fast-forward
`production` to `staging` and push.
## Why this shape
- **Single replica, pinned to `kotkan`.** Emdash uses local SQLite
(single-writer); horizontal scaling isn't a thing here. The PVC is
`local-path`, so the pod can only run where the volume lives.
- **Mutable `<branch>` tag.** The sha-tagged ref (`0.1.<pipeline>`) gives
audit trail; the mutable `<branch>` tag is what Flux watches. Together
they give visibility without forcing an `anton-helm-workloads` commit
per deploy (the `ImageUpdateAutomation` does that automatically).
- **Flux pulls the chart from this repo (no OCI step).** Keeps the chart
in the same git history as the app code; a chart change rolls with the
same release.
- **HelmRelease lives in `anton-helm-workloads`, not in fleet.** Matches
the convention that the kotkanagrilli.fi subdomain pool (legacy
`kotkanagrilli/`, `hello-kotkan/`, future subdomains) is reconciled
out of the workloads repo, not the personal fleet repo.
- **kotkan, not armer.** The legacy `kotkanagrilli.fi` site and the
emdash replacement both live on `kotkan`; colocating reduces DNS /
ingress drift while the WP → Emdash story plays out.
+49
View File
@@ -0,0 +1,49 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY app/package.json app/package-lock.json* ./
# package-lock.json may not exist on the first commit — fall back to `npm install`
# so the image still builds; once a lockfile is committed, npm ci kicks in.
RUN if [ -f package-lock.json ]; then npm ci --include=dev; else npm install --include=dev; fi
FROM deps AS build
WORKDIR /app
COPY app/ ./
RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4321
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1001 --home /app emdash \
&& mkdir -p /app/state/uploads \
&& chown -R emdash:emdash /app
COPY --from=build --chown=emdash:emdash /app/package.json ./
COPY --from=build --chown=emdash:emdash /app/node_modules ./node_modules
COPY --from=build --chown=emdash:emdash /app/dist ./dist
COPY --from=build --chown=emdash:emdash /app/seed ./seed
# Persistent state lives in /app/state (single PVC in k3s).
# data.db and uploads/ are symlinked from the working directory so the
# default emdash paths (./data.db, ./uploads) resolve into the volume.
RUN ln -s /app/state/data.db /app/data.db \
&& ln -s /app/state/uploads /app/uploads
COPY --chown=emdash:emdash docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER emdash
EXPOSE 4321
VOLUME ["/app/state"]
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["node", "./dist/server/entry.mjs"]
+80
View File
@@ -0,0 +1,80 @@
# cms-plugins
A browseable catalog of CMS plugins and how they map across platforms,
built on [Emdash](https://github.com/emdash-cms/emdash) (Astro 6 +
TypeScript + SQLite). The seed dataset is the WordPress → Emdash plugin
parity matrix from `kotkanagrilli.fi`; new entries can be added through
the Emdash admin.
| Repo | URL | Role |
|---|---|---|
| **cms-plugins** (this repo) | `git@git.oleks.space:oleks/cms-plugins.git` | App, Dockerfile, Helm chart, Woodpecker pipeline |
| **emdash.kotkanagrilli.fi** | `git@git.oleks.space:oleks/emdash-kotkanagrilli.git` | Reference Emdash deployment whose pattern this repo mirrors |
| **kotkanagrilli.fi** (WordPress) | `git@git.oleks.space:oleks/kotkanagrilli.fi.git` (+ `github.com:retif/kotkanagrilli.fi.git`) | Source of the seeded plugin entries (parity matrix) |
| **anton-helm-workloads** | `https://git.oleks.space/anton/helm-workloads` | Houses the FluxCD `HelmRelease`s for each environment (kotkanagrilli.fi subdomains) |
## Status
Phase 0 — Emdash scaffold + Helm chart + Woodpecker pipeline. No content
beyond the seeded WordPress plugin entries. Not yet running anywhere.
## What's in here
- `app/` — Emdash scaffold (Astro 6, node target). Three collections: `cmses`,
`plugins`, `pages`. Seed at `app/seed/seed.json`.
- `Dockerfile` + `docker/entrypoint.sh` — production image. Single PVC at
`/app/state` holds `data.db` and `uploads/`.
- `deploy/helm/` — the Helm chart that runs the pod in k3s. Same shape as
`~/projects/emdash.kotkanagrilli.fi/deploy/helm/`.
- `deploy/fleet-overlay/``HelmRelease` + `GitRepository` + image-automation
templates ready to drop into the `anton-helm-workloads` repo. Not consumed
from this repo directly.
- `.woodpecker/container.yaml` — build + push pipeline (develop / staging /
production branches).
- `.ddev/` — DDEV setup for local development (`https://cms-plugins.ddev.site/`).
- `DEPLOYMENT.md` — full pipeline from `git push` to a running pod, plus
the first-time setup checklist.
- `ARCHITECTURE.md` — chart / image / Flux contracts.
- `docs/` — additional notes.
## Quickstart (local)
```bash
cd ~/projects/cms-plugins
ddev start
# → https://cms-plugins.ddev.site/ (admin at /_emdash/admin)
```
Or without DDEV:
```bash
cd app
npm install
npm run bootstrap # emdash init: migrations + seed
npx emdash dev # http://localhost:4321/
```
Reset to a blank state:
```bash
rm -f app/data.db app/data.db-shm app/data.db-wal
rm -rf app/uploads
```
## Deploying
Push to the matching branch:
| Branch | Environment | URL |
|---|---|---|
| `develop` | dev (local DDEV only) | `https://cms-plugins.ddev.site/` |
| `staging` | staging | `https://cms-plugins-staging.kotkanagrilli.fi/` |
| `production` | production | `https://cms-plugins-production.kotkanagrilli.fi/` |
Woodpecker builds + pushes the image to `git.oleks.space/oleks/cms-plugins`,
FluxCD reconciles the chart from `deploy/helm/` against the matching
`HelmRelease` in `anton-helm-workloads/cms-plugins-{staging,production}/`.
See **[DEPLOYMENT.md](./DEPLOYMENT.md)** for the full flow, the relationship
between this repo and `anton-helm-workloads`, the first-time setup
checklist, and how rollbacks work.
+43
View File
@@ -0,0 +1,43 @@
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig, memoryCache } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
// Persistent state directory. Defaults to the working directory for dev/DDEV
// (so data.db + uploads/ stay where you'd expect them). The k8s deploy sets
// STATE_DIR=/app/state and mounts a PVC there so SQLite + uploads survive
// pod replacement.
const stateDir = process.env.STATE_DIR ?? ".";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
trailingSlash: "ignore",
server: { host: true, port: 4321 },
vite: {
server: {
// Dev runs behind DDEV's nginx (https://cms-plugins.ddev.site/).
// Vite's host check must allow the public hostname.
allowedHosts: ["cms-plugins.ddev.site", ".ddev.site"],
},
},
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: sqlite({ url: `file:${stateDir}/data.db` }),
storage: local({
directory: `${stateDir}/uploads`,
baseUrl: "/_emdash/api/media/file",
}),
}),
],
experimental: {
cache: { provider: memoryCache() },
},
devToolbar: { enabled: false },
});
+36
View File
@@ -0,0 +1,36 @@
{
"name": "cms-plugins-app",
"version": "0.1.0",
"private": true,
"type": "module",
"emdash": {
"label": "CMS plugins catalog",
"seed": "seed/seed.json"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"bootstrap": "emdash init",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/node": "^10.0.5",
"@astrojs/react": "^5.0.0",
"astro": "~6.2.2",
"better-sqlite3": "^12.8.0",
"emdash": "^0.10.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.7"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}
}
+572
View File
@@ -0,0 +1,572 @@
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "CMS plugins catalog",
"description": "WordPress → Emdash plugin parity catalog, seeded with kotkanagrilli.fi entries",
"author": "oleks"
},
"settings": {
"title": "CMS plugins catalog",
"tagline": "WordPress → Emdash plugin parity, one entry at a time"
},
"collections": [
{
"slug": "cmses",
"label": "CMS platforms",
"labelSingular": "CMS platform",
"urlPattern": "/cms/{slug}",
"supports": ["search", "seo"],
"fields": [
{ "slug": "title", "label": "Name", "type": "string", "required": true, "searchable": true },
{ "slug": "website", "label": "Website URL", "type": "string" },
{ "slug": "description", "label": "Description", "type": "text", "searchable": true }
]
},
{
"slug": "plugins",
"label": "Plugins",
"labelSingular": "Plugin",
"urlPattern": "/plugins/{slug}",
"supports": ["drafts", "search", "seo"],
"fields": [
{ "slug": "title", "label": "Plugin name", "type": "string", "required": true, "searchable": true },
{ "slug": "purpose", "label": "Purpose", "type": "text", "searchable": true,
"description": "One-line description of what the plugin does." },
{ "slug": "source_cms", "label": "Source CMS", "type": "string", "required": true, "searchable": true,
"description": "Name of the CMS this plugin runs on today (e.g. WordPress)." },
{ "slug": "target_cms", "label": "Target CMS", "type": "string", "searchable": true,
"description": "Name of the CMS we're porting to, if applicable." },
{ "slug": "category", "label": "Category", "type": "string",
"description": "e-commerce, SEO, content, performance, etc." },
{
"slug": "status",
"label": "Migration status",
"type": "select",
"required": true,
"defaultValue": "proposed",
"searchable": true,
"options": [
{ "value": "port", "label": "Port — reimplement on target" },
{ "value": "built-in", "label": "Built-in — covered by target core" },
{ "value": "saas", "label": "SaaS — replaced by external service" },
{ "value": "drop", "label": "Drop — not needed" },
{ "value": "gated", "label": "Gated — pending decision" },
{ "value": "done", "label": "Done — ported and shipped" },
{ "value": "proposed", "label": "Proposed — newly submitted" }
]
},
{ "slug": "source_repo_url", "label": "Source repo URL", "type": "string" },
{ "slug": "target_repo_url", "label": "Target repo URL", "type": "string" },
{ "slug": "notes", "label": "Migration notes", "type": "portableText", "searchable": true }
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"urlPattern": "/{slug}",
"supports": ["drafts", "revisions", "search", "seo"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" }
]
}
],
"content": {
"cmses": [
{
"slug": "wordpress",
"data": {
"title": "WordPress",
"website": "https://wordpress.org",
"description": "PHP/MariaDB CMS with the largest plugin ecosystem. Source platform for the kotkanagrilli.fi migration."
}
},
{
"slug": "emdash",
"data": {
"title": "Emdash",
"website": "https://github.com/emdash-cms/emdash",
"description": "TypeScript/Astro/SQLite CMS positioned as a spiritual successor to WordPress. Target platform for the kotkanagrilli.fi migration."
}
}
],
"pages": [
{
"slug": "about",
"data": {
"title": "About this catalog",
"excerpt": "What this catalog is and how it's organized.",
"content": [
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "This catalog tracks plugins from one CMS and how they map to another. The seeded entries come from the kotkanagrilli.fi WordPress → Emdash migration, where ~30 third-party and custom plugins needed to be classified before the rebuild." }] },
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Each entry has a migration status (port, built-in, saas, drop, gated, done, proposed) and free-form notes. New entries can be added through the Emdash admin at /_emdash/admin." }] }
]
}
}
],
"plugins": [
{
"slug": "woocommerce",
"data": {
"title": "WooCommerce",
"purpose": "E-commerce / order management for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated",
"source_repo_url": "https://wordpress.org/plugins/woocommerce/",
"notes": [
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Fate depends on the kotkanagrilli WooCommerce decision gate: (a) keep WP on a subdomain for ordering, (b) build an Emdash orders+SumUp plugin, (c) outsource to an ordering SaaS." }] }
]
}
},
{
"slug": "woocommerce-payments",
"data": {
"title": "WooCommerce Payments",
"purpose": "WC native payment gateway.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Tied to the WooCommerce decision." }] }]
}
},
{
"slug": "sumup-payment-gateway-for-woocommerce",
"data": {
"title": "SumUp Payment Gateway for WooCommerce",
"purpose": "SumUp checkout integration for WC.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Tied to WC decision. SumUp REST API is callable directly from a custom Emdash plugin if option (b) is chosen." }] }]
}
},
{
"slug": "fluid-checkout",
"data": {
"title": "Fluid Checkout",
"purpose": "Improved WooCommerce checkout UX.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "woo-checkout-field-editor-pro",
"data": {
"title": "Woo Checkout Field Editor Pro",
"purpose": "Custom checkout fields for WooCommerce.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "woocommerce-customizer",
"data": {
"title": "WooCommerce Customizer",
"purpose": "Miscellaneous WooCommerce tweaks.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "woc-open-close",
"data": {
"title": "WooCommerce Open/Close",
"purpose": "Show/hide the store as open or closed.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Reimplement as a small Emdash plugin or Astro middleware reading a business-hours config. Not actually WC-coupled despite the name." }] }]
}
},
{
"slug": "google-listings-and-ads",
"data": {
"title": "Google Listings & Ads",
"purpose": "Merchant Center feed for WooCommerce.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "marketing",
"status": "gated"
}
},
{
"slug": "polylang",
"data": {
"title": "Polylang",
"purpose": "FI/EN translation for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "i18n",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Native Emdash collections + locale routing in Astro (src/pages/[lang]/...) replaces Polylang." }] }]
}
},
{
"slug": "connect-polylang-elementor",
"data": {
"title": "Connect Polylang for Elementor",
"purpose": "Glue between Polylang and Elementor.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "i18n",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with Elementor." }] }]
}
},
{
"slug": "elementor",
"data": {
"title": "Elementor",
"purpose": "Visual page builder for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "page-builder",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Rebuild pages as Astro components / Emdash portable-text blocks." }] }]
}
},
{
"slug": "cafe-eatery",
"data": {
"title": "Cafe Eatery",
"purpose": "WordPress block theme (parent of the kotkanagrilli child theme).",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "theme",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Rebuild as an Astro theme (Phase 2)." }] }]
}
},
{
"slug": "autoptimize",
"data": {
"title": "Autoptimize",
"purpose": "Asset minification + concatenation.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "performance",
"status": "built-in",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro/Vite handles bundling natively." }] }]
}
},
{
"slug": "jetpack-boost",
"data": {
"title": "Jetpack Boost",
"purpose": "Performance hints for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "performance",
"status": "built-in",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro + Cloudflare handle this." }] }]
}
},
{
"slug": "ewww-image-optimizer",
"data": {
"title": "EWWW Image Optimizer",
"purpose": "Image optimization for WordPress media.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "performance",
"status": "built-in",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro Image integration + responsive styles already wired in astro.config.mjs." }] }]
}
},
{
"slug": "jetpack",
"data": {
"title": "Jetpack",
"purpose": "Stats, hardening, related-posts, and more.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "ops",
"status": "saas",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Replaced by Cloudflare analytics + WAF." }] }]
}
},
{
"slug": "redis-cache",
"data": {
"title": "Redis Object Cache",
"purpose": "PHP object cache for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "performance",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "No PHP, no need; Astro + Emdash memoryCache provider already wired." }] }]
}
},
{
"slug": "wp-mail-smtp",
"data": {
"title": "WP Mail SMTP",
"purpose": "Outbound mail via external SMTP.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "mail",
"status": "saas",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "External SMTP configured at the platform layer, or via an Emdash mailer plugin." }] }]
}
},
{
"slug": "cb-change-mail-sender",
"data": {
"title": "CB Change Mail Sender",
"purpose": "Override the sender address on outgoing mail.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "mail",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Handled by the same Emdash mailer plugin that replaces wp-mail-smtp." }] }]
}
},
{
"slug": "wpforms-lite",
"data": {
"title": "WPForms Lite",
"purpose": "Contact form builder.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "forms",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Small Astro form action posting to a server endpoint, or a Cloudflare Worker on the CF target." }] }]
}
},
{
"slug": "wp-google-maps",
"data": {
"title": "WP Google Maps",
"purpose": "Map embed for posts/pages.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "content",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static iframe embed in the theme — trivial port." }] }]
}
},
{
"slug": "sticky-chat-widget",
"data": {
"title": "Sticky Chat Widget",
"purpose": "Floating WhatsApp/chat button.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "content",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static component in the theme — trivial port." }] }]
}
},
{
"slug": "shortcodes-ultimate",
"data": {
"title": "Shortcodes Ultimate",
"purpose": "Shortcode library for the WP editor.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "content",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro components / portable-text block types replace it." }] }]
}
},
{
"slug": "tinymce-advanced",
"data": {
"title": "TinyMCE Advanced",
"purpose": "Editor enhancement for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "editor",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Emdash has its own editor." }] }]
}
},
{
"slug": "lara-google-analytics",
"data": {
"title": "Lara Google Analytics",
"purpose": "GA4 tag for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "analytics",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static <script> in the theme head — trivial port." }] }]
}
},
{
"slug": "social-login",
"data": {
"title": "Social Login",
"purpose": "OAuth login for WordPress.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "auth",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Drop unless actually needed; Emdash uses passkeys by default." }] }]
}
},
{
"slug": "twentytwentyfive",
"data": {
"title": "Twenty Twenty-Five",
"purpose": "Default WordPress fallback theme.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "theme",
"status": "drop"
}
},
{
"slug": "store-closed-button",
"data": {
"title": "store-closed-button (mu-plugin)",
"purpose": "Toggle the store between open and closed.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "ops",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Becomes an Emdash plugin: business-hours config + an admin badge. Replaces store.py CLI." }] }]
}
},
{
"slug": "store-override-admin-bar",
"data": {
"title": "store-override-admin-bar (mu-plugin)",
"purpose": "Admin-bar indicator for the open/close override.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "ops",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Folded into the same business-hours Emdash plugin." }] }]
}
},
{
"slug": "nginx-cache-indicator",
"data": {
"title": "nginx-cache-indicator (mu-plugin)",
"purpose": "Visual UX hint for nginx cache hits.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "ops",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Folded into the same admin badge that store-closed-button becomes." }] }]
}
},
{
"slug": "out-of-stock-display",
"data": {
"title": "out-of-stock-display (mu-plugin)",
"purpose": "WC UX fix for out-of-stock items.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "cart-fragment-cache-fix",
"data": {
"title": "cart-fragment-cache-fix (mu-plugin)",
"purpose": "Fix for WC cart fragment caching.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "checkout-button-fix",
"data": {
"title": "checkout-button-fix (mu-plugin)",
"purpose": "WC checkout button UX fix.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "checkout-local-pickup",
"data": {
"title": "checkout-local-pickup (mu-plugin)",
"purpose": "Local-pickup behavior at checkout.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "sumup-payment-verify",
"data": {
"title": "sumup-payment-verify (mu-plugin)",
"purpose": "Manual SumUp payment verification step.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "sumup-webhook-fix",
"data": {
"title": "sumup-webhook-fix (mu-plugin)",
"purpose": "Patch for the SumUp webhook handler.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "e-commerce",
"status": "gated"
}
},
{
"slug": "load-elementor-fonts",
"data": {
"title": "load-elementor-fonts (mu-plugin)",
"purpose": "Force-load Elementor fonts.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "theme",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with Elementor." }] }]
}
},
{
"slug": "remove-theme-ads",
"data": {
"title": "remove-theme-ads (mu-plugin)",
"purpose": "Strip upsell ads from the Cafe Eatery theme.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "theme",
"status": "drop",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with the Cafe Eatery theme." }] }]
}
},
{
"slug": "security-hardening",
"data": {
"title": "security-hardening (mu-plugin)",
"purpose": "Response headers + login lockdown.",
"source_cms": "WordPress",
"target_cms": "Emdash",
"category": "security",
"status": "port",
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Cloudflare WAF rules cover the WAF half; Astro middleware sets response headers for the rest." }] }]
}
}
]
}
}
+26
View File
@@ -0,0 +1,26 @@
---
import StatusBadge from "./StatusBadge.astro";
interface Props {
entry: {
id: string;
data: {
title: string;
purpose?: string | null;
status?: string | null;
source_cms?: string | null;
target_cms?: string | null;
};
};
}
const { entry } = Astro.props;
const d = entry.data;
---
<li class="plugin-card">
<h3><a href={`/plugins/${entry.id}`}>{d.title}</a></h3>
<div class="meta">
<StatusBadge status={d.status} />
{d.source_cms && <span>{d.source_cms} → {d.target_cms ?? "—"}</span>}
</div>
{d.purpose && <p>{d.purpose}</p>}
</li>
+18
View File
@@ -0,0 +1,18 @@
---
interface Props {
status: string | null | undefined;
}
const { status } = Astro.props;
const value = (status ?? "proposed").toLowerCase();
const labels: Record<string, string> = {
port: "Port",
"built-in": "Built-in",
drop: "Drop",
saas: "SaaS",
gated: "Gated",
done: "Done",
proposed: "Proposed",
};
const label = labels[value] ?? value;
---
<span class={`badge badge--${value}`}>{label}</span>
+17
View File
@@ -0,0 +1,17 @@
---
import StatusBadge from "./StatusBadge.astro";
const items = [
{ status: "port", desc: "must be reimplemented on the target CMS" },
{ status: "built-in", desc: "covered by target CMS core / framework" },
{ status: "saas", desc: "replaced by an external service" },
{ status: "drop", desc: "not needed on the target CMS" },
{ status: "gated", desc: "fate depends on an unresolved decision" },
{ status: "done", desc: "ported and shipped" },
{ status: "proposed", desc: "submitted for cataloging" },
];
---
<div class="legend">
{items.map((i) => (
<span class="item"><StatusBadge status={i.status} /> {i.desc}</span>
))}
</div>
+67
View File
@@ -0,0 +1,67 @@
---
import { getSiteSettings } from "emdash";
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import "../styles/global.css";
interface Props {
title: string;
description?: string | null;
content?: { collection: string; id: string; slug?: string | null };
}
const { title, description, content } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title ?? "CMS plugins catalog";
const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity catalog";
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
const pageDescription = description ?? siteTagline;
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: "website",
title: fullTitle,
pageTitle: title,
description: pageDescription,
content,
siteName: siteTitle,
});
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title>
<meta name="description" content={pageDescription} />
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header class="site">
<div class="container row">
<a href="/" class="brand">{siteTitle}</a>
<nav>
<a href="/">Plugins</a>
<a href="/cms">By CMS</a>
<a href="/about">About</a>
</nav>
</div>
</header>
<main>
<div class="container">
<slot />
</div>
</main>
<footer class="site">
<div class="container">
<p>{siteTagline}</p>
<p><a href="/_emdash/admin">Admin</a></p>
</div>
</footer>
<EmDashBodyEnd page={pageCtx} />
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
+7
View File
@@ -0,0 +1,7 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Not found">
<h1>Not found</h1>
<p>The page you're looking for doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
export const prerender = false;
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry, cacheHint } = await getEmDashEntry("pages", slug);
if (!entry) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
---
<Base title={entry.data.title} description={entry.data.excerpt ?? undefined} content={{ collection: "pages", id: entry.data.id, slug: entry.id }}>
<article class="detail">
<h1>{entry.data.title}</h1>
{entry.data.content && <div class="notes"><PortableText value={entry.data.content} /></div>}
</article>
</Base>
+36
View File
@@ -0,0 +1,36 @@
---
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import Base from "../../layouts/Base.astro";
import PluginCard from "../../components/PluginCard.astro";
export const prerender = false;
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry: cms, cacheHint } = await getEmDashEntry("cmses", slug);
if (!cms) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const { entries: all } = await getEmDashCollection("plugins", {
orderBy: { title: "asc" },
limit: 9999,
});
const cmsName = cms.data.title as string;
const plugins = all.filter((p) => p.data.source_cms === cmsName);
---
<Base title={cmsName} description={cms.data.description ?? undefined}>
<p class="crumbs"><a href="/cms">By CMS</a></p>
<h1>{cmsName}</h1>
{cms.data.website && <p><a href={cms.data.website}>{cms.data.website}</a></p>}
{cms.data.description && <p>{cms.data.description}</p>}
<h2 style="margin-top:2rem">Plugins ({plugins.length})</h2>
{plugins.length === 0 ? (
<p class="empty">No plugins cataloged for this CMS yet.</p>
) : (
<ul class="plugin-grid">
{plugins.map((entry) => <PluginCard entry={entry} />)}
</ul>
)}
</Base>
+30
View File
@@ -0,0 +1,30 @@
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
export const prerender = false;
const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
orderBy: { title: "asc" },
});
Astro.cache.set(cacheHint);
const { entries: plugins } = await getEmDashCollection("plugins", { limit: 9999 });
const countBySource = new Map<string, number>();
for (const p of plugins) {
const s = p.data.source_cms;
if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1);
}
---
<Base title="By CMS" description="Browse plugins grouped by source CMS.">
<h1>By CMS</h1>
<ul class="plugin-grid">
{cmses.map((c) => (
<li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<p class="meta">{countBySource.get(c.data.title) ?? 0} plugins</p>
{c.data.description && <p>{c.data.description}</p>}
</li>
))}
</ul>
</Base>
+61
View File
@@ -0,0 +1,61 @@
---
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PluginCard from "../components/PluginCard.astro";
import StatusLegend from "../components/StatusLegend.astro";
export const prerender = false;
const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", {
orderBy: { title: "asc" },
});
Astro.cache.set(cacheHint);
const url = new URL(Astro.request.url);
const q = (url.searchParams.get("q") ?? "").toLowerCase().trim();
const statusFilter = url.searchParams.get("status") ?? "";
const sourceFilter = url.searchParams.get("source") ?? "";
const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort();
const statuses = ["port", "built-in", "saas", "drop", "gated", "done", "proposed"];
const filtered = plugins.filter((p) => {
const d = p.data;
if (q && !(`${d.title} ${d.purpose ?? ""}`.toLowerCase().includes(q))) return false;
if (statusFilter && d.status !== statusFilter) return false;
if (sourceFilter && d.source_cms !== sourceFilter) return false;
return true;
});
---
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
<h1>Plugins</h1>
<p>{plugins.length} entries cataloged. Filter by status, source CMS, or search by name.</p>
<StatusLegend />
<form class="toolbar" method="get">
<input type="search" name="q" placeholder="Search…" value={q} />
<select name="source">
<option value="">All source CMSes</option>
{sources.map((s) => (
<option value={s} selected={s === sourceFilter}>{s}</option>
))}
</select>
<select name="status">
<option value="">All statuses</option>
{statuses.map((s) => (
<option value={s} selected={s === statusFilter}>{s}</option>
))}
</select>
<button type="submit">Apply</button>
{(q || statusFilter || sourceFilter) && <a href="/">Reset</a>}
</form>
{filtered.length === 0 ? (
<p class="empty">No plugins match the current filters.</p>
) : (
<ul class="plugin-grid">
{filtered.map((entry) => <PluginCard entry={entry} />)}
</ul>
)}
</Base>
+48
View File
@@ -0,0 +1,48 @@
---
import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import StatusBadge from "../../components/StatusBadge.astro";
export const prerender = false;
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404");
const { entry, cacheHint } = await getEmDashEntry("plugins", slug);
if (!entry) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const d = entry.data;
---
<Base title={d.title} description={d.purpose ?? undefined} content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}>
<article class="detail">
<header>
<p class="crumbs">
<a href="/">Plugins</a>
{d.source_cms && (<> · <a href={`/cms/${d.source_cms.toLowerCase()}`}>{d.source_cms}</a></>)}
</p>
<h1>{d.title}</h1>
<p>
<StatusBadge status={d.status} />
{d.source_cms && <span style="margin-left:.75rem;color:var(--c-muted)">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
</p>
{d.purpose && <p style="font-size:1.05rem">{d.purpose}</p>}
</header>
<dl>
{d.category && (<><dt>Category</dt><dd>{d.category}</dd></>)}
{d.source_cms && (<><dt>Source CMS</dt><dd>{d.source_cms}</dd></>)}
{d.target_cms && (<><dt>Target CMS</dt><dd>{d.target_cms}</dd></>)}
{d.source_repo_url && (<><dt>Source repo</dt><dd><a href={d.source_repo_url}>{d.source_repo_url}</a></dd></>)}
{d.target_repo_url && (<><dt>Target repo</dt><dd><a href={d.target_repo_url}>{d.target_repo_url}</a></dd></>)}
</dl>
{d.notes && (
<section class="notes">
<h2>Migration notes</h2>
<PortableText value={d.notes} />
</section>
)}
</article>
</Base>
+146
View File
@@ -0,0 +1,146 @@
:root {
--c-bg: #ffffff;
--c-bg-alt: #f6f7f9;
--c-border: #e2e4e9;
--c-heading: #0b1320;
--c-body: #2a2f3a;
--c-muted: #5b6371;
--c-link: #2b5cff;
--c-accent: #6b21a8;
/* status palette */
--c-status-port: #2563eb;
--c-status-builtin: #16a34a;
--c-status-drop: #6b7280;
--c-status-saas: #d97706;
--c-status-gated: #b91c1c;
--c-status-done: #047857;
--c-status-proposed: #7c3aed;
--fs-base: 1rem;
--fs-sm: 0.875rem;
--fs-h1: clamp(1.75rem, 4vw, 2.5rem);
--fs-h2: clamp(1.4rem, 3vw, 1.9rem);
--fs-h3: 1.25rem;
--fs-h4: 1.1rem;
--radius: 6px;
}
* { box-sizing: border-box; }
html { font-size: 16px; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
color: var(--c-body);
background: var(--c-bg);
line-height: 1.55;
font-size: var(--fs-base);
}
h1, h2, h3, h4 { color: var(--c-heading); margin: 0 0 0.5rem; line-height: 1.25; }
h1 { font-size: var(--fs-h1); }
h2 { font-size: var(--fs-h2); }
h3 { font-size: var(--fs-h3); }
h4 { font-size: var(--fs-h4); }
a { color: var(--c-link); text-decoration: none; }
a:hover { text-decoration: underline; }
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 clamp(1rem, 3vw, 2rem);
}
header.site {
border-bottom: 1px solid var(--c-border);
padding: 1rem 0;
background: var(--c-bg);
}
header.site .row {
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
}
header.site .brand { font-weight: 700; color: var(--c-heading); }
header.site .brand:hover { text-decoration: none; }
header.site nav a { color: var(--c-muted); margin-left: 1.25rem; font-size: var(--fs-sm); }
header.site nav a:hover { color: var(--c-heading); }
main { padding: clamp(1.5rem, 4vw, 3rem) 0; min-height: 60vh; }
footer.site {
border-top: 1px solid var(--c-border);
padding: 2rem 0;
color: var(--c-muted);
font-size: var(--fs-sm);
}
/* status badge */
.badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
color: white;
background: var(--c-muted);
white-space: nowrap;
}
.badge--port { background: var(--c-status-port); }
.badge--built-in { background: var(--c-status-builtin); }
.badge--drop { background: var(--c-status-drop); }
.badge--saas { background: var(--c-status-saas); }
.badge--gated { background: var(--c-status-gated); }
.badge--done { background: var(--c-status-done); }
.badge--proposed { background: var(--c-status-proposed); }
/* plugin grid + cards */
.toolbar {
display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center;
padding: 0.75rem 0 1.5rem;
}
.toolbar input[type="search"], .toolbar select {
font: inherit;
padding: 0.4rem 0.6rem;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background: white;
}
.toolbar input[type="search"] { min-width: 240px; }
.plugin-grid {
list-style: none; padding: 0; margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.plugin-card {
border: 1px solid var(--c-border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
background: var(--c-bg);
display: flex; flex-direction: column; gap: 0.5rem;
}
.plugin-card h3 { margin: 0; font-size: 1.05rem; }
.plugin-card h3 a { color: var(--c-heading); }
.plugin-card .meta { font-size: 0.78rem; color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); }
/* detail page */
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
.detail .crumbs { color: var(--c-muted); font-size: var(--fs-sm); margin-bottom: 0.25rem; }
.detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0 0 1.5rem; }
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
.detail dd { margin: 0; }
.detail .notes { line-height: 1.65; }
.detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
/* status legend */
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem; font-size: var(--fs-sm); color: var(--c-muted); padding: 0 0 1rem; }
.legend .item { display: inline-flex; gap: 0.4rem; align-items: center; }
.empty { padding: 2rem; text-align: center; color: var(--c-muted); }
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
# deploy/
Two sibling directories with very different lifecycles:
- **`helm/`** — the Helm chart that runs the pod. FluxCD pulls it
directly from this repo on the branch matching each environment
(no `helm push` step). Edit this in lockstep with the app code that
depends on it.
- **`fleet-overlay/`** — templates for the FluxCD manifests that live in
the `anton-helm-workloads` repo. Not consumed from here — they're
versioned alongside the chart so the chart's contract with Flux stays
legible.
See `../DEPLOYMENT.md` for the end-to-end pipeline.
+50
View File
@@ -0,0 +1,50 @@
# Fleet overlay templates
The YAMLs under `cms-plugins-staging/` and `cms-plugins-production/` are the
FluxCD manifests that drive each environment. They are **not** consumed from
this repo — they live here as a versioned blueprint, intended to be copied
into the workloads repo that Flux watches:
```
git.oleks.space/anton/helm-workloads
├─ cms-plugins-staging/ ← copy from deploy/fleet-overlay/cms-plugins-staging/
├─ cms-plugins-production/ ← copy from deploy/fleet-overlay/cms-plugins-production/
└─ kustomization.yaml ← add both directories to `resources:`
```
See `../../DEPLOYMENT.md` for the full pipeline and the first-time setup
checklist (deploy keys, sops secrets, Woodpecker secrets, DNS).
## Shape
Each env directory contains five files, mirroring the emdash-kotkanagrilli
layout in `~/projects/servers/fleet/apps/base/`:
- `source.yaml``GitRepository` pointing at this repo on the matching
branch (`staging` / `production`), restricted to `/deploy/helm` via the
`ignore` rule so Flux only pulls the chart.
- `helmrelease.yaml``HelmRelease` consuming the chart from `./deploy/helm`
in that `GitRepository`. Pinned by digest (see image-automation.yaml).
- `image-automation.yaml``ImageRepository` + `ImagePolicy` +
`ImageUpdateAutomation`. Watches the floating `staging` / `production`
tag in the Gitea OCI registry, resolves the current digest, and rewrites
the digest setter in `helmrelease.yaml` (which is what actually makes
`helm upgrade` see a change when CI retags the image).
- `secrets.yaml` — two Secrets per env: the SSH deploy key Flux uses to
clone this repo (`cms-plugins-deploy-key`, in `flux-system`), and the
pod's env-var secret (`cms-plugins-{staging,production}-secrets`, in
`kotkan`). **Templates here are NOT encrypted** — sops-encrypt them
before pushing to anton-helm-workloads.
- `kustomization.yaml` — bundles the above.
## Why this lives in two repos
The chart (`deploy/helm/`) ships with the app — that way a chart change
is reviewed and tagged alongside the code that depends on it. The
HelmRelease references the chart as a path inside a `GitRepository`,
not as an OCI artifact, so there's no "publish chart" step in CI.
The HelmRelease itself lives in the workloads repo because that repo is
the source of truth for what runs on the kotkanagrilli.fi subdomain
pool. Same convention as the existing `kotkanagrilli/` (legacy WP) and
`hello-kotkan/` entries there.
@@ -0,0 +1,48 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: cms-plugins-production
namespace: flux-system
spec:
interval: 5m
chart:
spec:
chart: ./deploy/helm
sourceRef:
kind: GitRepository
name: cms-plugins-production
namespace: flux-system
reconcileStrategy: Revision
releaseName: cms-plugins-production
targetNamespace: kotkan
install:
disableWait: true
remediation:
retries: 3
upgrade:
disableWait: true
remediation:
retries: 3
values:
existingSecret: cms-plugins-production-secrets
image:
# `tag` stays human-readable. The chart prefers `digest` when set
# and renders `repository@<digest>` — that's what actually pins
# the pod. Without digest pinning, helm upgrade would see no spec
# change when CI retags the floating `production` tag.
tag: production
digest: "" # {"$imagepolicy": "kotkan:cms-plugins-production:digest"}
pullPolicy: Always
ingress:
host: cms-plugins-production.kotkanagrilli.fi
nodeSelector:
kubernetes.io/hostname: kotkan
persistence:
size: 10Gi
resources:
requests:
cpu: 100m
memory: 384Mi
limits:
cpu: "1"
memory: 1Gi
@@ -0,0 +1,78 @@
---
# Watch the Gitea OCI registry for the floating `production` tag. Every
# push to the production branch retags the new image as `production`,
# overwriting the previous binding (OCI tag→manifest is single-valued).
# The image's immutable `0.1.<N>` tag stays in the registry as audit.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: cms-plugins-production
namespace: kotkan
spec:
image: git.oleks.space/oleks/cms-plugins
interval: 1m
secretRef:
name: gitea-registry-creds
---
# Only the `production` floating tag is in scope. There's at most one
# match at a time, so alphabetical ordering is a no-op — the policy
# just resolves to that single tag's current digest.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: cms-plugins-production
namespace: kotkan
spec:
interval: 1m
imageRepositoryRef:
name: cms-plugins-production
filterTags:
pattern: '^production$'
# Extract and reflect the resolved digest into helmrelease.yaml.
# This enables IUA to pin by digest, which makes helm upgrade detect
# changes when the floating tag is reassigned.
digestReflectionPolicy: Always
policy:
alphabetical:
order: asc
---
# IUA writes the resolved digest into helmrelease.yaml — pinning by
# digest is what makes `helm upgrade` see a change when the floating
# tag is reassigned (without digest, tag stays `production` literal and
# helm upgrade is a no-op).
#
# NOTE: `sourceRef` must reference a GitRepository that points at
# THIS workloads repo (anton-helm-workloads) with write access. If it
# doesn't exist yet, create one alongside this manifest. The
# emdash-kotkanagrilli equivalent uses `oleks-fleet-image-automation`
# because its HelmReleases live in the fleet repo.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: cms-plugins-production
namespace: kotkan
spec:
interval: 1m
sourceRef:
kind: GitRepository
name: anton-workloads-image-automation
namespace: flux-system
git:
checkout:
ref:
branch: main
commit:
author:
email: flux-bot@oleks.space
name: flux-bot
messageTemplate: |
chore(cms-plugins-production): pin new digest
Files:
{{ range $filename, $_ := .Changed.FileChanges -}}
- {{ $filename }}
{{ end -}}
push:
branch: main
update:
path: ./cms-plugins-production
strategy: Setters
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- source.yaml
- helmrelease.yaml
- secrets.yaml
- image-automation.yaml
@@ -0,0 +1,46 @@
# Two secrets per environment:
# 1. cms-plugins-deploy-key — Flux's SSH key for cloning the production branch
# of cms-plugins (only `read` on this Gitea repo).
# One pair is shared between production + production;
# commit it under whichever env directory is
# applied first.
# 2. cms-plugins-production-secrets — env vars consumed by the pod via the
# chart's `existingSecret`. EMDASH_ENCRYPTION_KEY
# is required; everything else is optional.
#
# These are TEMPLATES — encrypt them with sops before committing to the
# anton-helm-workloads repo:
#
# sops --encrypt --age <recipient-key> secrets.yaml > secrets.enc.yaml
# mv secrets.enc.yaml secrets.yaml
#
# Generation:
# ssh-keygen -t ed25519 -f /tmp/cms-plugins-deploy -N ""
# → upload /tmp/cms-plugins-deploy.pub to Gitea: Repo Settings → Deploy
# Keys → "cms-plugins Flux deploy", read-only.
# openssl rand -hex 32 → EMDASH_ENCRYPTION_KEY (one per env, do not reuse).
---
apiVersion: v1
kind: Secret
metadata:
name: cms-plugins-deploy-key
namespace: flux-system
type: Opaque
stringData:
identity: |
-----BEGIN OPENSSH PRIVATE KEY-----
REPLACE_WITH_PRIVATE_KEY
-----END OPENSSH PRIVATE KEY-----
identity.pub: |
ssh-ed25519 REPLACE_WITH_PUBLIC_KEY flux@cms-plugins
known_hosts: |
git.oleks.space REPLACE_WITH_HOST_KEY
---
apiVersion: v1
kind: Secret
metadata:
name: cms-plugins-production-secrets
namespace: kotkan
type: Opaque
stringData:
EMDASH_ENCRYPTION_KEY: REPLACE_WITH_RANDOM_HEX_32
@@ -0,0 +1,19 @@
---
# Flux pulls the chart from the `production` branch of cms-plugins on Gitea.
# The `ignore` rule restricts reconciliation to /deploy/helm so app-code
# pushes don't trigger chart re-reconcile.
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: cms-plugins-production
namespace: flux-system
spec:
interval: 1m0s
url: ssh://git@git.oleks.space/oleks/cms-plugins.git
ref:
branch: production
secretRef:
name: cms-plugins-deploy-key
ignore: |
/*
!/deploy/helm
@@ -0,0 +1,46 @@
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: cms-plugins-staging
namespace: flux-system
spec:
interval: 5m
chart:
spec:
chart: ./deploy/helm
sourceRef:
kind: GitRepository
name: cms-plugins-staging
namespace: flux-system
reconcileStrategy: Revision
releaseName: cms-plugins-staging
targetNamespace: kotkan
install:
disableWait: true
remediation:
retries: 3
upgrade:
disableWait: true
remediation:
retries: 3
values:
existingSecret: cms-plugins-staging-secrets
image:
# `tag` stays human-readable. The chart prefers `digest` when set
# and renders `repository@<digest>` — that's what actually pins
# the pod. Without digest pinning, helm upgrade would see no spec
# change when CI retags the floating `staging` tag.
tag: staging
digest: "" # {"$imagepolicy": "kotkan:cms-plugins-staging:digest"}
pullPolicy: Always
ingress:
host: cms-plugins-staging.kotkanagrilli.fi
nodeSelector:
kubernetes.io/hostname: kotkan
resources:
requests:
cpu: 50m
memory: 192Mi
limits:
cpu: 500m
memory: 768Mi
@@ -0,0 +1,78 @@
---
# Watch the Gitea OCI registry for the floating `staging` tag. Every
# push to the staging branch retags the new image as `staging`,
# overwriting the previous binding (OCI tag→manifest is single-valued).
# The image's immutable `0.1.<N>` tag stays in the registry as audit.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: cms-plugins-staging
namespace: kotkan
spec:
image: git.oleks.space/oleks/cms-plugins
interval: 1m
secretRef:
name: gitea-registry-creds
---
# Only the `staging` floating tag is in scope. There's at most one
# match at a time, so alphabetical ordering is a no-op — the policy
# just resolves to that single tag's current digest.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: cms-plugins-staging
namespace: kotkan
spec:
interval: 1m
imageRepositoryRef:
name: cms-plugins-staging
filterTags:
pattern: '^staging$'
# Extract and reflect the resolved digest into helmrelease.yaml.
# This enables IUA to pin by digest, which makes helm upgrade detect
# changes when the floating tag is reassigned.
digestReflectionPolicy: Always
policy:
alphabetical:
order: asc
---
# IUA writes the resolved digest into helmrelease.yaml — pinning by
# digest is what makes `helm upgrade` see a change when the floating
# tag is reassigned (without digest, tag stays `staging` literal and
# helm upgrade is a no-op).
#
# NOTE: `sourceRef` must reference a GitRepository that points at
# THIS workloads repo (anton-helm-workloads) with write access. If it
# doesn't exist yet, create one alongside this manifest. The
# emdash-kotkanagrilli equivalent uses `oleks-fleet-image-automation`
# because its HelmReleases live in the fleet repo.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageUpdateAutomation
metadata:
name: cms-plugins-staging
namespace: kotkan
spec:
interval: 1m
sourceRef:
kind: GitRepository
name: anton-workloads-image-automation
namespace: flux-system
git:
checkout:
ref:
branch: main
commit:
author:
email: flux-bot@oleks.space
name: flux-bot
messageTemplate: |
chore(cms-plugins-staging): pin new digest
Files:
{{ range $filename, $_ := .Changed.FileChanges -}}
- {{ $filename }}
{{ end -}}
push:
branch: main
update:
path: ./cms-plugins-staging
strategy: Setters
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- source.yaml
- helmrelease.yaml
- secrets.yaml
- image-automation.yaml
@@ -0,0 +1,46 @@
# Two secrets per environment:
# 1. cms-plugins-deploy-key — Flux's SSH key for cloning the staging branch
# of cms-plugins (only `read` on this Gitea repo).
# One pair is shared between staging + production;
# commit it under whichever env directory is
# applied first.
# 2. cms-plugins-staging-secrets — env vars consumed by the pod via the
# chart's `existingSecret`. EMDASH_ENCRYPTION_KEY
# is required; everything else is optional.
#
# These are TEMPLATES — encrypt them with sops before committing to the
# anton-helm-workloads repo:
#
# sops --encrypt --age <recipient-key> secrets.yaml > secrets.enc.yaml
# mv secrets.enc.yaml secrets.yaml
#
# Generation:
# ssh-keygen -t ed25519 -f /tmp/cms-plugins-deploy -N ""
# → upload /tmp/cms-plugins-deploy.pub to Gitea: Repo Settings → Deploy
# Keys → "cms-plugins Flux deploy", read-only.
# openssl rand -hex 32 → EMDASH_ENCRYPTION_KEY (one per env, do not reuse).
---
apiVersion: v1
kind: Secret
metadata:
name: cms-plugins-deploy-key
namespace: flux-system
type: Opaque
stringData:
identity: |
-----BEGIN OPENSSH PRIVATE KEY-----
REPLACE_WITH_PRIVATE_KEY
-----END OPENSSH PRIVATE KEY-----
identity.pub: |
ssh-ed25519 REPLACE_WITH_PUBLIC_KEY flux@cms-plugins
known_hosts: |
git.oleks.space REPLACE_WITH_HOST_KEY
---
apiVersion: v1
kind: Secret
metadata:
name: cms-plugins-staging-secrets
namespace: kotkan
type: Opaque
stringData:
EMDASH_ENCRYPTION_KEY: REPLACE_WITH_RANDOM_HEX_32
@@ -0,0 +1,19 @@
---
# Flux pulls the chart from the `staging` branch of cms-plugins on Gitea.
# The `ignore` rule restricts reconciliation to /deploy/helm so app-code
# pushes don't trigger chart re-reconcile.
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: cms-plugins-staging
namespace: flux-system
spec:
interval: 1m0s
url: ssh://git@git.oleks.space/oleks/cms-plugins.git
ref:
branch: staging
secretRef:
name: cms-plugins-deploy-key
ignore: |
/*
!/deploy/helm
+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]
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
# Ensure persistent state dirs exist (volume may be empty on first boot).
mkdir -p /app/state/uploads
# Bootstrap on first run: create data.db and apply migrations.
# emdash init is expected to be idempotent on subsequent boots.
if [ ! -f /app/state/data.db ]; then
echo "[entrypoint] no data.db found in /app/state, running emdash init"
node_modules/.bin/emdash init
fi
exec "$@"