handoff pass: vendored sveltia bundle, astro pinned ^7.2.10, root README + refreshed deploy runbook, review fixes (coverless card grid, shared NewsCard/lib, real site origin, nginx security headers, linger step, dead config removed, preview.css sync, tsconfig)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Build image for the CTAO portal demo: pinned Node + git.
|
||||
# Why: the host has node 24 but NO git (and we have no sudo); a 2-line image
|
||||
# keeps the whole toolchain pinned and independent of host packages.
|
||||
# keeps node pinned and independent of host packages (git comes from the
|
||||
# alpine repo unpinned — alpine drops old package versions, pinning is moot).
|
||||
# Built ONCE at install, never pulled again at runtime:
|
||||
# podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .
|
||||
FROM docker.io/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
|
||||
|
||||
+54
-39
@@ -11,62 +11,68 @@ Uninstall restores the machine exactly (see bottom). No secrets in any file.
|
||||
├── repo/ # clone of the portal repo (created by first build)
|
||||
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
|
||||
├── state/ # last-built SHA
|
||||
├── npm-cache/ # npm cache for the build container (created by build.sh)
|
||||
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo)
|
||||
└── config/nginx.conf
|
||||
```
|
||||
|
||||
| Port | What | Exposed how |
|
||||
Host prerequisites: `podman`, `curl`, `jq` (build.sh checks and says which is
|
||||
missing). Everything else runs inside containers.
|
||||
|
||||
| Port | What | Exposed as |
|
||||
|---|---|---|
|
||||
| 3000 | Gitea | ingress vhost (TODO: ask Hubert) |
|
||||
| 8080 | portal (nginx, static) | ingress vhost (TODO: ask Hubert) |
|
||||
| 3000 | Gitea | https://astro-git.isl-dev.grid.cyfronet.pl (ingress vhost) |
|
||||
| 8080 | portal (nginx, static) | https://astro.isl-dev.grid.cyfronet.pl (ingress vhost) |
|
||||
|
||||
## Install (each step reviewed before running; [W] = writes to the machine)
|
||||
|
||||
1. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user`
|
||||
2. **[W]** Copy files from this dir (scp from the Mac):
|
||||
1. **[W]** `loginctl enable-linger $USER` — without lingering every user unit
|
||||
dies at logout and nothing starts after a reboot. Verify:
|
||||
`loginctl show-user $USER -p Linger` → `Linger=yes`.
|
||||
2. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user`
|
||||
3. **[W]** Copy files from this dir (scp):
|
||||
- `ctao-demo-gitea.container`, `ctao-demo-web.container` → `~/.config/containers/systemd/`
|
||||
- `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/`
|
||||
- `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`)
|
||||
- `nginx.conf` → `~/ctao-portal-demo/config/`
|
||||
3. **[W]** Pull + pin images (one-time, needs internet):
|
||||
`podman pull docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine`
|
||||
- `Containerfile.build` → anywhere (needed once, for the next step)
|
||||
4. **[W]** Pull the images at the digests pinned in the unit files (one-time,
|
||||
needs internet) and build the build image:
|
||||
`podman pull docker.io/gitea/gitea@sha256:<digest from ctao-demo-gitea.container>`
|
||||
`podman pull docker.io/library/nginx@sha256:<digest from ctao-demo-web.container>`
|
||||
`podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .`
|
||||
Then `podman images --digests` → paste the sha256 digests into both `.container` files.
|
||||
4. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
|
||||
5. **[W]** Create the Gitea admin — run YOURSELF in your own terminal (password
|
||||
is prompted/printed there only; never goes through chat or shell history):
|
||||
(Upgrading later = pick new digests deliberately, update the pins in the
|
||||
`.container` files / `Containerfile.build`, re-pull, re-build.)
|
||||
5. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
|
||||
6. **[W]** Create the Gitea admin — run interactively in a terminal so the
|
||||
password never lands in a file or shell history:
|
||||
`podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password`
|
||||
6. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
|
||||
Push from the Mac through an SSH port-forward:
|
||||
`ssh -L 3300:localhost:3000 strapi-experimental.cyfronet` then
|
||||
`git remote add machine http://localhost:3300/ctao/portal.git && git push machine main`
|
||||
7. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
|
||||
7. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
|
||||
Then, from your workstation, push the portal repo over the vhost with a
|
||||
repo-scoped token:
|
||||
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
|
||||
8. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
|
||||
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
|
||||
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
|
||||
(starting nginx earlier just serves 404s until the first build lands).
|
||||
8. **[W]** `systemctl --user start ctao-demo-web`
|
||||
9. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
|
||||
9. **[W]** `systemctl --user start ctao-demo-web`
|
||||
10. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
|
||||
and `journalctl --user -u ctao-portal-build -n 20` (shows measured build times).
|
||||
|
||||
## After Hubert assigns the vhosts
|
||||
## CMS sign-in (Sveltia ↔ Gitea OAuth)
|
||||
|
||||
1. Replace both `TODO(vhost)` values in `ctao-demo-gitea.container`
|
||||
(ROOT_URL → gitea vhost, CORS `*` → portal origin, add
|
||||
`GITEA__cors__SCHEME=https`); `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
|
||||
2. In Gitea UI: Settings → Applications → new OAuth2 app for Sveltia
|
||||
(redirect: `https://<portal-vhost>/admin/`), PKCE, no client secret.
|
||||
3. Put the Gitea vhost URL + client id into `public/admin/config.yml` in the
|
||||
portal repo, commit, push — the timer publishes it like any other change.
|
||||
- Gitea OAuth2 app (PKCE, `confidential_client=false`, no secret) with
|
||||
redirect `https://astro.isl-dev.grid.cyfronet.pl/admin/`; its client id is
|
||||
the `app_id` in `public/admin/config.yml`.
|
||||
- CORS is pinned in `ctao-demo-gitea.container`: `ALLOW_DOMAIN` takes the
|
||||
FULL portal origin with scheme (a bare hostname silently disables CORS in
|
||||
Gitea 1.27; there is no `SCHEME` key) and `HEADERS` must include
|
||||
`Authorization` or authenticated API calls from the browser fail.
|
||||
- OAuth requires a secure context: the vhosts must stay HTTPS.
|
||||
- Changing origins later = edit the quadlet env + the OAuth app's redirect
|
||||
URI + `config.yml`, then `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
|
||||
|
||||
## Ask Hubert (one message)
|
||||
|
||||
1. Two ingress vhosts → `192.168.10.15:8080` (portal) and `:3000` (gitea) —
|
||||
same mechanism as `strapi.isl-dev…:1337`.
|
||||
2. Are vhosts public-internet or VPN-scopable? (Gitea preferably VPN-only.)
|
||||
3. Does Cyfronet offer static-file hosting on the ingress itself? If yes, we
|
||||
drop our nginx container entirely and rsync builds there instead.
|
||||
|
||||
## Uninstall (leaves zero traces)
|
||||
## Uninstall (leaves only podman's own storage metadata)
|
||||
|
||||
```
|
||||
systemctl --user disable --now ctao-portal-build.timer
|
||||
@@ -74,14 +80,22 @@ systemctl --user stop ctao-demo-web ctao-demo-gitea
|
||||
rm ~/.config/containers/systemd/ctao-demo-*.container \
|
||||
~/.config/systemd/user/ctao-portal-build.{service,timer}
|
||||
systemctl --user daemon-reload
|
||||
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine
|
||||
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless \
|
||||
docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine
|
||||
rm -rf ~/ctao-portal-demo
|
||||
loginctl disable-linger $USER # only if nothing else of yours should survive logout
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Publish latency = poll (≤10 s) + build (measured 1 s on M-series; expect
|
||||
4–8 s on the 2 vCPU VM — every build's time lands in the journal).
|
||||
- **Only site content auto-deploys.** Changes to `deploy/*` need a manual
|
||||
re-copy: `build.sh` → `bin/`, `nginx.conf` → `config/` +
|
||||
`systemctl --user restart ctao-demo-web`, unit files →
|
||||
`~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is
|
||||
deliberate: the build pipeline must not execute host-side code straight
|
||||
from the content repo.
|
||||
- Publish latency = poll (≤10 s) + build (measured: astro build 5–7 s on the
|
||||
2 vCPU VM, whole build.sh 16–21 s; every build's time lands in the journal).
|
||||
- Internet needed only for: image pulls (install) and `npm ci` when the
|
||||
lockfile changes. Routine rebuilds are fully offline.
|
||||
- Memory caps (`MemoryHigh`) keep us polite next to Outline + Strapi;
|
||||
@@ -93,4 +107,5 @@ rm -rf ~/ctao-portal-demo
|
||||
Gitea on localhost:3000 but cannot reach host loopback services. (Rootless
|
||||
netavark bridges don't work here: no `ip_tables` kernel module, no sudo.)
|
||||
- Secrets inventory: Gitea admin password (typed interactively, lives only
|
||||
in Gitea's DB) — that's the complete list. Build/poll/serve use none.
|
||||
in Gitea's DB) and repo-scoped push tokens (managed in Gitea) — that's the
|
||||
complete list. Build/poll/serve use none.
|
||||
|
||||
+23
-9
@@ -21,6 +21,12 @@ REPO_INTERNAL="${REPO_INTERNAL:-http://localhost:3000/$REPO.git}"
|
||||
KEEP="${KEEP:-3}" # released builds to retain
|
||||
mkdir -p "$BASE/repo" "$BASE/releases" "$BASE/state" "$BASE/npm-cache"
|
||||
|
||||
# Host prerequisites (everything else runs inside containers). Fail loud —
|
||||
# a missing tool is permanent, unlike a Gitea hiccup below.
|
||||
for tool in curl jq podman; do
|
||||
command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; }
|
||||
done
|
||||
|
||||
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) ---
|
||||
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \
|
||||
| jq -r '.commit.id' || true)
|
||||
@@ -30,7 +36,10 @@ if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "poll failed (gitea unreachable?) — skipping this tick"
|
||||
exit 0
|
||||
fi
|
||||
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" ]] && exit 0
|
||||
# Skip only if this sha is both recorded AND still present in releases/
|
||||
# (a deleted release dir must trigger a rebuild, not an eternal skip).
|
||||
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
|
||||
&& -d "$BASE/releases/$sha" ]] && exit 0
|
||||
|
||||
echo "building $sha"
|
||||
t0=$(date +%s)
|
||||
@@ -48,18 +57,21 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
|
||||
-v "$BASE/releases:/work/releases:z" \
|
||||
-v "$BASE/npm-cache:/root/.npm:z" \
|
||||
-w /work "$BUILD_IMAGE" sh -ec '
|
||||
git config --global safe.directory "*"
|
||||
git config --global safe.directory /work/repo
|
||||
[ -d repo/.git ] || git clone --branch "$BRANCH" "$REPO_URL" repo
|
||||
git -C repo remote set-url origin "$REPO_URL" # self-heal if the URL changes
|
||||
git -C repo fetch --quiet origin "$BRANCH"
|
||||
git -C repo checkout --quiet "$SHA"
|
||||
# --force: the working copy is disposable; a stray tracked-file edit must
|
||||
# not wedge every future build.
|
||||
git -C repo checkout --quiet --force "$SHA"
|
||||
cd repo
|
||||
lock=$(sha256sum package-lock.json | cut -d" " -f1)
|
||||
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
|
||||
# --ignore-scripts: (1) kills the malicious-postinstall vector from npm
|
||||
# deps entirely, (2) avoids the esbuild ETXTBSY postinstall race in
|
||||
# rootless containers. esbuild ships its binary as an optional dep, so
|
||||
# nothing needed here actually requires lifecycle scripts.
|
||||
# --ignore-scripts: (1) removes the install-time postinstall vector from
|
||||
# npm deps (build-time repo code still runs `npm run build` below — the
|
||||
# netns confinement is the control for that), (2) avoids the esbuild
|
||||
# ETXTBSY postinstall race in rootless containers. esbuild ships its
|
||||
# binary as an optional dep, so nothing here needs lifecycle scripts.
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
echo "$lock" > .deps-hash
|
||||
fi
|
||||
@@ -76,11 +88,13 @@ ln -s "$sha" "$BASE/releases/.current.$$"
|
||||
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
|
||||
echo "$sha" > "$BASE/state/last-built"
|
||||
|
||||
# --- 4. Prune old releases (never touches `current` — it is always newest).
|
||||
# --- 4. Prune old releases. `current`'s target is excluded explicitly —
|
||||
# mtime ordering makes it newest today, but nothing should depend on that.
|
||||
# `|| true`: an empty match must not fail the unit after a successful publish
|
||||
# (grep exits 1 under pipefail when there is nothing to prune).
|
||||
cd "$BASE/releases"
|
||||
ls -1t | grep -vx current | tail -n +"$((KEEP + 1))" | while read -r old; do
|
||||
cur=$(readlink current || true)
|
||||
ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | while read -r old; do
|
||||
rm -rf -- "$old"
|
||||
done || true
|
||||
|
||||
|
||||
@@ -22,25 +22,26 @@ UserNS=keep-id:uid=1000,gid=1000
|
||||
# so it sees Gitea on localhost:3000 while the HOST loopback stays invisible.
|
||||
Volume=%h/ctao-portal-demo/gitea-data:/var/lib/gitea:Z
|
||||
Volume=%h/ctao-portal-demo/gitea-config:/etc/gitea:Z
|
||||
# Bound on all interfaces DELIBERATELY: the ingress that terminates the
|
||||
# public vhost runs on a separate box and reaches this VM over the network —
|
||||
# a 127.0.0.1 bind would cut it off.
|
||||
PublishPort=3000:3000
|
||||
|
||||
# Env-driven config — re-applied on every start, no hand-edited app.ini
|
||||
# (see .skills/gitea/SKILL.md). Secrets: none here; the admin account is
|
||||
# created interactively after first start (README).
|
||||
Environment=GITEA__server__HTTP_PORT=3000
|
||||
# Public URL via the Cyfronet ingress (vhost by Hubert, 2026-07-29).
|
||||
# Public URL via the Cyfronet ingress vhost.
|
||||
Environment=GITEA__server__ROOT_URL=https://astro-git.isl-dev.grid.cyfronet.pl/
|
||||
Environment=GITEA__server__DISABLE_SSH=true
|
||||
Environment=GITEA__database__DB_TYPE=sqlite3
|
||||
Environment=GITEA__security__INSTALL_LOCK=true
|
||||
Environment=GITEA__service__DISABLE_REGISTRATION=true
|
||||
Environment=GITEA__mailer__ENABLED=false
|
||||
# First `git push` auto-creates the repo (no UI step); public so the build
|
||||
# pipeline can clone anonymously — content is the public site anyway.
|
||||
Environment=GITEA__repository__ENABLE_PUSH_CREATE_USER=true
|
||||
# Repos default to public so the build pipeline can clone anonymously —
|
||||
# content is the public site anyway. The `ctao/portal` repo itself is
|
||||
# created in the UI (README step 6).
|
||||
Environment=GITEA__repository__DEFAULT_PRIVATE=public
|
||||
# Push-created repos have their OWN default (true = private) — learned the hard way:
|
||||
Environment=GITEA__repository__DEFAULT_PUSH_CREATE_PRIVATE=false
|
||||
# Sveltia is served from the portal vhost and calls the Gitea API cross-origin
|
||||
# — CORS locked to exactly that origin. ALLOW_DOMAIN takes FULL origins with
|
||||
# scheme (verified in the 1.27 config cheat sheet; a SCHEME key no longer
|
||||
|
||||
@@ -15,11 +15,14 @@ Image=docker.io/library/nginx:stable-alpine@sha256:97d490c12ba55b4946b01546d1c3e
|
||||
# the config file is exclusive to nginx (:Z).
|
||||
Volume=%h/ctao-portal-demo/releases:/srv/releases:ro,z
|
||||
Volume=%h/ctao-portal-demo/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
|
||||
# All-interfaces bind is deliberate — the ingress box reaches us over the
|
||||
# network (see the matching note in ctao-demo-gitea.container).
|
||||
PublishPort=8080:80
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
MemoryHigh=64M
|
||||
MemoryMax=128M
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
+19
-4
@@ -7,28 +7,43 @@ server {
|
||||
server_name _;
|
||||
root /srv/releases/current;
|
||||
charset utf-8;
|
||||
server_tokens off;
|
||||
error_page 404 /404.html; # Astro emits 404.html at the site root
|
||||
# Directory redirects (/admin -> /admin/) must stay relative: an absolute
|
||||
# redirect is built from listen port 80 and loses the real port whenever the
|
||||
# site is reached through a tunnel or a proxy on a non-default port.
|
||||
absolute_redirect off;
|
||||
|
||||
# Security headers are REPEATED in every location on purpose: nginx
|
||||
# `add_header` inheritance is all-or-nothing — any add_header in a location
|
||||
# discards ALL server-level ones, so server-level headers would silently
|
||||
# vanish. `always` keeps them on error responses (404) too.
|
||||
|
||||
# Fingerprinted build assets (/_astro/<name>.<hash>.*) — immutable
|
||||
location /_astro/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# Editor-uploaded media (stable paths, may be re-uploaded) — short cache
|
||||
location /uploads/ {
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Cache-Control "public, max-age=3600" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# Everything else: HTML pages, feeds, /admin (Sveltia is static files too)
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml application/rss+xml text/xml;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml application/rss+xml text/xml application/xml;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user