14 Commits

Author SHA1 Message Date
Oleks 0be268307e style: auto-format from pre-push hooks
ci/woodpecker/push/container Pipeline was successful
2026-06-14 14:25:22 +03:00
Oleks 0072716733 fix(seed): rename reserved 'status' field to 'parity_status'
ci/woodpecker/push/container Pipeline was canceled
emdash reserves 'status' as a built-in entry field (publish state), so
'emdash seed' rejected the plugins collection's custom 'status' select
with 'Field slug status is reserved' — leaving the catalog empty. Rename
the domain field slug to parity_status (label stays 'Migration status')
across the seed field def + 39 entries, the collections type, and all
plugin-data reads. The public ?status= URL filter param and StatusBadge
prop name are unchanged.
2026-06-14 14:25:15 +03:00
Oleks 9b1090b614 style: auto-format from pre-push hooks
ci/woodpecker/push/container Pipeline was successful
2026-06-14 14:06:48 +03:00
Oleks 87eb6a0f84 fix(seed): add required id to seed entries + make seed non-fatal
ci/woodpecker/push/container Pipeline was canceled
emdash seed validates that every content entry has an id (validate.ts),
but seed/seed.json entries only had slug — so seed aborted with 'id is
required' and, under set -e, crash-looped the pod (502). Set id=slug for
all 42 entries (conflict-detection keys off slug, so id is just the
seed-local ref key). Also move the seed step out from under set -e: a bad
content seed should log loudly but not take the whole site down (init
migrations stay fatal).
2026-06-14 14:06:41 +03:00
Oleks 96c220825f fix: seed catalog on boot + emit https canonical/og:url
ci/woodpecker/push/container Pipeline failed
- entrypoint: run 'emdash seed' after 'emdash init' (init no longer loads
  JSON seeds in newer emdash, so the catalog booted empty). Idempotent
  onConflict=skip.
- Base.astro: derive canonical/og:url base from EMDASH_SITE_URL (per-env
  https URL the chart injects) instead of Astro.url.origin, which is plain
  http behind Traefik TLS termination.
2026-06-14 13:59:21 +03:00
Oleks 7b60fe452e ci: use arch-scoped buildkit service (buildkit-<arch>) instead of per-instance
ci/woodpecker/push/container Pipeline was successful
2026-06-04 19:10:28 +03:00
Oleks 597f089c53 ci: echo build arch (uname -m) as first line of every step for visibility
ci/woodpecker/push/container Pipeline was successful
2026-06-02 13:44:45 +03:00
Oleks c284c27aa8 style: auto-format from pre-push hooks
ci/woodpecker/push/container Pipeline was successful
2026-06-02 09:29:45 +03:00
Oleks d8de7617fb ci(#192): thin .woodpecker via #196 escape-hatch (emmett#44)
ci/woodpecker/push/container Pipeline was successful
cms-plugins is an Astro/emdash web app whose image is built by npm/astro
against an upstream package-lock.json (better-sqlite3 native build) and
cannot be expressed as Nix on emmett/amd64, so it stays on docker buildx.

Apply the cluster #196 OCI escape-hatch: move all build/tag/registry truth
into ci/local.sh, parameterized by BUILDKIT_ADDR (local docker-container
default, dry-run; CI overrides to the in-cluster native arm64 remote +
PUBLISH=1). CI now runs the same script a developer runs, so CI and local
can't drift. The three-tag Flux ImagePolicy contract (0.1.<pipeline>,
<branch>, <branch>-latest) and the arm64/kotkan targeting are preserved
verbatim; the Dockerfile is unchanged.
2026-06-02 09:29:38 +03:00
Oleks 0b1f2ebfbc docs(#3): go-live note — floating tags must exist before flux resume
ci/woodpecker/push/container Pipeline was successful
While HRs are suspended (Phase 0) the staging/production tags are referenced by
no live workload, so gitea-oci-cleanup can reap them. Document the registry-pins
mitigation and a pre-resume existence check in the first-deploy checklist.
2026-06-02 05:05:30 +03:00
Oleks 90a4b8088b fix(#4): consistent normalized plugin↔CMS join + orphan-ref check
Interim fix for the free-text title-match footguns (issue #4); durable ULID
reference + seed migration tracked separately.

- New lib/cms.ts: single normCms() match key + cmsSlugByTitle / resolveCmsSlug,
  used by all three join sites so a plugin can no longer link from its own page
  yet vanish from a CMS list over case/whitespace drift.
- cms/index.astro + cms/[slug].astro: counts and "plugins from / targeting"
  lists now use the normalized key (were exact-match).
- plugins/[slug].astro: drop the local normalize copy; link target_cms too
  (was source-only) for parity.
- warnOrphanCmsRefs(): logs any source_cms/target_cms that resolves to no CMS,
  so silent orphans surface in the server log.
2026-06-02 05:05:30 +03:00
Oleks 6e6fd76459 chore(docker): satisfy hadolint on the hardened Dockerfile
ci/woodpecker/push/container Pipeline was successful
- DL3008: explicit `hadolint ignore` on the two apt-get installs — bookworm-slim
  tracks current security-patched versions; pinning is brittle (reference image
  is also unpinned).
- DL3059: fold `npm run build` + `npm prune --omit=dev` into one RUN layer.
2026-06-02 04:59:20 +03:00
Oleks 8c119efff8 harden(deploy): apply safe fixes from review report-only items
- #3 Liveness probe targets full SSR DB-querying / route, coupling pod liveness to SQLite
- #4 Chart values-staging/production.yaml are dead config under Flux; drift trap
- #6 tsconfig includes gitignored emdash-env.d.ts that only the dev server generates
- #7 Dockerfile package-lock glob + npm install fallback can silently build an unlocked image
- #8 Dockerfile creates runtime user without pinning its GID
- #9 entrypoint.sh gates `emdash init` on data.db absence, skipping migrations on PVC reuse
- #10 pullPolicy: Always vs digest pinning
- #11 Dockerfile state symlinks contradict the STATE_DIR contract; Dockerfile does not set ENV STATE_DIR
- #12 astro is a production dependency, so npm prune --omit=dev keeps build-only tooling
- #14 Two ImageUpdateAutomations write back to the same anton-helm-workloads main branch
- #16 memoryCache provider is per-process; correctness depends implicitly on replicas:1
- #17 Root catch-all [slug].astro couples nav links to pages-collection rows + DB hit per unmatched path
- #18 Detail pages render a 200-style body under a 404 status and have no try/catch around getEmDash* calls
- #19 vite allowedHosts hardcodes ddev hostnames (dev-only; no prod impact)
2026-06-02 04:50:54 +03:00
Oleks 0c2cea8c25 fix: architecture + UI/UX review fixes
Architecture:
- Cap homepage plugin list at PLUGIN_FETCH_CAP like other pages
- Declare @types/node directly instead of relying on transitive dep
- Single-source status label text (statuses.ts vs seed.json drift)

UI/UX:
- Stop auto-submitting filter selects so keyboard navigation works
- Fix heading hierarchy (add h2) on flat list pages
- Improve homepage title beyond bare "Plugins"
- Make status taxonomy descriptions self-contained
- Render only relevant statuses in the legend, not all 7
- Fix PluginCard "WordPress -> —" for missing target
- Clarify "{n} from / {n} targeting" microcopy
- Use proper count meta markup on CMS list
- Allow header nav row to wrap
- Fix bare CMS URL horizontal overflow
- Add standard line-clamp fallback to cards
- Even out footer stacked paragraph spacing
- Center plugin detail status line (drop margin-left hack)
- Raise toolbar tap targets to 44px
- Surface status badge meaning beyond title attribute
- Include source-CMS breadcrumb step on lookup miss
- Add link into filtered catalog from CMS detail
2026-06-02 04:16:58 +03:00
31 changed files with 1215 additions and 220 deletions
+15 -37
View File
@@ -5,6 +5,14 @@
# repo → pod rolls), and <branch>-latest (same image; chart image.tag # repo → pod rolls), and <branch>-latest (same image; chart image.tag
# fallback). Only staging/production have an ImagePolicy, so only those # fallback). Only staging/production have an ImagePolicy, so only those
# move pods. # move pods.
#
# Thin wrapper (cluster #196, emmett#44): this is an Astro/emdash WEB app
# whose image is built by npm/astro against an upstream lockfile and can NOT
# be expressed as Nix on emmett/amd64, so it stays on `docker buildx`. CI
# runs the SAME ci/local.sh a developer runs — the only CI-specific bits are
# env overrides (BUILDKIT_ADDR -> in-cluster native-arch remote, PUBLISH=1).
# All the tag/registry semantics live in ci/local.sh so CI and local can't
# drift.
labels: labels:
# kotkan (the deploy target) is an arm64 host, so we build natively on # kotkan (the deploy target) is an arm64 host, so we build natively on
@@ -32,19 +40,15 @@ steps:
environment: environment:
REGISTRY_TOKEN: REGISTRY_TOKEN:
from_secret: registry_token from_secret: registry_token
# In-cluster native arm64 buildkit (kotkan's arch). ci/local.sh treats
# this as a native remote builder and skips qemu emulation.
BUILDKIT_ADDR: "tcp://buildkit-arm64.infra.svc.cluster.local:1234"
PUBLISH: "1"
commands: commands:
- BRANCH="$CI_COMMIT_BRANCH" - echo "▸ arch=$(uname -m)"
- 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). # Wait for the in-cluster buildkit to be reachable (it can be cold).
- | - |
BUILDER_HOST="buildkit-rootless-arm64.infra.svc.cluster.local" BUILDER_HOST="buildkit-arm64.infra.svc.cluster.local"
BUILDER_PORT="1234" BUILDER_PORT="1234"
echo "Waiting for buildkit at $BUILDER_HOST:$BUILDER_PORT..." echo "Waiting for buildkit at $BUILDER_HOST:$BUILDER_PORT..."
for i in $(seq 1 30); do for i in $(seq 1 30); do
@@ -54,33 +58,7 @@ steps:
[ "$i" -eq 30 ] && echo "Builder not available" && exit 1 [ "$i" -eq 30 ] && echo "Builder not available" && exit 1
sleep 10 sleep 10
done done
- ci/local.sh --arch arm64
- 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: backend_options:
kubernetes: kubernetes:
nodeSelector: nodeSelector:
+17 -4
View File
@@ -66,13 +66,26 @@ the matching branch of this repo. The `ignore` rule in `source.yaml`
restricts reconciliation to `/deploy/helm`, so app-source pushes don't restricts reconciliation to `/deploy/helm`, so app-source pushes don't
trigger chart re-reconcile. trigger chart re-reconcile.
Per-env values overlay `values.yaml` via `values-staging.yaml` / Per-env values come from **two independent sources** that must be kept in
`values-production.yaml` (for `helm upgrade` direct use) and via the sync by hand:
`values:` block in the FluxCD `HelmRelease`.
- The inline `values:` block in each FluxCD `HelmRelease` (in
`anton-helm-workloads`, templated under `deploy/fleet-overlay/`). **This is
the only source Flux applies to the cluster.**
- `values-staging.yaml` / `values-production.yaml` in `deploy/helm/`, used
*only* for direct `helm upgrade -f ...` invocations. **Flux never reads
these files**, so editing them has no effect on the live deploy and they
can silently drift from the HelmRelease.
Key shape decisions: Key shape decisions:
- **`replicas: 1`, `strategy: Recreate`.** SQLite is single-writer. - **`replicas: 1`, `strategy: Recreate`.** SQLite is single-writer. This
also keeps the in-process Astro response cache (`memoryCache()` in
`app/astro.config.mjs`, fed by `Astro.cache.set(cacheHint)` on the
content pages) coherent: it is per-process, so a second replica would
hold a divergent, independently-expiring cache. Scaling out would
require a shared cache provider, not just relaxing the SQLite writer
constraint.
- **`nodeSelector: kotkan`.** `local-path` PV is sticky to one node; emdash - **`nodeSelector: kotkan`.** `local-path` PV is sticky to one node; emdash
is colocated with the legacy kotkanagrilli WP install. is colocated with the legacy kotkanagrilli WP install.
- **Pinned by digest, not tag.** The HelmRelease sets - **Pinned by digest, not tag.** The HelmRelease sets
+6 -1
View File
@@ -53,7 +53,12 @@ cd app && npm install && npm run bootstrap && npx emdash dev
# Build production image # Build production image
docker build -t cms-plugins:dev . docker build -t cms-plugins:dev .
# Typecheck # Typecheck — NOTE: requires app/emdash-env.d.ts, which tsconfig.json
# includes but which is gitignored + untracked. Emdash regenerates it
# ONLY via the dev-server `astro:server:setup` hook, so on a clean
# checkout you must start the dev server once (`npx emdash dev`, then
# stop it) before `astro check` will resolve emdash types. `astro build`
# (the Docker/CI image path) does NOT type-check and is unaffected.
cd app && npm run typecheck cd app && npm run typecheck
``` ```
+16 -2
View File
@@ -225,10 +225,24 @@ Both should resolve to the `kotkan` ingress IP (the same one
### D. First deploy ### D. First deploy
1. Push to `staging` to trigger the first build. Watch Woodpecker. > **Go-live prerequisite — floating tag must exist (issue #3).** While the
2. Once the image lands, Flux reconciles. Watch the rollout: > HelmReleases are `suspend: true` (Phase 0) and no pod is running, the
> floating `staging`/`production` tags are referenced by **no live workload**.
> `gitea-oci-cleanup` is fleet-aware and only auto-pins in-use tags, so an
> unreferenced floating tag is reapable. If it gets reaped before go-live, the
> `ImagePolicy` has nothing to resolve and the first deploy can't pin a digest.
> Mitigation: the floating tags are explicitly pinned in
> `servers/armer/scripts/registry-pins.txt`
> (`container/cms-plugins==staging`, `container/cms-plugins==production`) so
> the cleaner always keeps them. Before the first `flux resume`, confirm both
> tags still exist (`oleks/-/packages/container/cms-plugins`); if not, push the
> branch to rebuild them first.
1. Re-run/confirm the `staging` build so `git.oleks.space/oleks/cms-plugins:staging` exists. Watch Woodpecker.
2. Resume the release and watch Flux reconcile + the rollout:
```bash ```bash
flux resume helmrelease cms-plugins-staging -n kotkan
kubectl -n kotkan get pods -w | grep cms-plugins kubectl -n kotkan get pods -w | grep cms-plugins
flux get all -n flux-system | grep cms-plugins flux get all -n flux-system | grep cms-plugins
``` ```
+28 -9
View File
@@ -2,30 +2,43 @@
FROM node:22-bookworm-slim AS deps FROM node:22-bookworm-slim AS deps
WORKDIR /app WORKDIR /app
# bookworm-slim: track the distro's current security-patched versions, don't pin.
# hadolint ignore=DL3008
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY app/package.json app/package-lock.json* ./ COPY app/package.json app/package-lock.json ./
# package-lock.json may not exist on the first commit — fall back to `npm install` # Lockfile (lockfileVersion 3) is committed; npm ci is reproducible and
# so the image still builds; once a lockfile is committed, npm ci kicks in. # fails if it drifts from package.json. No npm install fallback.
RUN if [ -f package-lock.json ]; then npm ci --include=dev; else npm install --include=dev; fi RUN npm ci --include=dev
FROM deps AS build FROM deps AS build
WORKDIR /app WORKDIR /app
COPY app/ ./ COPY app/ ./
RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads
RUN npm run build # `astro` is a runtime dependency (required by the @astrojs/node standalone
RUN npm prune --omit=dev # SSR server), so the prune only drops the two devDependencies
# (@astrojs/check, @types/node). Astro's transitive build tooling
# (vite, esbuild, @astrojs/compiler, rollup plugins) stays in node_modules
# because Astro itself declares them as runtime deps. Slimming those out
# would require verifying the dist/server bundle never imports `astro/*` at
# boot; not attempted here. Image-size tradeoff is accepted for now.
# (build + prune in one layer; separate RUNs would trip hadolint DL3059.)
RUN npm run build \
&& npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime FROM node:22-bookworm-slim AS runtime
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=4321 PORT=4321
# bookworm-slim: track the distro's current security-patched versions, don't pin.
# hadolint ignore=DL3008
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tini \ && apt-get install -y --no-install-recommends ca-certificates tini \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1001 --home /app emdash \ && groupadd --system --gid 1001 emdash \
&& useradd --system --uid 1001 --gid 1001 --home /app emdash \
&& mkdir -p /app/state/uploads \ && mkdir -p /app/state/uploads \
&& chown -R emdash:emdash /app && chown -R emdash:emdash /app
@@ -35,8 +48,14 @@ COPY --from=build --chown=emdash:emdash /app/dist ./dist
COPY --from=build --chown=emdash:emdash /app/seed ./seed COPY --from=build --chown=emdash:emdash /app/seed ./seed
# Persistent state lives in /app/state (single PVC in k3s). # Persistent state lives in /app/state (single PVC in k3s).
# data.db and uploads/ are symlinked from the working directory so the # STATE_DIR is intentionally NOT set here: the Helm chart injects
# default emdash paths (./data.db, ./uploads) resolve into the volume. # STATE_DIR=/app/state (deploy/helm values.yaml), so the running app writes
# directly to /app/state/data.db and /app/state/uploads and these symlinks
# are never traversed. Leaving it unset keeps the image deploy-agnostic so a
# bare `docker run` / DDEV smoke test falls back to the WORKDIR (./data.db).
# The symlinks below only backstop that STATE_DIR-unset fallback
# (astro.config.mjs: `process.env.STATE_DIR ?? "."`), redirecting the default
# emdash paths into the volume — they do not contradict the STATE_DIR contract.
RUN ln -s /app/state/data.db /app/data.db \ RUN ln -s /app/state/data.db /app/data.db \
&& ln -s /app/state/uploads /app/uploads && ln -s /app/state/uploads /app/uploads
+3 -2
View File
@@ -16,8 +16,9 @@ export default defineConfig({
server: { host: true, port: 4321 }, server: { host: true, port: 4321 },
vite: { vite: {
server: { server: {
// Dev runs behind DDEV's nginx (https://cms-plugins.ddev.site/). // Dev-only: `vite.server` applies to `astro dev` / `emdash dev`
// Vite's host check must allow the public hostname. // (DDEV's nginx fronts https://cms-plugins.ddev.site/). Production
// runs the @astrojs/node standalone server, so this has no prod effect.
allowedHosts: ["cms-plugins.ddev.site", ".ddev.site"], allowedHosts: ["cms-plugins.ddev.site", ".ddev.site"],
}, },
}, },
+10 -11
View File
@@ -14,7 +14,8 @@
"emdash": "^0.10.0" "emdash": "^0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.7" "@astrojs/check": "^0.9.7",
"@types/node": "^22"
} }
}, },
"node_modules/@astrojs/check": { "node_modules/@astrojs/check": {
@@ -3607,13 +3608,12 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.1", "version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"undici-types": ">=7.24.0 <7.24.7" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
@@ -9505,11 +9505,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.24.6", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
+2 -1
View File
@@ -22,6 +22,7 @@
"emdash": "^0.10.0" "emdash": "^0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.7" "@astrojs/check": "^0.9.7",
"@types/node": "^22"
} }
} }
+551 -95
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -7,7 +7,7 @@ interface Props {
data: { data: {
title: string; title: string;
purpose?: string | null; purpose?: string | null;
status?: string | null; parity_status?: string | null;
source_cms?: string | null; source_cms?: string | null;
target_cms?: string | null; target_cms?: string | null;
}; };
@@ -19,8 +19,8 @@ const d = entry.data;
<li class="plugin-card"> <li class="plugin-card">
<h3><a href={`/plugins/${entry.id}`}>{d.title}</a></h3> <h3><a href={`/plugins/${entry.id}`}>{d.title}</a></h3>
<div class="meta"> <div class="meta">
<StatusBadge status={d.status} /> <StatusBadge status={d.parity_status} />
{d.source_cms && <span>{d.source_cms} → {d.target_cms ?? ""}</span>} {d.source_cms && <span>{d.source_cms}{d.target_cms ? `${d.target_cms}` : ""}</span>}
</div> </div>
{d.purpose && <p>{d.purpose}</p>} {d.purpose && <p>{d.purpose}</p>}
</li> </li>
+1 -1
View File
@@ -8,4 +8,4 @@ const value = (status ?? "proposed").toLowerCase();
const label = STATUS_LABELS[value] ?? value; const label = STATUS_LABELS[value] ?? value;
const desc = STATUSES.find((s) => s.value === value)?.desc; const desc = STATUSES.find((s) => s.value === value)?.desc;
--- ---
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: </span>{label}</span> <span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: {label}{desc ? ` — ${desc}` : ""}. </span>{label}</span>
+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
import StatusBadge from "./StatusBadge.astro"; import StatusBadge from "./StatusBadge.astro";
import { STATUSES } from "../lib/statuses"; import { STATUSES } from "../lib/statuses";
interface Props {
items?: typeof STATUSES;
}
const { items = STATUSES } = Astro.props;
--- ---
<div class="legend"> <div class="legend">
{STATUSES.map((i) => ( {items.map((i) => (
<span class="item"><StatusBadge status={i.value} /> {i.desc}</span> <span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
))} ))}
</div> </div>
+1 -1
View File
@@ -28,7 +28,7 @@ declare module "emdash" {
source_cms: string; source_cms: string;
target_cms?: string | null; target_cms?: string | null;
category?: string | null; category?: string | null;
status: string; parity_status: string;
source_repo_url?: string | null; source_repo_url?: string | null;
target_repo_url?: string | null; target_repo_url?: string | null;
notes?: import("emdash").PortableTextBlock[] | null; notes?: import("emdash").PortableTextBlock[] | null;
+6 -1
View File
@@ -19,7 +19,12 @@ const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity cat
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`; const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
const pageDescription = description ?? siteTagline; const pageDescription = description ?? siteTagline;
const canonicalBase = Astro.site ?? new URL(Astro.url.origin); // Prefer the per-env site URL the chart injects (EMDASH_SITE_URL, https://<host>)
// over Astro.url.origin: TLS terminates at Traefik, so the pod sees plain http
// and would otherwise emit http:// canonical + og:url. Read at request time via
// process.env (runtime pod env, not a build-time import.meta.env constant).
const siteUrlEnv = process.env.EMDASH_SITE_URL;
const canonicalBase = Astro.site ?? (siteUrlEnv ? new URL(siteUrlEnv) : new URL(Astro.url.origin));
const canonical = new URL(Astro.url.pathname, canonicalBase).href; const canonical = new URL(Astro.url.pathname, canonicalBase).href;
const pageCtx = createPublicPageContext({ const pageCtx = createPublicPageContext({
+65
View File
@@ -0,0 +1,65 @@
// Plugin↔CMS relation helpers.
//
// A plugin references a CMS by the free-text `source_cms` / `target_cms`
// string, which is matched against a CMS entry's `title`. There is no
// reference field / foreign key yet (tracked in issue #4 — the durable fix is
// an Emdash relation field keyed by the CMS ULID + a seed migration). Until
// then, EVERY page that joins plugins to CMSes must use the SAME normalized
// match so a plugin can't link from its own page yet vanish from a CMS list
// over mere casing/whitespace drift.
interface CmsEntry {
id: string;
data: { title?: string | null };
}
interface PluginRefData {
source_cms?: string | null;
target_cms?: string | null;
}
/** Canonical CMS-match key: trim + lowercase. The ONE place case/whitespace is normalized. */
export const normCms = (s: string): string => s.trim().toLowerCase();
/** Map of normalized CMS title → CMS entry slug (`entry.id`, used in URLs). */
export function cmsSlugByTitle(cmses: CmsEntry[]): Map<string, string> {
return new Map(
cmses.filter((c) => c.data.title).map((c) => [normCms(c.data.title as string), c.id]),
);
}
/** Resolve a plugin's CMS reference to a CMS slug, or undefined if it matches no CMS. */
export function resolveCmsSlug(
ref: string | null | undefined,
index: Map<string, string>,
): string | undefined {
return ref ? index.get(normCms(ref)) : undefined;
}
/**
* Integrity check: log any plugin `source_cms`/`target_cms` that resolves to no
* CMS entry. These are silent orphans (excluded from CMS counts/lists with no
* user-visible error), so surfacing them in the server log is the cheap guard
* until the relation becomes a real reference (issue #4). Call from a page that
* already has both collections in scope; it does not fetch.
*/
export function warnOrphanCmsRefs(
plugins: Array<{ id: string; data: PluginRefData }>,
cmses: CmsEntry[],
): void {
const known = new Set(
cmses.filter((c) => c.data.title).map((c) => normCms(c.data.title as string)),
);
const orphans: string[] = [];
for (const p of plugins) {
for (const field of ["source_cms", "target_cms"] as const) {
const ref = p.data[field];
if (ref && !known.has(normCms(ref))) orphans.push(`${p.id}.${field}="${ref}"`);
}
}
if (orphans.length) {
console.warn(
`[cms] ${orphans.length} plugin CMS reference(s) match no CMS entry (issue #4):`,
orphans.join(", "),
);
}
}
+9 -7
View File
@@ -1,3 +1,5 @@
export const PLUGIN_FETCH_CAP = 10000;
export interface StatusDef { export interface StatusDef {
value: string; value: string;
label: string; label: string;
@@ -5,13 +7,13 @@ export interface StatusDef {
} }
export const STATUSES: StatusDef[] = [ export const STATUSES: StatusDef[] = [
{ value: "port", label: "Port", desc: "must be reimplemented on the target CMS" }, { value: "port", label: "Port", desc: "Reimplemented as a new Emdash plugin" },
{ value: "built-in", label: "Built-in", desc: "covered by target CMS core / framework" }, { value: "built-in", label: "Built-in", desc: "Already covered by Emdash core" },
{ value: "saas", label: "SaaS", desc: "replaced by an external service" }, { value: "saas", label: "SaaS", desc: "Replaced by an external hosted service" },
{ value: "drop", label: "Drop", desc: "not needed on the target CMS" }, { value: "drop", label: "Drop", desc: "Not needed after migration" },
{ value: "gated", label: "Gated", desc: "fate depends on an unresolved decision" }, { value: "gated", label: "Gated", desc: "Blocked on an open decision" },
{ value: "done", label: "Done", desc: "ported and shipped" }, { value: "done", label: "Done", desc: "Ported and shipped" },
{ value: "proposed", label: "Proposed", desc: "submitted for cataloging" }, { value: "proposed", label: "Proposed", desc: "Newly added, not yet classified" },
]; ];
export const STATUS_LABELS: Record<string, string> = Object.fromEntries( export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
+15
View File
@@ -3,8 +3,23 @@ import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui"; import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro"; import Base from "../layouts/Base.astro";
// Root-level catch-all for the `pages` content collection (standard Emdash model:
// a CMS "page" IS a pages-collection row whose `entry.id` is its slug). The site nav
// (`/about` in Base.astro) deliberately links to pages-collection rows rather than to
// hard-coded .astro files, so adding/renaming a static page is a content edit, not a
// code change. Consequence: every unmatched top-level path (incl. bot probes) does one
// indexed SQLite point-lookup here and 404s on a miss. Acceptable by design — single
// replica, single-writer local-path SQLite on kotkan, and hits already emit the
// `cacheHint` below. Do NOT "fix" by reintroducing per-page .astro files.
export const prerender = false; export const prerender = false;
// Missing slug/entry → 404 status + a "Not found" body (the markup below).
// getEmDashEntry is intentionally NOT wrapped in try/catch: a thrown error
// here means SQLite/infra is unhealthy and should surface as a 500 (a real
// pod-health signal on this single-replica/local-path deploy), not be masked
// as a 404. Mirrors the emdash-kotkanagrilli reference, which also lets these
// calls propagate.
const slug = decodeSlug(Astro.params.slug); const slug = decodeSlug(Astro.params.slug);
const { entry, cacheHint } = slug const { entry, cacheHint } = slug
? await getEmDashEntry("pages", slug) ? await getEmDashEntry("pages", slug)
+15 -3
View File
@@ -2,9 +2,17 @@
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash"; import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import Base from "../../layouts/Base.astro"; import Base from "../../layouts/Base.astro";
import PluginCard from "../../components/PluginCard.astro"; import PluginCard from "../../components/PluginCard.astro";
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
import { normCms } from "../../lib/cms";
export const prerender = false; export const prerender = false;
// Missing slug/entry → 404 status + a "Not found" body (the markup below).
// getEmDashEntry/getEmDashCollection are intentionally NOT wrapped in
// try/catch: a thrown error here means SQLite/infra is unhealthy and should
// surface as a 500 (a real pod-health signal on this single-replica/local-path
// deploy), not be masked as a 404. Mirrors the emdash-kotkanagrilli reference,
// which also lets these calls propagate.
const slug = decodeSlug(Astro.params.slug); const slug = decodeSlug(Astro.params.slug);
const { entry: cms, cacheHint } = slug const { entry: cms, cacheHint } = slug
? await getEmDashEntry("cmses", slug) ? await getEmDashEntry("cmses", slug)
@@ -14,14 +22,17 @@ if (!slug || !cms) {
} }
if (cacheHint) Astro.cache.set(cacheHint); if (cacheHint) Astro.cache.set(cacheHint);
const PLUGIN_FETCH_CAP = 10000;
const { entries: all } = cms const { entries: all } = cms
? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP }) ? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
: { entries: [] }; : { entries: [] };
if (all.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated"); if (all.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
const cmsName = cms?.data.title as string; const cmsName = cms?.data.title as string;
const fromHere = all.filter((p) => p.data.source_cms === cmsName); // Normalized match (see lib/cms.ts / issue #4): a plugin whose source_cms
const targetingHere = all.filter((p) => p.data.target_cms === cmsName); // differs from this CMS title only by case/whitespace must still appear here,
// exactly as it links from its own detail page.
const cmsKey = cmsName ? normCms(cmsName) : "";
const fromHere = all.filter((p) => p.data.source_cms && normCms(p.data.source_cms) === cmsKey);
const targetingHere = all.filter((p) => p.data.target_cms && normCms(p.data.target_cms) === cmsKey);
--- ---
{!cms ? ( {!cms ? (
<Base title="Not found"> <Base title="Not found">
@@ -36,6 +47,7 @@ const targetingHere = all.filter((p) => p.data.target_cms === cmsName);
{cms.data.description && <p>{cms.data.description}</p>} {cms.data.description && <p>{cms.data.description}</p>}
<h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2> <h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2>
<p><a href={`/?source=${encodeURIComponent(cmsName)}`}>Filter these in the catalog</a></p>
{fromHere.length === 0 ? ( {fromHere.length === 0 ? (
<p class="empty">No plugins cataloged for this CMS yet.</p> <p class="empty">No plugins cataloged for this CMS yet.</p>
) : ( ) : (
+9 -4
View File
@@ -1,6 +1,8 @@
--- ---
import { getEmDashCollection } from "emdash"; import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro"; import Base from "../../layouts/Base.astro";
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
import { normCms, warnOrphanCmsRefs } from "../../lib/cms";
export const prerender = false; export const prerender = false;
@@ -9,25 +11,28 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
}); });
Astro.cache.set(cacheHint); Astro.cache.set(cacheHint);
const PLUGIN_FETCH_CAP = 10000;
const { entries: plugins } = await getEmDashCollection("plugins", { limit: PLUGIN_FETCH_CAP }); const { entries: plugins } = await getEmDashCollection("plugins", { limit: PLUGIN_FETCH_CAP });
if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated"); if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
warnOrphanCmsRefs(plugins, cmses);
// Count by NORMALIZED CMS key so the tallies match the (normalized) joins on
// the CMS detail and plugin detail pages — see lib/cms.ts / issue #4.
const countBySource = new Map<string, number>(); const countBySource = new Map<string, number>();
const countByTarget = new Map<string, number>(); const countByTarget = new Map<string, number>();
for (const p of plugins) { for (const p of plugins) {
const s = p.data.source_cms; const s = p.data.source_cms;
if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1); if (s) countBySource.set(normCms(s), (countBySource.get(normCms(s)) ?? 0) + 1);
const t = p.data.target_cms; const t = p.data.target_cms;
if (t) countByTarget.set(t, (countByTarget.get(t) ?? 0) + 1); if (t) countByTarget.set(normCms(t), (countByTarget.get(normCms(t)) ?? 0) + 1);
} }
--- ---
<Base title="By CMS" description="Browse plugins grouped by source CMS."> <Base title="By CMS" description="Browse plugins grouped by source CMS.">
<h1>By CMS</h1> <h1>By CMS</h1>
<h2 class="sr-only">CMSes</h2>
<ul class="plugin-grid"> <ul class="plugin-grid">
{cmses.map((c) => ( {cmses.map((c) => (
<li class="plugin-card"> <li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3> <h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<p class="meta">{countBySource.get(c.data.title) ?? 0} from · {countByTarget.get(c.data.title) ?? 0} targeting</p> <div class="meta">{countBySource.get(normCms(c.data.title)) ?? 0} plugins from · {countByTarget.get(normCms(c.data.title)) ?? 0} targeting</div>
{c.data.description && <p>{c.data.description}</p>} {c.data.description && <p>{c.data.description}</p>}
</li> </li>
))} ))}
+12 -9
View File
@@ -3,13 +3,15 @@ import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro"; import Base from "../layouts/Base.astro";
import PluginCard from "../components/PluginCard.astro"; import PluginCard from "../components/PluginCard.astro";
import StatusLegend from "../components/StatusLegend.astro"; import StatusLegend from "../components/StatusLegend.astro";
import { STATUSES } from "../lib/statuses"; import { STATUSES, PLUGIN_FETCH_CAP } from "../lib/statuses";
export const prerender = false; export const prerender = false;
const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", { const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", {
orderBy: { title: "asc" }, orderBy: { title: "asc" },
limit: PLUGIN_FETCH_CAP,
}); });
if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[index] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- list may be truncated");
Astro.cache.set(cacheHint); Astro.cache.set(cacheHint);
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
@@ -18,44 +20,45 @@ const statusFilter = url.searchParams.get("status") ?? "";
const sourceFilter = url.searchParams.get("source") ?? ""; const sourceFilter = url.searchParams.get("source") ?? "";
const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort(); const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort();
const present = new Set(plugins.map((p) => p.data.status).filter(Boolean)); const present = new Set(plugins.map((p) => p.data.parity_status).filter(Boolean));
const statuses = STATUSES.filter((s) => present.has(s.value)); const statuses = STATUSES.filter((s) => present.has(s.value));
const filtered = plugins.filter((p) => { const filtered = plugins.filter((p) => {
const d = p.data; const d = p.data;
if (q && !(`${d.title} ${d.purpose ?? ""}`.toLowerCase().includes(q))) return false; if (q && !(`${d.title} ${d.purpose ?? ""}`.toLowerCase().includes(q))) return false;
if (statusFilter && d.status !== statusFilter) return false; if (statusFilter && d.parity_status !== statusFilter) return false;
if (sourceFilter && d.source_cms !== sourceFilter) return false; if (sourceFilter && d.source_cms !== sourceFilter) return false;
return true; return true;
}); });
--- ---
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status."> <Base title="WordPress → Emdash plugin parity catalog" description="How each WordPress plugin maps onto its Emdash replacement when migrating a site — browse by migration status, source CMS, or name.">
<h1>Plugins</h1> <h1>Plugins</h1>
<p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} entries cataloged. Filter by status, source CMS, or search by name.`}</p> <p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} WordPress plugins cataloged with how each maps onto Emdash. Filter by status, source CMS, or search by name.`}</p>
<StatusLegend /> <StatusLegend items={statuses} />
<form class="toolbar" method="get"> <form class="toolbar" method="get">
<input type="search" name="q" placeholder="Search…" value={q} aria-label="Search plugins by name" /> <input type="search" name="q" placeholder="Search…" value={q} aria-label="Search plugins by name" />
<select name="source" aria-label="Filter by source CMS" onchange="this.form.submit()"> <select name="source" aria-label="Filter by source CMS">
<option value="">All source CMSes</option> <option value="">All source CMSes</option>
{sources.map((s) => ( {sources.map((s) => (
<option value={s} selected={s === sourceFilter}>{s}</option> <option value={s} selected={s === sourceFilter}>{s}</option>
))} ))}
</select> </select>
<select name="status" aria-label="Filter by status" onchange="this.form.submit()"> <select name="status" aria-label="Filter by status">
<option value="">All statuses</option> <option value="">All statuses</option>
{statuses.map((s) => ( {statuses.map((s) => (
<option value={s.value} selected={s.value === statusFilter}>{s.label}</option> <option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
))} ))}
</select> </select>
<button type="submit">Apply</button> <button type="submit">Apply</button>
{(q || statusFilter || sourceFilter) && <a href="/">Reset</a>} {(q || statusFilter || sourceFilter) && <a href="/">Clear all</a>}
</form> </form>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p class="empty">No plugins match {q ? `“${q}”` : "the current filters"}. <a href="/">Clear filters</a></p> <p class="empty">No plugins match {q ? `“${q}”` : "the current filters"}. <a href="/">Clear filters</a></p>
) : ( ) : (
<h2 class="sr-only">Plugin results</h2>
<ul class="plugin-grid"> <ul class="plugin-grid">
{filtered.map((entry) => <PluginCard entry={entry} />)} {filtered.map((entry) => <PluginCard entry={entry} />)}
</ul> </ul>
+16 -7
View File
@@ -3,9 +3,16 @@ import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui"; import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro"; import Base from "../../layouts/Base.astro";
import StatusBadge from "../../components/StatusBadge.astro"; import StatusBadge from "../../components/StatusBadge.astro";
import { cmsSlugByTitle, resolveCmsSlug } from "../../lib/cms";
export const prerender = false; export const prerender = false;
// Missing slug/entry → 404 status + a "Not found" body (the markup below).
// getEmDashEntry/getEmDashCollection are intentionally NOT wrapped in
// try/catch: a thrown error here means SQLite/infra is unhealthy and should
// surface as a 500 (a real pod-health signal on this single-replica/local-path
// deploy), not be masked as a 404. Mirrors the emdash-kotkanagrilli reference,
// which also lets these calls propagate.
const slug = decodeSlug(Astro.params.slug); const slug = decodeSlug(Astro.params.slug);
const { entry, cacheHint } = slug const { entry, cacheHint } = slug
? await getEmDashEntry("plugins", slug) ? await getEmDashEntry("plugins", slug)
@@ -18,8 +25,10 @@ if (cacheHint) Astro.cache.set(cacheHint);
const d = entry?.data; const d = entry?.data;
const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] }; const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] };
const cmsSlugByTitle = new Map(cmses.map((c) => [c.data.title as string, c.id])); // Shared normalized join (lib/cms.ts / issue #4) — same match the CMS pages use.
const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefined; const cmsIndex = cmsSlugByTitle(cmses);
const sourceCmsSlug = resolveCmsSlug(d?.source_cms, cmsIndex);
const targetCmsSlug = resolveCmsSlug(d?.target_cms, cmsIndex);
--- ---
{!entry || !d ? ( {!entry || !d ? (
<Base title="Not found"> <Base title="Not found">
@@ -31,7 +40,7 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
title={d.title} title={d.title}
description={d.purpose ?? undefined} description={d.purpose ?? undefined}
content={{ collection: "plugins", id: entry.data.id, slug: entry.id }} content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}
breadcrumbs={[{ name: "Plugins", url: "/" }, ...(sourceCmsSlug ? [{ name: d.source_cms, url: `/cms/${sourceCmsSlug}` }] : []), { name: d.title, url: Astro.url.pathname }]} breadcrumbs={[{ name: "Plugins", url: "/" }, ...(d.source_cms ? [{ name: d.source_cms, url: sourceCmsSlug ? `/cms/${sourceCmsSlug}` : "/cms" }] : []), { name: d.title, url: Astro.url.pathname }]}
> >
<article class="detail"> <article class="detail">
<header> <header>
@@ -40,8 +49,8 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
{d.source_cms && (<> · {sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : <span>{d.source_cms}</span>}</>)} {d.source_cms && (<> · {sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : <span>{d.source_cms}</span>}</>)}
</p> </p>
<h1>{d.title}</h1> <h1>{d.title}</h1>
<p> <p class="meta">
<StatusBadge status={d.status} /> <StatusBadge status={d.parity_status} />
{d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>} {d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
</p> </p>
{d.purpose && <p class="lead">{d.purpose}</p>} {d.purpose && <p class="lead">{d.purpose}</p>}
@@ -49,8 +58,8 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
<dl> <dl>
{d.category && (<><dt>Category</dt><dd>{d.category}</dd></>)} {d.category && (<><dt>Category</dt><dd>{d.category}</dd></>)}
{d.source_cms && (<><dt>Source CMS</dt><dd>{d.source_cms}</dd></>)} {d.source_cms && (<><dt>Source CMS</dt><dd>{sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : d.source_cms}</dd></>)}
{d.target_cms && (<><dt>Target CMS</dt><dd>{d.target_cms}</dd></>)} {d.target_cms && (<><dt>Target CMS</dt><dd>{targetCmsSlug ? <a href={`/cms/${targetCmsSlug}`}>{d.target_cms}</a> : 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.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></>)} {d.target_repo_url && (<><dt>Target repo</dt><dd><a href={d.target_repo_url}>{d.target_repo_url}</a></dd></>)}
</dl> </dl>
+10 -5
View File
@@ -70,7 +70,7 @@ header.site {
background: var(--c-bg); background: var(--c-bg);
} }
header.site .row { header.site .row {
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1.5rem;
} }
header.site .brand { font-weight: 700; color: var(--c-heading); } header.site .brand { font-weight: 700; color: var(--c-heading); }
header.site .brand:hover { text-decoration: none; } header.site .brand:hover { text-decoration: none; }
@@ -85,6 +85,8 @@ footer.site {
color: var(--c-muted); color: var(--c-muted);
font-size: var(--fs-sm); font-size: var(--fs-sm);
} }
footer.site .container { display: flex; flex-direction: column; gap: 0.35rem; }
footer.site p { margin: 0; }
/* status badge */ /* status badge */
.badge { .badge {
@@ -115,14 +117,15 @@ footer.site {
.toolbar input[type="search"], .toolbar select { .toolbar input[type="search"], .toolbar select {
font: inherit; font: inherit;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
min-height: 44px;
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
border-radius: var(--radius); border-radius: var(--radius);
background: white; background: white;
} }
.toolbar input[type="search"] { flex: 1 1 240px; min-width: 0; } .toolbar input[type="search"] { flex: 1 1 240px; min-width: 0; }
.toolbar button { font: inherit; padding: 0.4rem 0.9rem; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; } .toolbar button { font: inherit; padding: 0.4rem 0.9rem; min-height: 44px; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; }
.toolbar button:hover { filter: brightness(1.08); } .toolbar button:hover { filter: brightness(1.08); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; border-radius: var(--radius); } .toolbar a { font: inherit; padding: 0.4rem 0.6rem; min-height: 44px; display: inline-flex; align-items: center; border-radius: var(--radius); }
.plugin-grid { .plugin-grid {
list-style: none; padding: 0; margin: 0; list-style: none; padding: 0; margin: 0;
@@ -142,7 +145,7 @@ footer.site {
.plugin-card h3 { margin: 0; font-size: var(--fs-card-title); } .plugin-card h3 { margin: 0; font-size: var(--fs-card-title); }
.plugin-card h3 a { color: var(--c-heading); } .plugin-card h3 a { color: var(--c-heading); }
.plugin-card .meta { font-size: var(--fs-sm); color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } .plugin-card .meta { font-size: var(--fs-sm); 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); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
/* detail page */ /* detail page */
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; } .detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
@@ -151,7 +154,9 @@ footer.site {
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); } .detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
.detail dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .detail dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.detail .lead { font-size: 1.05rem; } .detail .lead { font-size: 1.05rem; }
.detail .source-target { margin-left: .75rem; color: var(--c-muted); } .detail .meta { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.detail .source-target { color: var(--c-muted); }
main p a { overflow-wrap: anywhere; }
.section-gap { margin-top: 2rem; } .section-gap { margin-top: 2rem; }
.detail .notes { line-height: 1.65; } .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; } .detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
Executable
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# oci-image escape hatch (cluster #196) for a keep-Dockerfile repo.
#
# cms-plugins is an Astro/emdash WEB app: its production image is built by
# `npm ci` + `astro build` against an upstream package-lock.json with an
# apt-installed C toolchain (better-sqlite3 native build). It does NOT
# nixify and cannot be built on emmett/amd64 as Nix, so it stays on
# `docker buildx` instead of the parity-lib nix archetypes.
#
# This script is the single source of build truth: a developer runs it
# locally (default builder, dry-run) and CI runs the EXACT same script with
# a couple of env overrides, so CI and local can't drift (emmett#44).
#
# Builder is parameterized:
# BUILDKIT_ADDR default docker-container://local (dev: local buildkitd)
# CI overrides to the in-cluster native-arch remote, e.g.
# tcp://buildkit-arm64.infra.svc.cluster.local:1234
#
# kotkan (the deploy target) is arm64, so the default target arch is arm64.
# Foreign-arch (an arch != the host) is emulated via qemu binfmt and is
# SLOW — an explicit notice is printed and, for a remote NATIVE-arch
# builder, emulation is skipped. If a foreign arch cannot be built locally
# (no binfmt handler, no remote builder) the blocker is named + exit != 0.
#
# DRY-RUN by default. Pass --publish (or PUBLISH=1) to actually --push.
#
# Tagging contract (PRESERVED verbatim from the old .woodpecker pipeline):
# every PUBLISH pushes three refs onto the one image:
# $VERSION — semver-shaped, one per build, immutable (audit).
# Default 0.1.$CI_PIPELINE_NUMBER (override via VERSION).
# $BRANCH — floating channel pointer Flux's ImagePolicy tracks.
# Retagging it is what rolls staging/production pods.
# $BRANCH-latest — same image; chart image.tag cosmetic fallback.
# Only staging/production have an ImagePolicy, so only those move pods.
#
# Token: $REGISTRY_TOKEN, else `pass <PASS_ENTRY>`, else a named hard fail.
# The token is NEVER echoed and `set -x` is never enabled.
set -euo pipefail
# ---- configuration (override via env) ---------------------------------------
REGISTRY_HOST="${REGISTRY_HOST:-git.oleks.space}"
REGISTRY_OWNER="${REGISTRY_OWNER:-oleks}"
REGISTRY_USER="${REGISTRY_USER:-oleks}"
IMAGE_REPO="${IMAGE_REPO:-cms-plugins}"
PASS_ENTRY="${PASS_ENTRY:-infra/gitea/personal_access_token_packages_rw}"
# Local buildkitd by default; CI overrides to the cluster native-arch remote.
BUILDKIT_ADDR="${BUILDKIT_ADDR:-docker-container://local}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# ---- argument parsing -------------------------------------------------------
PUBLISH="${PUBLISH:-0}"
TARGET_ARCH=""
usage() {
cat <<'EOF'
Usage: ci/local.sh [--arch <arch>] [--publish]
--arch <arch> Target arch: amd64 | arm64 | s390x (default: arm64,
the deploy target kotkan's arch)
--publish Actually push to the registry (default: DRY-RUN)
Environment:
BUILDKIT_ADDR buildx builder endpoint
(default docker-container://local; CI -> cluster remote)
PUBLISH=1 same as --publish
REGISTRY_TOKEN registry token (else `pass PASS_ENTRY`)
VERSION immutable semver-shaped tag (default 0.1.$CI_PIPELINE_NUMBER)
CI_COMMIT_BRANCH branch -> floating channel tag (default current git branch)
Examples:
ci/local.sh # dry-run, arm64, both via local buildkit
ci/local.sh --arch amd64 # dry-run, foreign arch via qemu (slow)
PUBLISH=1 ci/local.sh # real push of the arm64 image (3 tags)
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--arch)
TARGET_ARCH="${2:-}"
shift 2
;;
--publish)
PUBLISH=1
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
# ---- arch resolution --------------------------------------------------------
host_arch() {
case "$(uname -m)" in
x86_64) echo amd64 ;;
aarch64 | arm64) echo arm64 ;;
s390x) echo s390x ;;
*) echo "unknown" ;;
esac
}
HOST_ARCH="$(host_arch)"
# kotkan is arm64; the image is only ever deployed there.
[ -n "$TARGET_ARCH" ] || TARGET_ARCH="arm64"
case "$TARGET_ARCH" in
amd64 | arm64 | s390x) ;;
*)
echo "ERROR: unsupported --arch '$TARGET_ARCH' (want amd64|arm64|s390x)" >&2
exit 2
;;
esac
# ---- version + branch derivation --------------------------------------------
# $VERSION -> $VERSION env -> 0.1.$CI_PIPELINE_NUMBER -> 0.1.0-dev.
derive_version() {
if [ -n "${VERSION:-}" ]; then
printf '%s' "$VERSION"
return
fi
if [ -n "${CI_PIPELINE_NUMBER:-}" ]; then
printf '0.1.%s' "$CI_PIPELINE_NUMBER"
return
fi
printf '0.1.0-dev'
}
# $CI_COMMIT_BRANCH -> current git branch -> "dev".
derive_branch() {
if [ -n "${CI_COMMIT_BRANCH:-}" ]; then
printf '%s' "$CI_COMMIT_BRANCH"
return
fi
local b
if b="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)" &&
[ -n "$b" ] && [ "$b" != "HEAD" ]; then
printf '%s' "$b"
return
fi
printf 'dev'
}
IMAGE_VERSION="$(derive_version)"
BRANCH="$(derive_branch)"
# ---- token resolution (never printed) ---------------------------------------
resolve_token() {
if [ -n "${REGISTRY_TOKEN:-}" ]; then
printf '%s' "$REGISTRY_TOKEN"
return 0
fi
if command -v pass >/dev/null 2>&1; then
local t
if t="$(pass "$PASS_ENTRY" 2>/dev/null)" && [ -n "$t" ]; then
printf '%s' "$t"
return 0
fi
fi
echo "BLOCKER: no registry token." >&2
echo " Set \$REGISTRY_TOKEN or store it at: pass $PASS_ENTRY" >&2
return 1
}
# ---- foreign-arch (qemu binfmt) gate ----------------------------------------
binfmt_handler_present() {
local q="$1"
[ -e "/proc/sys/fs/binfmt_misc/qemu-${q}" ]
}
qemu_name_for() {
case "$1" in
amd64) echo "x86_64" ;;
arm64) echo "aarch64" ;;
s390x) echo "s390x" ;;
esac
}
# Is BUILDKIT_ADDR the matching per-arch cluster builder (native, no qemu)?
builder_is_native_remote() {
case "$BUILDKIT_ADDR" in
tcp://*"buildkit-"*"${TARGET_ARCH}"*) return 0 ;;
*) return 1 ;;
esac
}
emulation_gate() {
if [ "$TARGET_ARCH" = "$HOST_ARCH" ]; then
return 0
fi
if builder_is_native_remote; then
echo "NOTE: target $TARGET_ARCH served NATIVELY by remote builder ($BUILDKIT_ADDR)."
return 0
fi
local q
q="$(qemu_name_for "$TARGET_ARCH")"
echo "================================================================"
echo " SLOW / ACCELERATOR NOTICE"
echo " Target arch '$TARGET_ARCH' != host '$HOST_ARCH'."
echo " This build runs under qemu-${q} binfmt emulation and will be"
echo " MUCH slower than a native build. For speed, point BUILDKIT_ADDR"
echo " at a native remote builder, e.g.:"
echo " BUILDKIT_ADDR=tcp://buildkit-rootless-${TARGET_ARCH}.infra.svc.cluster.local:1234"
echo "================================================================"
if ! binfmt_handler_present "$q"; then
echo "BLOCKER: cannot build '$TARGET_ARCH' locally — no qemu-${q} binfmt" >&2
echo " handler is registered with this kernel." >&2
echo " Fix one of:" >&2
echo " * register emulation: docker run --privileged --rm tonistiigi/binfmt --install ${TARGET_ARCH}" >&2
echo " * or use a native remote builder via BUILDKIT_ADDR (see notice above)." >&2
return 1
fi
return 0
}
# ---- buildx builder bootstrap ----------------------------------------------
BUILDER_NAME="cc-local-${TARGET_ARCH}"
ensure_builder() {
if docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
return 0
fi
case "$BUILDKIT_ADDR" in
docker-container://*)
docker buildx create --name "$BUILDER_NAME" \
--driver docker-container >/dev/null
;;
tcp://*)
local endpoint="${BUILDKIT_ADDR#tcp://}"
docker buildx create --name "$BUILDER_NAME" \
--driver remote "tcp://${endpoint}" >/dev/null
;;
*)
echo "ERROR: unrecognized BUILDKIT_ADDR scheme: $BUILDKIT_ADDR" >&2
echo " want docker-container://... or tcp://host:port" >&2
exit 2
;;
esac
}
# ---- build / publish --------------------------------------------------------
# Builds the single repo-root Dockerfile and (on --publish) pushes the three
# contract tags onto it in one buildx invocation.
build_image() {
local image="${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}"
local -a args=(
docker buildx build
--builder "$BUILDER_NAME"
--platform "linux/${TARGET_ARCH}"
-f "${REPO_ROOT}/Dockerfile"
-t "${image}:${IMAGE_VERSION}"
-t "${image}:${BRANCH}"
-t "${image}:${BRANCH}-latest"
)
if [ "$PUBLISH" = "1" ]; then
args+=(--push)
echo ">> PUBLISH $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest"
else
echo ">> DRY-RUN $image tags: ${IMAGE_VERSION}, ${BRANCH}, ${BRANCH}-latest (build-only; pass --publish to push)"
fi
args+=("$REPO_ROOT")
"${args[@]}"
}
# ---- main -------------------------------------------------------------------
main() {
echo "repo : ${REGISTRY_HOST}/${REGISTRY_OWNER}/${IMAGE_REPO}"
echo "host arch : ${HOST_ARCH}"
echo "target arch : ${TARGET_ARCH}"
echo "branch : ${BRANCH}"
echo "version tag : ${IMAGE_VERSION}"
echo "builder : ${BUILDKIT_ADDR}"
echo "mode : $([ "$PUBLISH" = "1" ] && echo PUBLISH || echo DRY-RUN)"
emulation_gate
if [ "$PUBLISH" = "1" ]; then
local token
token="$(resolve_token)"
echo "$token" | docker login "$REGISTRY_HOST" \
-u "$REGISTRY_USER" --password-stdin >/dev/null
unset token
fi
ensure_builder
build_image
echo "done."
}
main
+29
View File
@@ -50,3 +50,32 @@ The HelmRelease itself lives in the workloads repo because that repo is
the source of truth for what runs on the kotkanagrilli.fi subdomain the source of truth for what runs on the kotkanagrilli.fi subdomain
pool. Same convention as the existing `kotkanagrilli/` (legacy WP) and pool. Same convention as the existing `kotkanagrilli/` (legacy WP) and
`hello-kotkan/` entries there. `hello-kotkan/` entries there.
## Why two image automations share one branch
Both `cms-plugins-staging` and `cms-plugins-production` define an
`ImageUpdateAutomation` that checks out, commits to, and pushes the
**same** `main` branch of `anton-helm-workloads` on the same `interval: 1m`.
This is intentional and safe:
- Each automation is scoped to a disjoint `update.path`
(`./cms-plugins-staging` vs `./cms-plugins-production`), so they only ever
rewrite the digest setter inside their *own* `helmrelease.yaml`. They
never touch the same file.
- `strategy: Setters` rewrites only the explicitly marked digest setter, not
arbitrary YAML — there is no whole-file regeneration that could clobber a
sibling's change.
- The image-automation-controller serializes its git pushes and retries on
a non-fast-forward rejection, so two automations landing commits on `main`
in the same reconcile window resolve cleanly rather than racing.
This mirrors the per-env automations under
`~/projects/servers/fleet/apps/base/` for `emdash-kotkanagrilli-*`. The
only deviation (justified in `image-automation.yaml`) is that these reuse
the read-side `anton-helm-workloads` `GitRepository` as the write-back
`sourceRef` instead of a dedicated image-automation source, because these
workloads live in that same repo.
Note for go-live: nothing here reconciles while the HelmReleases are
`suspend: true` (Phase 0). These automations only begin writing back once
the releases are deliberately resumed.
@@ -32,6 +32,8 @@ spec:
# change when CI retags the floating `production` tag. # change when CI retags the floating `production` tag.
tag: production tag: production
digest: "" # {"$imagepolicy": "kotkan:cms-plugins-production:digest"} digest: "" # {"$imagepolicy": "kotkan:cms-plugins-production:digest"}
# digest-pinned below, so this is effectively a no-op (a digest is
# immutable); kept as Always to match the chart default.
pullPolicy: Always pullPolicy: Always
ingress: ingress:
host: cms-plugins-production.kotkanagrilli.fi host: cms-plugins-production.kotkanagrilli.fi
@@ -32,6 +32,8 @@ spec:
# change when CI retags the floating `staging` tag. # change when CI retags the floating `staging` tag.
tag: staging tag: staging
digest: "" # {"$imagepolicy": "kotkan:cms-plugins-staging:digest"} digest: "" # {"$imagepolicy": "kotkan:cms-plugins-staging:digest"}
# digest-pinned below, so this is effectively a no-op (a digest is
# immutable); kept as Always to match the chart default.
pullPolicy: Always pullPolicy: Always
ingress: ingress:
host: cms-plugins-staging.kotkanagrilli.fi host: cms-plugins-staging.kotkanagrilli.fi
+2
View File
@@ -69,6 +69,7 @@ spec:
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }} periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
readinessProbe: readinessProbe:
httpGet: httpGet:
path: {{ .Values.probes.readiness.path }} path: {{ .Values.probes.readiness.path }}
@@ -76,6 +77,7 @@ spec:
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }} periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
resources: resources:
{{- toYaml .Values.resources | nindent 12 }} {{- toYaml .Values.resources | nindent 12 }}
volumes: volumes:
+7 -2
View File
@@ -1,5 +1,10 @@
# Production overrides — applied via the FluxCD HelmRelease (or directly with # Production overrides for DIRECT `helm upgrade -f values-production.yaml` use only.
# `helm upgrade -f values-production.yaml`). #
# IMPORTANT: FluxCD does NOT read this file. The live production deploy is driven
# solely by the inline `spec.values:` block in
# deploy/fleet-overlay/cms-plugins-production/helmrelease.yaml (copied into
# anton-helm-workloads). Editing values here has NO effect on the cluster.
# Keep this file in sync with that HR `values:` block by hand, or it will drift.
image: image:
tag: production-latest tag: production-latest
+7 -2
View File
@@ -1,5 +1,10 @@
# Staging overrides — applied via the FluxCD HelmRelease (or directly with # Staging overrides for DIRECT `helm upgrade -f values-staging.yaml` use only.
# `helm upgrade -f values-staging.yaml`). #
# IMPORTANT: FluxCD does NOT read this file. The live staging deploy is driven
# solely by the inline `spec.values:` block in
# deploy/fleet-overlay/cms-plugins-staging/helmrelease.yaml (copied into
# anton-helm-workloads). Editing values here has NO effect on the cluster.
# Keep this file in sync with that HR `values:` block by hand, or it will drift.
image: image:
tag: staging-latest tag: staging-latest
+25 -7
View File
@@ -1,13 +1,21 @@
# Defaults for the cms-plugins chart. # Defaults for the cms-plugins chart.
# Per-env overrides come from values-staging.yaml / values-production.yaml # Per-env overrides: Flux applies ONLY the HelmRelease `values:` block.
# and from the FluxCD HelmRelease's `values:` block. # values-staging.yaml / values-production.yaml are for direct `helm upgrade -f`
# use and are NOT read by Flux — keep them in sync with the HR by hand.
image: image:
repository: git.oleks.space/oleks/cms-plugins repository: git.oleks.space/oleks/cms-plugins
tag: develop-latest tag: develop-latest
# The tag is a mutable floating pointer (CI retags <branch>-latest onto # `Always` is here for the chart-default FLOATING-TAG path: with no
# each new build), so kubelet must always re-pull — IfNotPresent would # `digest` set, the image renders as `repository:<branch>-latest`
# pin the node to whatever digest it cached first and never roll. # (a mutable pointer CI retags onto each build), so kubelet must
# re-pull or it would pin to the first cached digest and never roll.
# NOTE: the deployed overlays pin by `digest` (repository@sha256:…),
# where a tag change instead changes the image *reference string*, so
# `helm upgrade` already detects it and `Always` is a no-op (a digest
# is content-addressed — it can never resolve to different bytes).
# `IfNotPresent` would be marginally better on the digest path but is
# left as `Always` so both render paths share one safe value.
pullPolicy: Always pullPolicy: Always
service: service:
@@ -57,17 +65,27 @@ imagePullSecrets:
probes: probes:
liveness: liveness:
# /_emdash/api/health requires auth (401 to unauthenticated requests), # /_emdash/api/health requires auth (401 to unauthenticated requests),
# so kubelet probes fail and the pod gets killed. The site root is # so we probe the public site root instead. But `/` is server-rendered
# public and a 200 from it is a reasonable proxy for "the server is up". # and queries SQLite content, so a content/render or DB fault makes it
# 500 while the Node process is perfectly alive. Liveness must NOT
# crash-loop the single SQLite replica over a transient content/DB
# error: keep failureThreshold high so only a genuinely wedged process
# (sustained failures) triggers a restart. Readiness (below) is what
# sheds traffic on a content 500.
path: / path: /
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 5 timeoutSeconds: 5
failureThreshold: 6
readiness: readiness:
# Probe the public site root. A content/render 500 here removes the pod
# from Endpoints (stops serving 500s) WITHOUT the kubelet killing the
# process — readiness failures never restart the container.
path: / path: /
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5 timeoutSeconds: 5
failureThreshold: 3
resources: resources:
requests: requests:
+21 -5
View File
@@ -4,11 +4,27 @@ set -eu
# Ensure persistent state dirs exist (volume may be empty on first boot). # Ensure persistent state dirs exist (volume may be empty on first boot).
mkdir -p /app/state/uploads mkdir -p /app/state/uploads
# Bootstrap on first run: create data.db and apply migrations. # Run emdash init + seed on EVERY boot, before exec'ing the server. Both are
# emdash init is expected to be idempotent on subsequent boots. # idempotent: `init` runs only pending migrations (no-op when all applied) and
if [ ! -f /app/state/data.db ]; then # `seed` applies seed/seed.json with onConflict=skip (no-op once rows exist).
echo "[entrypoint] no data.db found in /app/state, running emdash init" # `init` does NOT load JSON seeds — that moved to the separate `emdash seed`
node_modules/.bin/emdash init # command — so without this seed step the catalog boots empty. Under `set -e` a
# non-zero exit aborts before `exec "$@"`, so a failed/partial init or seed
# surfaces as a crash-loop with logs instead of a silently half-set-up boot.
# (Gating on the mere presence of data.db would skip pending migrations on image
# upgrades against an existing PVC and never recover a partial first-run init.)
echo "[entrypoint] running emdash init (applies pending migrations)"
node_modules/.bin/emdash init
# Seed is best-effort: unlike migrations, a content-seed failure (e.g. a bad
# seed.json entry) must NOT crash-loop the whole site, so it is NOT under the
# `set -e` abort. We still surface a non-zero rc loudly in the logs rather than
# swallowing it silently.
echo "[entrypoint] running emdash seed (applies seed/seed.json, onConflict=skip)"
if node_modules/.bin/emdash seed; then
echo "[entrypoint] emdash seed ok"
else
echo "[entrypoint] WARNING: emdash seed failed (rc=$?) — serving without full seed" >&2
fi fi
exec "$@" exec "$@"