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:
@@ -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/'"
|
||||
@@ -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:
|
||||
Executable
+49
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD032": false,
|
||||
"MD033": false,
|
||||
"MD040": false,
|
||||
"MD060": false
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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." }] }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
*.swp
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
README.md
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Executable
+14
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user