Initial Commit
This commit is contained in:
14
ops/registry/.dockerignore
Normal file
14
ops/registry/.dockerignore
Normal 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
32
ops/registry/.env.example
Normal 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
99
ops/registry/Caddyfile
Normal 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
25
ops/registry/Dockerfile
Normal 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
282
ops/registry/README.md
Normal 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`.
|
||||
56
ops/registry/docker-compose.yml
Normal file
56
ops/registry/docker-compose.yml
Normal 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
74
ops/registry/rollback.sh
Normal 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)"
|
||||
52
ops/registry/scripts/build-registry.ts
Normal file
52
ops/registry/scripts/build-registry.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user