Make the site fully prerendered so a plain curl returns complete content
for every route (crawlers / AI screening tools see real text, not an empty
#root), while humans keep full client interactivity.
Prerender:
- Build-time per-route render: prefetch data, renderToString, inline the
dehydrated react-query cache as window.__RQ_STATE__; client hydrateRoots
and refetches live (activity stays fresh; crawlers get the baked snapshot).
- New entry-server.tsx + prerender/{prefetch,routes,meta}.ts + run-prerender.mjs;
shared lib/ranges.ts keeps SSR and client query keys identical.
- pnpm build now: tsc -b -> vite client build -> ssr build -> prerender.
- API base absolute at build (VITE_API_BASE), relative /api/v1 in the browser.
- CSS imports moved to the client entry so the tree imports under Node.
- schema.org Person + Occupation JSON-LD and per-route title/description/og.
- UTC + explicit field widths on shared date formatting so SSR and client
hydration match byte-for-byte (fixes hydration mismatch on /activity).
- Strip non-text gist content from the CV fetch (1MB -> 25KB gzipped page).
Deploy (Gitea Actions, replaces script/deploy.sh):
- deploy.yml: on push to main, lint/test gate, build api+worker as static
musl binaries (pure-rustls, no glibc skew) + prerendered web, deploy each
over SSH as gitea_ci with scoped sudo.
- refresh.yml: daily cron re-bakes only the web snapshot so gist/activity
edits propagate without a push or bouncing the api/worker.
- script/infra-setup.sh + asset/sudoers.d/{api,worker,web}-host.conf for
one-time per-host provisioning. Secrets: RSYNC_SSH_KEY, QUERY_GITHUB_TOKEN,
QUERY_GITEA_TOKEN.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
133 lines
5.2 KiB
Bash
Executable File
133 lines
5.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# One-time setup for the gitea_ci deploy-user on every host that
|
|
# .gitea/workflows/{deploy,refresh}.yml targets:
|
|
# - create the gitea_ci system user (if missing)
|
|
# - install the runner's pubkey into ~gitea_ci/.ssh/authorized_keys
|
|
# - add gitea_ci to the systemd-journal group (so the workflow can capture
|
|
# `journalctl -u moments-*.service` without a sudoers entry)
|
|
# - install the host-appropriate /etc/sudoers.d/moments_*_gitea_ci drop-in,
|
|
# verified with `visudo -cf` so a typo can't lock the host out
|
|
# - check the postgres mTLS host cert the api/worker need already exists
|
|
#
|
|
# Run this from a workstation with ssh + sudo access to the hosts, once per
|
|
# host, before the deploy workflow can succeed. Idempotent — safe to re-run.
|
|
# Application config is NOT shipped here: the workflow renders /etc/moments/*.env
|
|
# from Gitea secrets on every deploy.
|
|
#
|
|
# Never suppresses errors; hosts that fail to provision are reported and skipped
|
|
# so one offline host doesn't block the rest.
|
|
|
|
set -euo pipefail
|
|
|
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
repo_path="$(cd "${script_dir}/.." && pwd)"
|
|
|
|
# host -> sudoers drop-in (the source of infra truth, mirroring the workflow env)
|
|
api_host="nikola.kosherinata.internal"
|
|
worker_host="frootmig.kosherinata.internal"
|
|
web_host="oolon.kosherinata.internal"
|
|
|
|
api_sudoers="${repo_path}/asset/sudoers.d/api-host.conf"
|
|
worker_sudoers="${repo_path}/asset/sudoers.d/worker-host.conf"
|
|
web_sudoers="${repo_path}/asset/sudoers.d/web-host.conf"
|
|
|
|
pubkey="${HOME}/.ssh/id_gitea_ci.pub"
|
|
if [[ ! -f "${pubkey}" ]]; then
|
|
echo "fatal: ${pubkey} not found" >&2
|
|
echo " generate with: ssh-keygen -t ed25519 -f ${pubkey%.pub} -C gitea_ci" >&2
|
|
echo " then add the matching private key as the RSYNC_SSH_KEY Gitea secret" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Create gitea_ci, install its authorized_keys, and grant journal read access.
|
|
provision_user() {
|
|
local host="$1"
|
|
echo "==> ${host}: provisioning gitea_ci"
|
|
if ! ssh "${host}" '
|
|
set -eu
|
|
if id -u gitea_ci >/dev/null 2>&1; then
|
|
echo " gitea_ci user already present"
|
|
else
|
|
sudo useradd --system --create-home \
|
|
--home-dir /var/lib/gitea_ci --shell /bin/bash gitea_ci
|
|
echo " gitea_ci user created"
|
|
fi
|
|
# `install -o` does its own fresh user lookup, avoiding the brief NSS
|
|
# cache lag that makes `sudo -u gitea_ci` fail right after useradd.
|
|
sudo install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
|
|
sudo usermod -aG systemd-journal gitea_ci
|
|
'; then
|
|
echo " failed to provision gitea_ci — skipping ${host}"
|
|
return 1
|
|
fi
|
|
|
|
if rsync --archive --compress \
|
|
--chown gitea_ci:gitea_ci --chmod 0600 \
|
|
--rsync-path 'sudo rsync' \
|
|
"${pubkey}" \
|
|
"${host}:/var/lib/gitea_ci/.ssh/authorized_keys"; then
|
|
echo " authorized_keys synced"
|
|
else
|
|
echo " failed to sync authorized_keys to ${host}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Install the sudoers drop-in and verify it parses, so a typo can't lock out.
|
|
install_sudoers() {
|
|
local host="$1" template="$2" name="$3"
|
|
local dest="/etc/sudoers.d/${name}"
|
|
echo "==> ${host}: installing ${dest}"
|
|
if ! rsync --archive --compress \
|
|
--chown root:root --chmod 0440 \
|
|
--rsync-path 'sudo rsync' \
|
|
"${template}" \
|
|
"${host}:${dest}"; then
|
|
echo " failed to sync ${template##*/}"
|
|
return 1
|
|
fi
|
|
if ssh "${host}" "sudo visudo -cf ${dest}" >/dev/null; then
|
|
echo " installed and verified"
|
|
else
|
|
echo " WARNING: visudo rejected the installed file — review on ${host}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# The api/worker connect to postgres with mTLS using the host's own cert. The
|
|
# workflow only ACLs it to the moments user; it must already exist (provisioned
|
|
# by the host's PKI/step convention — see architecture/internal-tls.md).
|
|
check_pg_cert() {
|
|
local host="$1"
|
|
echo "==> ${host}: checking postgres mTLS host cert"
|
|
if ssh "${host}" '
|
|
fqdn="$(hostname -f)"
|
|
test -f "/etc/pki/tls/private/${fqdn}.pem" && test -f "/etc/pki/tls/misc/${fqdn}.pem"
|
|
'; then
|
|
echo " host cert present"
|
|
else
|
|
echo " WARNING: /etc/pki/tls/{private,misc}/<fqdn>.pem missing on ${host}"
|
|
echo " the moments-{api,worker} service will fail to reach postgres until it exists"
|
|
fi
|
|
}
|
|
|
|
setup_host() {
|
|
local host="$1" sudoers="$2" name="$3"
|
|
provision_user "${host}" && install_sudoers "${host}" "${sudoers}" "${name}" \
|
|
|| { echo " ${host}: setup incomplete"; return 1; }
|
|
}
|
|
|
|
setup_host "${api_host}" "${api_sudoers}" moments_api_gitea_ci
|
|
check_pg_cert "${api_host}"
|
|
setup_host "${worker_host}" "${worker_sudoers}" moments_worker_gitea_ci
|
|
check_pg_cert "${worker_host}"
|
|
setup_host "${web_host}" "${web_sudoers}" moments_web_gitea_ci
|
|
|
|
echo "==> done."
|
|
echo " Gitea repo secrets to set (Settings -> Actions -> Secrets):"
|
|
echo " RSYNC_SSH_KEY private key matching ${pubkey}"
|
|
echo " QUERY_GITHUB_TOKEN github api token for the worker poller"
|
|
echo " QUERY_GITEA_TOKEN git.lair.cafe api token for the worker poller"
|
|
echo " (GITHUB_TOKEN / GITEA_TOKEN are reserved Actions names — hence QUERY_.)"
|