Compare commits
14 Commits
production
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be268307e | |||
| 0072716733 | |||
| 9b1090b614 | |||
| 87eb6a0f84 | |||
| 96c220825f | |||
| 7b60fe452e | |||
| 597f089c53 | |||
| c284c27aa8 | |||
| d8de7617fb | |||
| 0b1f2ebfbc | |||
| 90a4b8088b | |||
| 6e6fd76459 | |||
| 8c119efff8 | |||
| 0c2cea8c25 |
+15
-37
@@ -5,6 +5,14 @@
|
||||
# repo → pod rolls), and <branch>-latest (same image; chart image.tag
|
||||
# fallback). Only staging/production have an ImagePolicy, so only those
|
||||
# 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:
|
||||
# kotkan (the deploy target) is an arm64 host, so we build natively on
|
||||
@@ -32,19 +40,15 @@ steps:
|
||||
environment:
|
||||
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:
|
||||
- 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)"'
|
||||
|
||||
- echo "▸ arch=$(uname -m)"
|
||||
# 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"
|
||||
echo "Waiting for buildkit at $BUILDER_HOST:$BUILDER_PORT..."
|
||||
for i in $(seq 1 30); do
|
||||
@@ -54,33 +58,7 @@ steps:
|
||||
[ "$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"'
|
||||
|
||||
- ci/local.sh --arch arm64
|
||||
backend_options:
|
||||
kubernetes:
|
||||
nodeSelector:
|
||||
|
||||
+17
-4
@@ -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
|
||||
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`.
|
||||
Per-env values come from **two independent sources** that must be kept in
|
||||
sync by hand:
|
||||
|
||||
- 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:
|
||||
|
||||
- **`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
|
||||
is colocated with the legacy kotkanagrilli WP install.
|
||||
- **Pinned by digest, not tag.** The HelmRelease sets
|
||||
|
||||
@@ -53,7 +53,12 @@ cd app && npm install && npm run bootstrap && npx emdash dev
|
||||
# Build production image
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
+16
-2
@@ -225,10 +225,24 @@ Both should resolve to the `kotkan` ingress IP (the same one
|
||||
|
||||
### D. First deploy
|
||||
|
||||
1. Push to `staging` to trigger the first build. Watch Woodpecker.
|
||||
2. Once the image lands, Flux reconciles. Watch the rollout:
|
||||
> **Go-live prerequisite — floating tag must exist (issue #3).** While the
|
||||
> 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
|
||||
flux resume helmrelease cms-plugins-staging -n kotkan
|
||||
kubectl -n kotkan get pods -w | grep cms-plugins
|
||||
flux get all -n flux-system | grep cms-plugins
|
||||
```
|
||||
|
||||
+28
-9
@@ -2,30 +2,43 @@
|
||||
|
||||
FROM node:22-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
# bookworm-slim: track the distro's current security-patched versions, don't pin.
|
||||
# hadolint ignore=DL3008
|
||||
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
|
||||
COPY app/package.json app/package-lock.json ./
|
||||
# Lockfile (lockfileVersion 3) is committed; npm ci is reproducible and
|
||||
# fails if it drifts from package.json. No npm install fallback.
|
||||
RUN npm ci --include=dev
|
||||
|
||||
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
|
||||
RUN npm prune --omit=dev
|
||||
# `astro` is a runtime dependency (required by the @astrojs/node standalone
|
||||
# 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
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=4321
|
||||
# bookworm-slim: track the distro's current security-patched versions, don't pin.
|
||||
# hadolint ignore=DL3008
|
||||
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 \
|
||||
&& groupadd --system --gid 1001 emdash \
|
||||
&& useradd --system --uid 1001 --gid 1001 --home /app emdash \
|
||||
&& mkdir -p /app/state/uploads \
|
||||
&& 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
|
||||
|
||||
# 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.
|
||||
# STATE_DIR is intentionally NOT set here: the Helm chart injects
|
||||
# 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 \
|
||||
&& ln -s /app/state/uploads /app/uploads
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ export default defineConfig({
|
||||
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.
|
||||
// Dev-only: `vite.server` applies to `astro dev` / `emdash dev`
|
||||
// (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"],
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+10
-11
@@ -14,7 +14,8 @@
|
||||
"emdash": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.7"
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@types/node": "^22"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/check": {
|
||||
@@ -3607,13 +3608,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -9505,11 +9505,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@
|
||||
"emdash": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.7"
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@types/node": "^22"
|
||||
}
|
||||
}
|
||||
|
||||
+551
-95
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ interface Props {
|
||||
data: {
|
||||
title: string;
|
||||
purpose?: string | null;
|
||||
status?: string | null;
|
||||
parity_status?: string | null;
|
||||
source_cms?: string | null;
|
||||
target_cms?: string | null;
|
||||
};
|
||||
@@ -19,8 +19,8 @@ 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>}
|
||||
<StatusBadge status={d.parity_status} />
|
||||
{d.source_cms && <span>{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
|
||||
</div>
|
||||
{d.purpose && <p>{d.purpose}</p>}
|
||||
</li>
|
||||
|
||||
@@ -8,4 +8,4 @@ const value = (status ?? "proposed").toLowerCase();
|
||||
const label = STATUS_LABELS[value] ?? value;
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
---
|
||||
import StatusBadge from "./StatusBadge.astro";
|
||||
import { STATUSES } from "../lib/statuses";
|
||||
interface Props {
|
||||
items?: typeof STATUSES;
|
||||
}
|
||||
const { items = STATUSES } = Astro.props;
|
||||
---
|
||||
<div class="legend">
|
||||
{STATUSES.map((i) => (
|
||||
{items.map((i) => (
|
||||
<span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Vendored
+1
-1
@@ -28,7 +28,7 @@ declare module "emdash" {
|
||||
source_cms: string;
|
||||
target_cms?: string | null;
|
||||
category?: string | null;
|
||||
status: string;
|
||||
parity_status: string;
|
||||
source_repo_url?: string | null;
|
||||
target_repo_url?: string | null;
|
||||
notes?: import("emdash").PortableTextBlock[] | null;
|
||||
|
||||
@@ -19,7 +19,12 @@ const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity cat
|
||||
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
|
||||
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 pageCtx = createPublicPageContext({
|
||||
|
||||
@@ -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(", "),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export const PLUGIN_FETCH_CAP = 10000;
|
||||
|
||||
export interface StatusDef {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -5,13 +7,13 @@ export interface StatusDef {
|
||||
}
|
||||
|
||||
export const STATUSES: StatusDef[] = [
|
||||
{ value: "port", label: "Port", desc: "must be reimplemented on the target CMS" },
|
||||
{ value: "built-in", label: "Built-in", desc: "covered by target CMS core / framework" },
|
||||
{ value: "saas", label: "SaaS", desc: "replaced by an external service" },
|
||||
{ value: "drop", label: "Drop", desc: "not needed on the target CMS" },
|
||||
{ value: "gated", label: "Gated", desc: "fate depends on an unresolved decision" },
|
||||
{ value: "done", label: "Done", desc: "ported and shipped" },
|
||||
{ value: "proposed", label: "Proposed", desc: "submitted for cataloging" },
|
||||
{ value: "port", label: "Port", desc: "Reimplemented as a new Emdash plugin" },
|
||||
{ value: "built-in", label: "Built-in", desc: "Already covered by Emdash core" },
|
||||
{ value: "saas", label: "SaaS", desc: "Replaced by an external hosted service" },
|
||||
{ value: "drop", label: "Drop", desc: "Not needed after migration" },
|
||||
{ value: "gated", label: "Gated", desc: "Blocked on an open decision" },
|
||||
{ value: "done", label: "Done", desc: "Ported and shipped" },
|
||||
{ value: "proposed", label: "Proposed", desc: "Newly added, not yet classified" },
|
||||
];
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
|
||||
|
||||
@@ -3,8 +3,23 @@ import { getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
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;
|
||||
|
||||
// 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 { entry, cacheHint } = slug
|
||||
? await getEmDashEntry("pages", slug)
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import PluginCard from "../../components/PluginCard.astro";
|
||||
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
|
||||
import { normCms } from "../../lib/cms";
|
||||
|
||||
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 { entry: cms, cacheHint } = slug
|
||||
? await getEmDashEntry("cmses", slug)
|
||||
@@ -14,14 +22,17 @@ if (!slug || !cms) {
|
||||
}
|
||||
if (cacheHint) Astro.cache.set(cacheHint);
|
||||
|
||||
const PLUGIN_FETCH_CAP = 10000;
|
||||
const { entries: all } = cms
|
||||
? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
|
||||
: { entries: [] };
|
||||
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 fromHere = all.filter((p) => p.data.source_cms === cmsName);
|
||||
const targetingHere = all.filter((p) => p.data.target_cms === cmsName);
|
||||
// Normalized match (see lib/cms.ts / issue #4): a plugin whose source_cms
|
||||
// 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 ? (
|
||||
<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>}
|
||||
|
||||
<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 ? (
|
||||
<p class="empty">No plugins cataloged for this CMS yet.</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
|
||||
import { normCms, warnOrphanCmsRefs } from "../../lib/cms";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -9,25 +11,28 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
|
||||
});
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const PLUGIN_FETCH_CAP = 10000;
|
||||
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");
|
||||
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 countByTarget = 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);
|
||||
if (s) countBySource.set(normCms(s), (countBySource.get(normCms(s)) ?? 0) + 1);
|
||||
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.">
|
||||
<h1>By CMS</h1>
|
||||
<h2 class="sr-only">CMSes</h2>
|
||||
<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} 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>}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -3,13 +3,15 @@ import { getEmDashCollection } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import PluginCard from "../components/PluginCard.astro";
|
||||
import StatusLegend from "../components/StatusLegend.astro";
|
||||
import { STATUSES } from "../lib/statuses";
|
||||
import { STATUSES, PLUGIN_FETCH_CAP } from "../lib/statuses";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", {
|
||||
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);
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
@@ -18,44 +20,45 @@ 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 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 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 (statusFilter && d.parity_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.">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
{sources.map((s) => (
|
||||
<option value={s} selected={s === sourceFilter}>{s}</option>
|
||||
))}
|
||||
</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>
|
||||
{statuses.map((s) => (
|
||||
<option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Apply</button>
|
||||
{(q || statusFilter || sourceFilter) && <a href="/">Reset</a>}
|
||||
{(q || statusFilter || sourceFilter) && <a href="/">Clear all</a>}
|
||||
</form>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<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">
|
||||
{filtered.map((entry) => <PluginCard entry={entry} />)}
|
||||
</ul>
|
||||
|
||||
@@ -3,9 +3,16 @@ import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import StatusBadge from "../../components/StatusBadge.astro";
|
||||
import { cmsSlugByTitle, resolveCmsSlug } from "../../lib/cms";
|
||||
|
||||
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 { entry, cacheHint } = slug
|
||||
? await getEmDashEntry("plugins", slug)
|
||||
@@ -18,8 +25,10 @@ if (cacheHint) Astro.cache.set(cacheHint);
|
||||
const d = entry?.data;
|
||||
|
||||
const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] };
|
||||
const cmsSlugByTitle = new Map(cmses.map((c) => [c.data.title as string, c.id]));
|
||||
const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefined;
|
||||
// Shared normalized join (lib/cms.ts / issue #4) — same match the CMS pages use.
|
||||
const cmsIndex = cmsSlugByTitle(cmses);
|
||||
const sourceCmsSlug = resolveCmsSlug(d?.source_cms, cmsIndex);
|
||||
const targetCmsSlug = resolveCmsSlug(d?.target_cms, cmsIndex);
|
||||
---
|
||||
{!entry || !d ? (
|
||||
<Base title="Not found">
|
||||
@@ -31,7 +40,7 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
|
||||
title={d.title}
|
||||
description={d.purpose ?? undefined}
|
||||
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">
|
||||
<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>}</>)}
|
||||
</p>
|
||||
<h1>{d.title}</h1>
|
||||
<p>
|
||||
<StatusBadge status={d.status} />
|
||||
<p class="meta">
|
||||
<StatusBadge status={d.parity_status} />
|
||||
{d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
|
||||
</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>
|
||||
{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_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>{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.target_repo_url && (<><dt>Target repo</dt><dd><a href={d.target_repo_url}>{d.target_repo_url}</a></dd></>)}
|
||||
</dl>
|
||||
|
||||
@@ -70,7 +70,7 @@ header.site {
|
||||
background: var(--c-bg);
|
||||
}
|
||||
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:hover { text-decoration: none; }
|
||||
@@ -85,6 +85,8 @@ footer.site {
|
||||
color: var(--c-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
footer.site .container { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
footer.site p { margin: 0; }
|
||||
|
||||
/* status badge */
|
||||
.badge {
|
||||
@@ -115,14 +117,15 @@ footer.site {
|
||||
.toolbar input[type="search"], .toolbar select {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
background: white;
|
||||
}
|
||||
.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 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 {
|
||||
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 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 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 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 dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
Executable
+305
@@ -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
|
||||
@@ -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
|
||||
pool. Same convention as the existing `kotkanagrilli/` (legacy WP) and
|
||||
`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.
|
||||
tag: production
|
||||
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
|
||||
ingress:
|
||||
host: cms-plugins-production.kotkanagrilli.fi
|
||||
|
||||
@@ -32,6 +32,8 @@ spec:
|
||||
# change when CI retags the floating `staging` tag.
|
||||
tag: staging
|
||||
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
|
||||
ingress:
|
||||
host: cms-plugins-staging.kotkanagrilli.fi
|
||||
|
||||
@@ -69,6 +69,7 @@ spec:
|
||||
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: {{ .Values.probes.readiness.path }}
|
||||
@@ -76,6 +77,7 @@ spec:
|
||||
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Production overrides — applied via the FluxCD HelmRelease (or directly with
|
||||
# `helm upgrade -f values-production.yaml`).
|
||||
# Production overrides for DIRECT `helm upgrade -f values-production.yaml` use only.
|
||||
#
|
||||
# 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:
|
||||
tag: production-latest
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Staging overrides — applied via the FluxCD HelmRelease (or directly with
|
||||
# `helm upgrade -f values-staging.yaml`).
|
||||
# Staging overrides for DIRECT `helm upgrade -f values-staging.yaml` use only.
|
||||
#
|
||||
# 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:
|
||||
tag: staging-latest
|
||||
|
||||
+25
-7
@@ -1,13 +1,21 @@
|
||||
# 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.
|
||||
# Per-env overrides: Flux applies ONLY the HelmRelease `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:
|
||||
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.
|
||||
# `Always` is here for the chart-default FLOATING-TAG path: with no
|
||||
# `digest` set, the image renders as `repository:<branch>-latest`
|
||||
# (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
|
||||
|
||||
service:
|
||||
@@ -57,17 +65,27 @@ imagePullSecrets:
|
||||
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".
|
||||
# so we probe the public site root instead. But `/` is server-rendered
|
||||
# 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: /
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
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: /
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
||||
+20
-4
@@ -4,11 +4,27 @@ 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"
|
||||
# Run emdash init + seed on EVERY boot, before exec'ing the server. Both are
|
||||
# idempotent: `init` runs only pending migrations (no-op when all applied) and
|
||||
# `seed` applies seed/seed.json with onConflict=skip (no-op once rows exist).
|
||||
# `init` does NOT load JSON seeds — that moved to the separate `emdash seed`
|
||||
# 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
|
||||
|
||||
exec "$@"
|
||||
|
||||
Reference in New Issue
Block a user