Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
# Anything not strictly needed inside the image layer.
README.md
.env
.env.*
!.env.example
*.md
docker-compose.yml
.dockerignore
.git
.github
node_modules
*.log
*.tmp
rollback.sh

32
ops/registry/.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Example environment for the GitHub Actions registry-deploy workflow.
#
# Real values live in GitHub Actions secrets, NEVER in a committed file.
# This file documents the variable names and their shape so a human
# reading the repo can understand what the workflow needs without
# spelunking through .github/workflows/registry-deploy.yml.
#
# Set the real values via:
# gh secret set OVH_SSH_HOST --body "ovh.example.com"
# gh secret set OVH_SSH_USER --body "deploy-registry"
# gh secret set OVH_SSH_PRIVATE_KEY < ~/.ssh/registry_deploy
#
# See ops/registry/README.md "First-time OVH setup" for the full flow.
# OVH host the deploy SSHes into. Hostname or IPv4/IPv6 literal.
OVH_SSH_HOST=__OVH_HOST__
# Scoped deploy user on the OVH host. NOT root. The user is created
# during first-time setup with a sudoers rule that permits only
# `rsync` writes into /srv/registry/.
OVH_SSH_USER=deploy-registry
# Server-side paths the workflow rsyncs into. The container mounts
# /srv/registry read-only at runtime; only the host-side write path
# differs.
REGISTRY_DEPLOY_PATH=/srv/registry/r/
STORYBOOK_DEPLOY_PATH=/srv/registry/storybook/
# Snapshot directory for rollback. The workflow rsyncs the previous
# tree here before overwriting; rollback.sh promotes the most recent
# snapshot back to the live path.
SNAPSHOT_ROOT=/srv/registry/.previous/

99
ops/registry/Caddyfile Normal file
View File

@@ -0,0 +1,99 @@
# Caddyfile for ui.suaveplan.com — @suaveplan/react-ui registry + Storybook
#
# Serves two static trees off /srv/registry:
# /r/* shadcn registry-item JSON manifests
# /storybook/* hosted Storybook static build
#
# Auto-TLS via Let's Encrypt. TLS 1.2 minimum, HSTS preload-eligible,
# security headers on every response.
{
# Global options. ACME contact email; auto-HTTPS is on by default.
email ops@suaveplan.com
servers {
protocols h1 h2
}
}
ui.suaveplan.com {
root * /srv/registry
# TLS pinned to modern protocols only. TLS 1.0/1.1 are rejected.
tls {
protocols tls1.2 tls1.3
}
# Compression.
encode zstd gzip
# Security headers applied to every response.
# HSTS includes preload directive — domain is preload-list eligible
# after a 90-day soak of the live deployment.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "interest-cohort=()"
# Remove server fingerprinting.
-Server
}
# Health probe — always 200, no filesystem touch, no logs noise.
handle /health {
respond "ok" 200 {
close
}
}
# Block any non-ACME .well-known traffic. Caddy's internal ACME
# handler binds /.well-known/acme-challenge/* before this block runs;
# everything else under .well-known is denied.
@wellKnownNotAcme {
path /.well-known/*
not path /.well-known/acme-challenge/*
}
respond @wellKnownNotAcme 404
# Registry JSON tree. Public, CORS-open (manifests are static JSON
# fetched by `npx shadcn` from any origin). Short TTL so updated
# manifests propagate quickly to consumers; SWR keeps perceived
# latency low.
handle_path /r/* {
root * /srv/registry/r
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Access-Control-Allow-Headers "Content-Type"
Cache-Control "public, max-age=300, stale-while-revalidate=60"
Content-Type "application/json; charset=utf-8"
}
file_server {
precompressed br gzip
}
}
# Storybook static build. Restrictive CSP — Storybook needs inline
# styles and inline scripts for its own runtime, but third-party
# script sources are blocked.
handle_path /storybook/* {
root * /srv/registry/storybook
header {
Cache-Control "public, max-age=3600, stale-while-revalidate=600"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
}
try_files {path} {path}/ /index.html
file_server
}
# Catch-all 404 for anything outside /r, /storybook, /health.
handle {
respond "Not Found" 404
}
# Logs to stdout — `docker logs` captures.
log {
output stdout
format json
level INFO
}
}

25
ops/registry/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Static-only Caddy 2 image for ui.suaveplan.com.
#
# No build step — the registry and Storybook trees are bind-mounted at
# runtime from /srv/registry on the OVH host. Image carries only the
# Caddy binary plus the validated Caddyfile.
FROM caddy:2-alpine
# wget is in the base image; used by HEALTHCHECK below.
COPY Caddyfile /etc/caddy/Caddyfile
# Validate Caddyfile at build time so a syntactically broken config
# fails the image build, not the deploy.
RUN caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# Caddy needs both ports for ACME HTTP-01 challenge + HTTPS serve.
EXPOSE 80 443
# Health probe hits the static /health endpoint defined in the Caddyfile.
# Uses --no-check-certificate because the cert is for ui.suaveplan.com
# but the probe targets 127.0.0.1 inside the container — TLS chain
# validation is not the contract being checked here, response body is.
# 30 s interval, 10 s timeout, 3 retries before unhealthy.
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD wget -q -O- --no-check-certificate https://127.0.0.1/health | grep -q '^ok$' || exit 1

282
ops/registry/README.md Normal file
View File

@@ -0,0 +1,282 @@
# `ops/registry/` — `ui.suaveplan.com` deployment runbook
This directory ships the Docker + Caddy scaffolding that hosts the
`@suaveplan/react-ui` shadcn registry and Storybook static at
`https://ui.suaveplan.com`.
## What this is
Two static trees served from a single Caddy 2 container behind
auto-issued Let's Encrypt TLS:
| Path | Source on host | Purpose |
| --------------- | ------------------------------- | ----------------------------------------------------------- |
| `/r/*` | `/srv/registry/r/` | shadcn `registry-item` JSON manifests (`{name}.json`). |
| `/storybook/*` | `/srv/registry/storybook/` | Hosted Storybook static build of every published component. |
| `/health` | (in-process Caddy responder) | Returns `200 OK` body `ok` for health probes. |
Consumers point their `components.json` at `https://ui.suaveplan.com/r/{name}.json`
and run `npx shadcn@latest add @suaveplan/<component>` to source-eject
into their own `src/components/ui/` tree.
## File index
| File | Purpose |
| --------------------- | ------------------------------------------------------------------------------------ |
| `Dockerfile` | `caddy:2-alpine`, validated Caddyfile baked in, healthcheck on `/health`. |
| `Caddyfile` | `ui.suaveplan.com` site block; TLS 1.2+; HSTS preload; `/r/*` + `/storybook/*` + `/health`. |
| `docker-compose.yml` | Single `registry` service; bind-mounts `/srv/registry`; named cert volumes; `restart: unless-stopped`. |
| `.env.example` | Placeholder env. Real values live in GitHub Actions secrets — never commit them. |
| `rollback.sh` | One-shot atomic-swap of live tree with most recent snapshot. Idempotent. |
| `scripts/build-registry.ts` | Bun script that emits the per-component JSON tree from `@suaveplan/react-ui` source. |
## First-time OVH setup (USER MANUAL — required before first deploy)
These steps run ONCE when bringing the deployment online. They cannot
be scripted in this change because they install secrets and create a
host-side user. Run them in order.
### 1. SSH to OVH as root and create the scoped deploy user
```bash
ssh root@ovh
# Create a system user with a real shell so rsync over ssh works.
useradd -r -m -s /bin/bash deploy-registry
# Allow the user to run rsync into /srv/registry only — no shell sudo.
cat >/etc/sudoers.d/deploy-registry <<'EOF'
deploy-registry ALL=(root) NOPASSWD: /usr/bin/rsync --server -* /srv/registry/*
EOF
chmod 0440 /etc/sudoers.d/deploy-registry
visudo -c -f /etc/sudoers.d/deploy-registry # validates syntax
# Add to docker group so the user can run `docker compose ...` for
# the rare in-place config change. The deploy itself does NOT need
# docker — rsync into the bind-mounted directory is enough.
usermod -aG docker deploy-registry
```
### 2. Lay out the host directory tree
```bash
mkdir -p /srv/registry/{r,storybook,.previous,ops}
chown -R deploy-registry:deploy-registry /srv/registry/r /srv/registry/storybook /srv/registry/.previous
# Ops files (Dockerfile, Caddyfile, docker-compose.yml, rollback.sh)
# stay root-owned; deploy-registry has no business overwriting them.
chown -R root:root /srv/registry/ops
```
### 3. Copy the ops scaffolding to the host
From your laptop (not from the OVH host):
```bash
rsync -avz --delete \
ops/registry/Dockerfile \
ops/registry/Caddyfile \
ops/registry/docker-compose.yml \
ops/registry/rollback.sh \
ops/registry/.env.example \
root@ovh:/srv/registry/ops/
ssh root@ovh "chmod +x /srv/registry/ops/rollback.sh"
```
### 4. Bring up Caddy
```bash
ssh root@ovh
cd /srv/registry/ops
docker compose up -d --build
# Wait 30–60 s for Let's Encrypt to issue the cert. Then verify:
curl -fsSL https://ui.suaveplan.com/health # → ok
docker compose ps # status: healthy
```
If the cert does not issue:
```bash
docker compose logs --tail=200 registry | grep -iE 'acme|cert|tls'
```
The most common cause is port 80 not being open from the public
internet — Let's Encrypt's HTTP-01 challenge needs it.
### 5. Generate the CI deploy SSH keypair
On your laptop:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/registry_deploy -C "github-actions-registry-deploy"
# Install the public key for the deploy user.
ssh-copy-id -i ~/.ssh/registry_deploy.pub deploy-registry@ovh
# Verify it works AND that root login is unaffected.
ssh -i ~/.ssh/registry_deploy deploy-registry@ovh whoami # → deploy-registry
```
### 6. Add the secrets to GitHub Actions
```bash
# Run from inside the genesis checkout.
gh secret set OVH_SSH_HOST --body "ovh.example.com"
gh secret set OVH_SSH_USER --body "deploy-registry"
gh secret set OVH_SSH_PRIVATE_KEY < ~/.ssh/registry_deploy
# Confirm three secrets are set.
gh secret list | grep -E '^OVH_'
```
### 7. Verify DNS
```bash
dig +short ui.suaveplan.com
# Should print the OVH host's public IP.
```
The first push to `genesis` that touches `packages/react/react-ui/**`
or `ops/registry/**` triggers the `registry-deploy` workflow and
publishes to the live host.
## On-deploy flow (CI)
1. GitHub Actions `registry-deploy` workflow fires on push to `genesis`
when paths under `packages/react/react-ui/**`,
`packages/react/react-ui-theme-*/**`,
`packages/react/react-ui-pack-*/**`, or `ops/registry/**` change.
2. The `build` job runs `bun install --frozen-lockfile`, builds the
registry JSON via `bun run ops/registry/scripts/build-registry.ts`,
builds the Storybook static, and uploads the staged artifact.
3. The `deploy` job downloads the artifact, configures the SSH
private key from the `OVH_SSH_PRIVATE_KEY` secret, and rsyncs both
trees to the OVH host as `deploy-registry`. The previous live
contents are snapshotted to
`/srv/registry/.previous/<utc-timestamp>/` via rsync's
`--backup --backup-dir=...` flags.
4. A post-deploy probe hits `https://ui.suaveplan.com/health` up to
five times; the workflow fails if none succeed.
Caddy serves the new files immediately — no `caddy reload` is needed
for static-file changes. Reload is only required when `Caddyfile`
itself changes; in that case bump the image and `docker compose up
-d --build` from the OVH host.
## Rollback
The previous live tree is preserved at
`/srv/registry/.previous/<utc-timestamp>/` after every deploy.
`rollback.sh` atomic-swaps the current tree with the most recent
snapshot:
```bash
ssh root@ovh "bash /srv/registry/ops/rollback.sh"
```
Re-running the script roll-forwards (i.e., promotes the just-parked
tree back to live). The script exits non-zero with a clear message
if no snapshot exists.
## Cert renewal
Caddy auto-renews ~30 days before expiry. To verify:
```bash
ssh root@ovh "docker compose -f /srv/registry/ops/docker-compose.yml exec registry \
caddy list-certificates --address localhost:2019" 2>&1 | grep -A1 ui.suaveplan.com
```
To force-renew (rare; only if a CT-log issue or a botched serial):
```bash
ssh root@ovh "docker compose -f /srv/registry/ops/docker-compose.yml restart registry"
```
Cert state lives in the `caddy_data` named volume so a restart does
not re-trigger ACME unless the cert is genuinely expired.
## Log inspection
```bash
# Recent access + error logs.
ssh root@ovh "docker logs --tail=500 suaveplan-registry"
# Live tail.
ssh root@ovh "docker logs -f --tail=50 suaveplan-registry"
# 5xx grep across the last 24 h (assumes JSON log driver).
ssh root@ovh "docker logs --since=24h suaveplan-registry 2>&1 \
| grep -E '\"status\":(5[0-9]{2})'"
```
## Troubleshooting
| Symptom | Likely cause | Fix |
| ------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `curl https://ui.suaveplan.com/health` hangs | Caddy not running or firewall blocking 443. | `docker compose ps`; check `iptables -L -n -v` for `DROP` on tcp/443. |
| 502 Bad Gateway | Should not happen for static file serving. Caddy crashed. | `docker logs suaveplan-registry`; restart: `docker compose restart registry`. |
| 503 Service Unavailable | Caddy is starting up or healthcheck is failing. | Wait 15 s for `start_period`; if it persists, inspect `docker logs`. |
| 504 Gateway Timeout | Static file serve does not produce 504. Check upstream proxy if any. | Confirm there is no Cloudflare or other reverse proxy in front of OVH. |
| Cert renewal fails | ACME HTTP-01 challenge can't reach port 80. | Verify port 80 is open publicly; check no other process binds it. |
| GH Action `Permission denied (publickey)` | Public key not in `deploy-registry`'s `authorized_keys`, or key rotated. | Re-run `ssh-copy-id`; re-add `OVH_SSH_PRIVATE_KEY` secret. |
| Empty registry served (`{ "items": [] }`) | `@suaveplan/react-ui` not yet present on genesis (pre-Wave-2). | Expected; the script emits an empty index when no source components exist. No action. |
## Manual ad-hoc admin
`ssh root@ovh` remains available for any out-of-band admin work
(restarting Docker, inspecting filesystem state, editing the Caddyfile
in place during incident response). CI deliberately does not use root
to keep its blast radius minimal — even if the CI SSH key leaks, the
attacker can only overwrite the public registry contents, not own the
host.
## Secret rotation
To rotate the deploy SSH key:
```bash
# 1. Generate a new keypair locally.
ssh-keygen -t ed25519 -f ~/.ssh/registry_deploy_v2 -C "github-actions-registry-deploy-v2"
# 2. Add the new public key to OVH alongside the old one.
ssh-copy-id -i ~/.ssh/registry_deploy_v2.pub deploy-registry@ovh
# 3. Replace the secret in GitHub Actions.
gh secret set OVH_SSH_PRIVATE_KEY < ~/.ssh/registry_deploy_v2
# 4. Trigger a deploy (push or `gh workflow run registry-deploy.yml`).
# Confirm the workflow succeeds.
# 5. Remove the old public key from OVH.
ssh deploy-registry@ovh "sed -i '/registry-deploy[^-]/d' ~/.ssh/authorized_keys"
```
To rotate `OVH_SSH_HOST` (host migration) or `OVH_SSH_USER` (user
rename): set the new value via `gh secret set` then trigger a
deploy. The next workflow run picks the updated secret automatically.
## Security model
- **Read-only registry.** Caddy serves files only; there is no write
endpoint exposed publicly. The bind mount is `:ro`.
- **Scoped non-root deploy user.** `deploy-registry` has a single
sudoers rule limited to `rsync` writes into `/srv/registry/`. No
shell sudo, no general docker control beyond restart.
- **TLS 1.2 minimum.** The Caddyfile pins `protocols tls1.2 tls1.3`.
TLS 1.0 and 1.1 are rejected by the server.
- **HSTS preload-eligible.** `Strict-Transport-Security: max-age=31536000;
includeSubDomains; preload`. After a 90-day soak with no downgrade
incidents, submit the domain to <https://hstspreload.org/>.
- **Header hardening.** Every response carries `X-Content-Type-Options:
nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`,
and `Permissions-Policy: interest-cohort=()`. Storybook adds a
restrictive Content-Security-Policy.
- **No `.well-known` leakage.** Caddy's ACME handler binds
`/.well-known/acme-challenge/*` internally; everything else under
`.well-known` is denied.
- **No secrets in committed files.** `.env.example` carries
placeholders only. Real values live in GitHub Actions secrets named
`OVH_SSH_HOST`, `OVH_SSH_USER`, `OVH_SSH_PRIVATE_KEY`.

View File

@@ -0,0 +1,56 @@
# Single-service Docker Compose for ui.suaveplan.com.
#
# The Caddy container serves /srv/registry from the OVH host as
# read-only. Cert state survives container restarts via the named
# `caddy_data` and `caddy_config` volumes — restarting the container
# does NOT trigger an ACME re-issuance.
#
# Usage on the OVH host:
# cp .env.example .env # fill in placeholder values if any deploy
# # script reads them; runtime needs none.
# docker compose up -d
#
# Verify:
# curl -fsSL https://ui.suaveplan.com/health # → "ok"
# docker compose ps # status: healthy
services:
registry:
build:
context: .
dockerfile: Dockerfile
image: suaveplan/registry:latest
container_name: suaveplan-registry
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
# Static content tree — populated by the GitHub Actions deploy
# job via rsync. Read-only inside the container; Caddy never
# mutates the served files.
- /srv/registry:/srv/registry:ro
# Cert state and config cache. Persists across container
# rebuilds so Let's Encrypt isn't re-hit on every deploy.
- caddy_data:/data
- caddy_config:/config
environment:
# No secrets here. ACME contact email is baked into Caddyfile.
TZ: UTC
healthcheck:
test: ["CMD-SHELL", "wget -q -O- --no-check-certificate https://127.0.0.1/health | grep -q '^ok$$' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
volumes:
caddy_data:
name: suaveplan_registry_caddy_data
caddy_config:
name: suaveplan_registry_caddy_config

74
ops/registry/rollback.sh Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# rollback.sh — atomic-swap the live registry tree with the most
# recent snapshot under /srv/registry/.previous/.
#
# Usage (on the OVH host, as deploy-registry or root):
# bash /srv/registry/ops/rollback.sh
#
# Idempotent within reason: a second invocation immediately after the
# first promotes what was previously live (effectively roll-forward).
# Exits non-zero with a clear message if no snapshot exists.
set -euo pipefail
REGISTRY_ROOT="${REGISTRY_ROOT:-/srv/registry}"
LIVE_R="${REGISTRY_ROOT}/r"
LIVE_SB="${REGISTRY_ROOT}/storybook"
SNAPSHOT_ROOT="${REGISTRY_ROOT}/.previous"
ROLLBACK_PARK="${REGISTRY_ROOT}/.rollback-park"
log() {
printf '[rollback %s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2
}
err() {
log "ERROR: $*"
exit 1
}
if [[ ! -d "${SNAPSHOT_ROOT}" ]]; then
err "no snapshot directory at ${SNAPSHOT_ROOT}; nothing to roll back to"
fi
# Pick the most recent snapshot directory by name (ISO-8601 lexically sorts).
LATEST="$(find "${SNAPSHOT_ROOT}" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort | tail -n 1)"
if [[ -z "${LATEST}" ]]; then
err "no snapshots present under ${SNAPSHOT_ROOT}; cannot roll back"
fi
SNAPSHOT_DIR="${SNAPSHOT_ROOT}/${LATEST}"
log "rolling back to snapshot: ${SNAPSHOT_DIR}"
# Park the current tree as the next-snapshot so we can roll-forward
# by re-running the script.
PARK_DIR="${ROLLBACK_PARK}/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "${PARK_DIR}"
if [[ -d "${LIVE_R}" ]]; then
mv "${LIVE_R}" "${PARK_DIR}/r"
fi
if [[ -d "${LIVE_SB}" ]]; then
mv "${LIVE_SB}" "${PARK_DIR}/storybook"
fi
# Promote snapshot to live.
if [[ -d "${SNAPSHOT_DIR}/r" ]]; then
mv "${SNAPSHOT_DIR}/r" "${LIVE_R}"
fi
if [[ -d "${SNAPSHOT_DIR}/storybook" ]]; then
mv "${SNAPSHOT_DIR}/storybook" "${LIVE_SB}"
fi
# Move the parked tree into the snapshots dir so the next rollback
# call (a roll-forward) can find it.
NEW_SNAPSHOT="${SNAPSHOT_ROOT}/$(basename "${PARK_DIR}")"
mv "${PARK_DIR}" "${NEW_SNAPSHOT}"
# Drained snapshot dir is now empty — clean up so next rollback picks
# the right "latest".
rmdir "${SNAPSHOT_DIR}" 2>/dev/null || true
rmdir "${ROLLBACK_PARK}" 2>/dev/null || true
log "rollback complete; live tree now serves contents from ${LATEST}"
log "previous live tree parked at ${NEW_SNAPSHOT} (re-run to roll forward)"

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bun
/**
* Backward-compatibility shim. The real registry generator now lives at
* `packages/react/react-ui/scripts/build-registry.ts` (per Wave 2 of
* `enterprise-react-ui-v2`). This shim re-invokes it via Bun's
* import-and-call entry point so any existing CI / docker pipelines
* pointing at the ops path continue to function unchanged.
*
* The ops directory still owns the Caddy / Docker / rsync deployment
* stack — only the source-of-truth for the registry GENERATION moved
* into the chassis package.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
build,
parseArgs,
} from "../../../packages/react/react-ui/scripts/build-registry.js";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..", "..", "..");
const DEFAULT_COMPONENTS_DIR = resolve(
REPO_ROOT,
"packages",
"react",
"react-ui",
"src",
"components"
);
const DEFAULT_OUT_DIR = resolve(
REPO_ROOT,
"packages",
"react",
"react-ui",
"registry",
"r"
);
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
await build({
componentsDir: parsed.componentsDir ?? DEFAULT_COMPONENTS_DIR,
outDir: parsed.outDir ?? DEFAULT_OUT_DIR,
});
}
await main().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`build-registry FAILED: ${message}\n`);
process.exit(1);
});