Compare commits
2 Commits
f30f949895
...
4c8a663288
| Author | SHA1 | Date | |
|---|---|---|---|
|
4c8a663288
|
|||
|
8867ff5df3
|
@@ -1,6 +1,6 @@
|
|||||||
JOURNAL_STREAM=1
|
JOURNAL_STREAM=1
|
||||||
RUST_LOG=info,sqlx=warn,tower_http=info
|
RUST_LOG=info,sqlx=warn,tower_http=info
|
||||||
|
|
||||||
BIND_ADDR=0.0.0.0:42424
|
BIND_ADDR={{BIND}}
|
||||||
|
|
||||||
DATABASE_URL=postgres://moments_ro@magrathea.kosherinata.internal:5432/moments?sslmode=verify-full&sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem&sslcert=/etc/pki/tls/misc/{{HOSTNAME}}.pem&sslkey=/etc/pki/tls/private/{{HOSTNAME}}.pem
|
DATABASE_URL=postgres://moments_ro@magrathea.kosherinata.internal:5432/moments?sslmode=verify-full&sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem&sslcert=/etc/pki/tls/misc/{{HOSTNAME}}.pem&sslkey=/etc/pki/tls/private/{{HOSTNAME}}.pem
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ SEARCH_POLL_INTERVAL_SECS=86400
|
|||||||
|
|
||||||
GITEA_HOST=git.lair.cafe
|
GITEA_HOST=git.lair.cafe
|
||||||
GITEA_USER=grenade
|
GITEA_USER=grenade
|
||||||
|
GITEA_TOKEN={{GITEA_TOKEN}}
|
||||||
GITEA_POLL_INTERVAL_SECS=600
|
GITEA_POLL_INTERVAL_SECS=600
|
||||||
|
|
||||||
HG_HOST=hg-edge.mozilla.org
|
HG_HOST=hg-edge.mozilla.org
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<service>
|
<service>
|
||||||
<short>moments-api</short>
|
<short>moments-api</short>
|
||||||
<description>moments read-only HTTP API</description>
|
<description>moments read-only HTTP API</description>
|
||||||
<port protocol="tcp" port="42424"/>
|
<port protocol="tcp" port="{{API_PORT}}"/>
|
||||||
</service>
|
</service>
|
||||||
@@ -3,7 +3,7 @@ environments:
|
|||||||
prod:
|
prod:
|
||||||
components:
|
components:
|
||||||
api:
|
api:
|
||||||
hosts: [anjie.kosherinata.internal]
|
hosts: [nikola.kosherinata.internal]
|
||||||
config:
|
config:
|
||||||
bind: 0.0.0.0:42424
|
bind: 0.0.0.0:42424
|
||||||
db_role: moments_ro
|
db_role: moments_ro
|
||||||
@@ -11,7 +11,7 @@ environments:
|
|||||||
db_port: 5432
|
db_port: 5432
|
||||||
db_name: moments
|
db_name: moments
|
||||||
worker:
|
worker:
|
||||||
hosts: [anjie.kosherinata.internal]
|
hosts: [frootmig.kosherinata.internal]
|
||||||
config:
|
config:
|
||||||
db_role: moments_rw
|
db_role: moments_rw
|
||||||
db_host: magrathea.kosherinata.internal
|
db_host: magrathea.kosherinata.internal
|
||||||
@@ -27,10 +27,10 @@ environments:
|
|||||||
bugzilla_email: rthijssen@mozilla.com
|
bugzilla_email: rthijssen@mozilla.com
|
||||||
secrets:
|
secrets:
|
||||||
GITHUB_TOKEN: github.com/grenade/admin-token
|
GITHUB_TOKEN: github.com/grenade/admin-token
|
||||||
# GITEA_TOKEN, BUGZILLA_API_KEY: optional, omit unless required.
|
GITEA_TOKEN: git.lair.cafe/grenade/admin-token
|
||||||
web:
|
web:
|
||||||
hosts: [oolon.kosherinata.internal]
|
hosts: [oolon.kosherinata.internal]
|
||||||
config:
|
config:
|
||||||
server_name: rob.tn
|
server_name: rob.tn
|
||||||
root: /var/www/rob.tn
|
root: /var/www/rob.tn
|
||||||
api_upstream: http://anjie.kosherinata.internal:42424
|
api_upstream: http://nikola.kosherinata.internal:42424
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
upstream moments_api {
|
upstream moments_api {
|
||||||
server anjie.kosherinata.internal:42424 max_fails=3 fail_timeout=30s;
|
server {{API_UPSTREAM_ADDR}} max_fails=3 fail_timeout=30s;
|
||||||
keepalive 8;
|
keepalive 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_name rob.tn;
|
server_name {{SERVER_NAME}};
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/rob.tn/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/{{SERVER_NAME}}/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/rob.tn/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/{{SERVER_NAME}}/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
root /var/www/rob.tn;
|
root {{DOCROOT}};
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -28,7 +28,7 @@ server {
|
|||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
rewrite ^/api/(.*)$ /$1 break;
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
proxy_pass http://moments_api;
|
proxy_pass {{API_UPSTREAM_SCHEME}}://moments_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -38,6 +38,6 @@ server {
|
|||||||
proxy_connect_timeout 5s;
|
proxy_connect_timeout 5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
access_log /var/log/nginx/rob.tn.access.log;
|
access_log /var/log/nginx/{{SERVER_NAME}}.access.log;
|
||||||
error_log /var/log/nginx/rob.tn.error.log;
|
error_log /var/log/nginx/{{SERVER_NAME}}.error.log;
|
||||||
}
|
}
|
||||||
48
readme.md
48
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# moments
|
# moments
|
||||||
|
|
||||||
Personal activity timeline for [rob.tn](https://rob.tn). Polls public sources (GitHub, Gitea, hg-edge.mozilla.org, bugzilla.mozilla.org), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.
|
Personal activity timeline. Polls public sources (GitHub, Gitea, Mercurial, Bugzilla), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.
|
||||||
|
|
||||||
Successor to the now-defunct [grenade-events-react](https://github.com/grenade/grenade-events-react), which depended on MongoDB Stitch (retired by MongoDB in September 2022).
|
Successor to the now-defunct [grenade-events-react](https://github.com/grenade/grenade-events-react), which depended on MongoDB Stitch (retired by MongoDB in September 2022).
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ cargo run -p moments-api # serves on 127.0.0.1:8080
|
|||||||
cargo run -p moments-worker # one-shot ingest tick (until --interval is wired up)
|
cargo run -p moments-worker # one-shot ingest tick (until --interval is wired up)
|
||||||
```
|
```
|
||||||
|
|
||||||
The API expects a Postgres reachable at `DATABASE_URL`. For magrathea, that's an mTLS connection using the host cert. For local dev against a throwaway database:
|
The API expects a Postgres reachable at `DATABASE_URL`. In production this is an mTLS connection using the host cert. For local dev against a throwaway database:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
|
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
|
||||||
@@ -39,37 +39,31 @@ Migrations live in `crates/moments-data/migrations/` and run automatically on wo
|
|||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./script/deploy.sh prod all # api + worker + web
|
./script/deploy.sh <env> all # api + worker + web
|
||||||
./script/deploy.sh prod api worker # subset
|
./script/deploy.sh <env> api worker # subset
|
||||||
./script/deploy.sh prod default # api + web only (worker untouched)
|
./script/deploy.sh <env> default # api + web only (worker untouched)
|
||||||
./script/deploy.sh prod all --dry-run
|
./script/deploy.sh <env> all --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Topology:
|
Concrete hosts, ports, and the site's `server_name` live in `asset/manifest.yml`. The shape of the deployment:
|
||||||
|
|
||||||
| Component | Host | Notes |
|
| Component | Notes |
|
||||||
| --------- | --------------------------------- | ------------------------------------------------------------------ |
|
| --------- | ---------------------------------------------------------------------- |
|
||||||
| api | `anjie.kosherinata.internal` | binds `0.0.0.0:42424`; firewalld service `moments-api` |
|
| api | binds the port from `api.config.bind`; firewalld service `moments-api` |
|
||||||
| worker | `anjie.kosherinata.internal` | no listening port; pollers only |
|
| worker | no listening port; pollers only |
|
||||||
| web | `oolon.kosherinata.internal` | per-site nginx ingress for rob.tn; `/api/*` → anjie across the WG |
|
| web | per-site nginx ingress; `/api/*` reverse-proxies to the api host |
|
||||||
| db | `magrathea.kosherinata.internal` | postgres mTLS, passwordless |
|
| db | postgres mTLS, passwordless |
|
||||||
|
|
||||||
api and worker are co-located on `anjie` while `nikola` and `frootmig` are out for drive replacement.
|
Postgres roles `moments_rw` and `moments_ro` must exist on the primary, with `pg_ident.conf.d/<host>.conf` mapping the api host's FQDN → `moments_ro` and the worker host's FQDN → `moments_rw`. See `asset/sql/bootstrap-moments.sql`, `asset/postgres/ident.conf.tmpl`, and `script/db-perms.sh` (idempotently adds the cert_cn lines on the postgres primary + standby and reloads postgres).
|
||||||
|
|
||||||
Postgres roles `moments_rw` and `moments_ro` must exist on the primary, with `pg_ident.conf` mapping `anjie.kosherinata.internal` to **both** roles (one cert_cn line per mapping). See `asset/sql/bootstrap-moments.sql` and `asset/postgres/ident.conf.tmpl`.
|
Inter-host traffic over the WG mesh: web's nginx connects to the api host in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to `https://`.
|
||||||
|
|
||||||
Inter-host traffic over the WG mesh: oolon's nginx connects to `http://anjie.kosherinata.internal:42424` in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to `https://`.
|
Secrets are resolved at deploy time via `pass`. The mapping of env-var name → pass-store path lives under `worker.secrets` in `manifest.yml`; `deploy.sh` iterates the map, fetches each secret, and substitutes the matching `{{NAME}}` placeholder in `worker.env.tmpl`. To add a secret: add a `worker.secrets` entry, add `NAME={{NAME}}` to `worker.env.tmpl`, and ensure `pass show <path>` returns the value on the deploying machine.
|
||||||
|
|
||||||
Secrets resolved by `deploy.sh` via `pass`:
|
### First-time setup
|
||||||
|
|
||||||
- `github.com/grenade/admin-token` — GitHub PAT for events + search APIs (worker only).
|
After the first successful prod deploy:
|
||||||
|
|
||||||
Optional, set if needed in `worker.env`: `GITEA_TOKEN`, `BUGZILLA_API_KEY`.
|
1. Point public DNS for the site at the web host's public IP (unproxied).
|
||||||
|
2. Confirm `curl --fail --silent --show-error https://<site>/api/v1/healthz` returns `ok`.
|
||||||
### DNS cutover
|
3. If migrating from a predecessor, archive the old repo with a pointer to this one.
|
||||||
|
|
||||||
`rob.tn` currently resolves to GitHub Pages. After the first successful prod deploy:
|
|
||||||
|
|
||||||
1. Update Cloudflare DNS for `rob.tn` to the WAN IP that fronts `oolon` (unproxied — see architecture doc §11).
|
|
||||||
2. Confirm `curl -fsS https://rob.tn/api/v1/healthz` returns `ok`.
|
|
||||||
3. Add an archival notice to the top of [grenade-events-react/readme.md](https://github.com/grenade/grenade-events-react) pointing at this repo, and archive the GitHub repo.
|
|
||||||
|
|||||||
63
script/db-perms.sh
Executable file
63
script/db-perms.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Idempotently add cert_cn → role mappings to pg_ident.conf.d on the moments
|
||||||
|
# postgres primary and standby, then reload postgres so the changes take
|
||||||
|
# effect. Re-running is a no-op (no duplicate lines, no spurious reload).
|
||||||
|
#
|
||||||
|
# Run from a workstation with ssh access to both pg hosts. This script ssh's
|
||||||
|
# out; do NOT run it on magrathea/frankie directly.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
api_host=nikola.kosherinata.internal
|
||||||
|
worker_host=frootmig.kosherinata.internal
|
||||||
|
|
||||||
|
pg_hosts=(
|
||||||
|
magrathea.kosherinata.internal
|
||||||
|
frankie.kosherinata.internal
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each (cert_cn host, role) pair becomes one cert_cn line in
|
||||||
|
# pg_ident.conf.d/<cert_cn host>.conf on every pg host listed above.
|
||||||
|
mapping_pairs=(
|
||||||
|
"$api_host" moments_ro
|
||||||
|
"$worker_host" moments_rw
|
||||||
|
)
|
||||||
|
|
||||||
|
ident_dir=/var/lib/pgsql/18/data/pg_ident.conf.d
|
||||||
|
|
||||||
|
for pg_host in "${pg_hosts[@]}"; do
|
||||||
|
printf '==> %s\n' "$pg_host"
|
||||||
|
ssh -o BatchMode=yes "$pg_host" "sudo bash -s -- ${ident_dir@Q} ${mapping_pairs[@]@Q}" <<'REMOTE_EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
ident_dir="$1"; shift
|
||||||
|
|
||||||
|
changed=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
cert_cn_host="$1"
|
||||||
|
role="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
line="cert_cn ${cert_cn_host} ${role}"
|
||||||
|
file="${ident_dir}/${cert_cn_host}.conf"
|
||||||
|
|
||||||
|
# The heredoc runs as root via sudo bash, so [[ -f ]] and grep are fine
|
||||||
|
# without dropping privs. tee --append runs as postgres so a newly-created
|
||||||
|
# file lands with the conventional postgres:postgres ownership.
|
||||||
|
if [[ -f "$file" ]] && grep --fixed-strings --line-regexp --quiet -- "$line" "$file"; then
|
||||||
|
printf ' present: %s\n' "$line"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$line" | sudo -u postgres tee --append "$file" >/dev/null
|
||||||
|
printf ' added: %s\n' "$line"
|
||||||
|
changed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( changed )); then
|
||||||
|
systemctl reload postgresql-18
|
||||||
|
echo " reloaded postgresql-18"
|
||||||
|
else
|
||||||
|
echo " no changes; reload skipped"
|
||||||
|
fi
|
||||||
|
REMOTE_EOF
|
||||||
|
done
|
||||||
396
script/deploy.sh
396
script/deploy.sh
@@ -68,10 +68,10 @@ command -v cargo >/dev/null 2>&1 || die "cargo is required"
|
|||||||
# Resolve component list ----------------------------------------------------
|
# Resolve component list ----------------------------------------------------
|
||||||
|
|
||||||
env_path=".environments.${environment}"
|
env_path=".environments.${environment}"
|
||||||
yq -e "${env_path}" "$manifest" >/dev/null \
|
yq --exit-status "${env_path}" "$manifest" >/dev/null \
|
||||||
|| die "environment '$environment' not found in manifest"
|
|| die "environment '$environment' not found in manifest"
|
||||||
|
|
||||||
mapfile -t all_components < <(yq -r "${env_path}.components | keys | .[]" "$manifest")
|
mapfile -t all_components < <(yq --raw-output "${env_path}.components | keys | .[]" "$manifest")
|
||||||
|
|
||||||
if [[ ${#components[@]} -eq 0 ]]; then
|
if [[ ${#components[@]} -eq 0 ]]; then
|
||||||
usage
|
usage
|
||||||
@@ -108,75 +108,116 @@ deploy_api() {
|
|||||||
local host="$1"
|
local host="$1"
|
||||||
log "api -> $host"
|
log "api -> $host"
|
||||||
|
|
||||||
|
local bind
|
||||||
|
bind="$(yq --raw-output "${env_path}.components.api.config.bind" "$manifest")"
|
||||||
|
[[ -n "$bind" && "$bind" != "null" ]] || die "api.config.bind missing in manifest"
|
||||||
|
[[ "$bind" == *:* ]] \
|
||||||
|
|| die "api.config.bind must be host:port form: '$bind'"
|
||||||
|
|
||||||
|
local api_port
|
||||||
|
api_port="${bind##*:}"
|
||||||
|
[[ "$api_port" =~ ^[0-9]+$ ]] \
|
||||||
|
|| die "api.config.bind port is not numeric: '$api_port'"
|
||||||
|
|
||||||
if (( dry_run )); then
|
if (( dry_run )); then
|
||||||
printf '\033[2m[dry-run]\033[0m render api.env (HOSTNAME=%s) + units, rsync to %s:/, run sysusers/restorecon/semanage/systemctl on %s\n' \
|
printf '\033[2m[dry-run]\033[0m render api.env (HOSTNAME=%s, BIND=%s) + firewalld svc (port=%s) + units, stage to %s:/tmp/, install via heredoc, run sysusers/restorecon/semanage/systemctl on %s\n' \
|
||||||
"$host" "$host" "$host" >&2
|
"$host" "$bind" "$api_port" "$host" "$host" >&2
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local fqdn
|
local fqdn="$host"
|
||||||
fqdn="$host"
|
|
||||||
|
|
||||||
local stage
|
local stage
|
||||||
stage="$(mktemp -d)"
|
stage="$(mktemp --directory)"
|
||||||
trap "rm -rf '$stage'" RETURN
|
trap "rm --recursive --force '$stage'" RETURN
|
||||||
|
|
||||||
install -d "$stage/etc/moments" "$stage/etc/systemd/system" "$stage/etc/sysusers.d" "$stage/etc/firewalld/services" "$stage/usr/local/bin"
|
install --directory \
|
||||||
|
"$stage/etc/moments" \
|
||||||
|
"$stage/etc/systemd/system" \
|
||||||
|
"$stage/etc/sysusers.d" \
|
||||||
|
"$stage/etc/firewalld/services" \
|
||||||
|
"$stage/usr/local/bin"
|
||||||
|
|
||||||
# Render env file with hostname substitution.
|
local rendered
|
||||||
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/config/api.env.tmpl" \
|
rendered="$(<"${repo_root}/asset/config/api.env.tmpl")"
|
||||||
> "$stage/etc/moments/api.env"
|
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
|
||||||
|
rendered=${rendered//'{{BIND}}'/$bind}
|
||||||
|
printf '%s\n' "$rendered" > "$stage/etc/moments/api.env"
|
||||||
|
|
||||||
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/systemd/moments-api-cert.path" \
|
rendered="$(<"${repo_root}/asset/systemd/moments-api-cert.path")"
|
||||||
> "$stage/etc/systemd/system/moments-api-cert.path"
|
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
|
||||||
|
printf '%s\n' "$rendered" > "$stage/etc/systemd/system/moments-api-cert.path"
|
||||||
|
|
||||||
install -m 0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
|
rendered="$(<"${repo_root}/asset/firewalld/moments-api.xml.tmpl")"
|
||||||
install -m 0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
|
rendered=${rendered//'{{API_PORT}}'/$api_port}
|
||||||
install -m 0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
printf '%s\n' "$rendered" > "$stage/etc/firewalld/services/moments-api.xml"
|
||||||
install -m 0644 "${repo_root}/asset/firewalld/moments-api.xml" "$stage/etc/firewalld/services/moments-api.xml"
|
chmod 0644 "$stage/etc/firewalld/services/moments-api.xml"
|
||||||
install -m 0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
|
|
||||||
|
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
|
||||||
|
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
|
||||||
|
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
||||||
|
install --mode=0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
|
||||||
|
|
||||||
# Permissions on the rendered env: root-owned, moments group readable.
|
|
||||||
chmod 0640 "$stage/etc/moments/api.env"
|
chmod 0640 "$stage/etc/moments/api.env"
|
||||||
|
|
||||||
if (( dry_run )); then
|
# Stage to a tmpdir on the remote, then `install` each file at its final
|
||||||
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
|
# path via the heredoc. Never rsync into /, since rsync of staged parent
|
||||||
else
|
# dirs (etc/, usr/, ...) can leak ownership, ACLs and xattrs onto the
|
||||||
rsync \
|
# live system dirs.
|
||||||
--archive \
|
local remote_stage="/tmp/moments-deploy.api.${$}.${RANDOM}"
|
||||||
--hard-links \
|
|
||||||
--acls \
|
|
||||||
--xattrs \
|
|
||||||
--numeric-ids \
|
|
||||||
--chown root:root \
|
|
||||||
--rsync-path 'sudo rsync' \
|
|
||||||
"$stage/" \
|
|
||||||
"${host}:/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
|
rsync \
|
||||||
|
--archive \
|
||||||
|
--hard-links \
|
||||||
|
--numeric-ids \
|
||||||
|
--rsh='ssh -o BatchMode=yes' \
|
||||||
|
"$stage/" \
|
||||||
|
"${host}:${remote_stage}/"
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s -- ${remote_stage@Q} ${api_port@Q}" <<'REMOTE_EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
fqdn="$(hostname -f)"
|
remote_stage="$1"
|
||||||
|
api_port="$2"
|
||||||
|
trap 'rm --recursive --force "$remote_stage"' EXIT
|
||||||
|
|
||||||
|
fqdn="$(hostname --fqdn)"
|
||||||
|
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/sysusers.d/moments.conf" \
|
||||||
|
/etc/sysusers.d/moments.conf
|
||||||
systemd-sysusers /etc/sysusers.d/moments.conf
|
systemd-sysusers /etc/sysusers.d/moments.conf
|
||||||
|
|
||||||
install -d -o root -g moments -m 0750 /etc/moments
|
install --directory --owner=root --group=moments --mode=0750 /etc/moments
|
||||||
install -d -o moments -g moments -m 0750 /var/lib/moments
|
install --directory --owner=moments --group=moments --mode=0750 /var/lib/moments
|
||||||
chown root:moments /etc/moments/api.env
|
|
||||||
chmod 0640 /etc/moments/api.env
|
|
||||||
|
|
||||||
# Grant the moments user read access to the host private key — required for
|
install --owner=root --group=moments --mode=0640 \
|
||||||
# the postgres mTLS connection.
|
"$remote_stage/etc/moments/api.env" \
|
||||||
setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
|
/etc/moments/api.env
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/systemd/system/moments-api.service" \
|
||||||
|
/etc/systemd/system/moments-api.service
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/systemd/system/moments-api-cert.path" \
|
||||||
|
/etc/systemd/system/moments-api-cert.path
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/systemd/system/moments-api-cert-reload.service" \
|
||||||
|
/etc/systemd/system/moments-api-cert-reload.service
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/firewalld/services/moments-api.xml" \
|
||||||
|
/etc/firewalld/services/moments-api.xml
|
||||||
|
install --owner=root --group=root --mode=0755 \
|
||||||
|
"$remote_stage/usr/local/bin/moments-api" \
|
||||||
|
/usr/local/bin/moments-api
|
||||||
|
|
||||||
# Label the API port. Idempotent — the -m fallback turns "already labelled"
|
# Grant the moments user read access to the host private key for the
|
||||||
# into a no-op.
|
# postgres mTLS connection.
|
||||||
if ! semanage port -l | awk '{print $1, $3}' | grep -qE "^http_port_t .*42424"; then
|
setfacl --modify=u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
|
||||||
semanage port -a -t http_port_t -p tcp 42424 || \
|
|
||||||
semanage port -m -t http_port_t -p tcp 42424
|
# Idempotent label: --add fails if the port is already labelled (we suppress
|
||||||
fi
|
# that one stderr line); --modify is then a no-op or fixes a stale type.
|
||||||
|
semanage port --add --type=http_port_t --proto=tcp "$api_port" 2>/dev/null \
|
||||||
|
|| semanage port --modify --type=http_port_t --proto=tcp "$api_port"
|
||||||
|
|
||||||
# Firewalld: install the named service and enable it in the default zone.
|
|
||||||
firewall-cmd --reload
|
firewall-cmd --reload
|
||||||
zone="$(firewall-cmd --get-default-zone)"
|
zone="$(firewall-cmd --get-default-zone)"
|
||||||
if ! firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
|
if ! firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
|
||||||
@@ -191,17 +232,18 @@ systemctl enable --now moments-api-cert.path
|
|||||||
systemctl enable --now moments-api.service
|
systemctl enable --now moments-api.service
|
||||||
systemctl restart moments-api.service
|
systemctl restart moments-api.service
|
||||||
|
|
||||||
# Health probe — hit the bound interface, not loopback, so we exercise the
|
# Quietly retry while the service binds; only show curl's diagnostics if
|
||||||
# same path nginx will use from oolon.
|
# every attempt fails. The journalctl tail on failure is the verbose source.
|
||||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
if curl -fsS "http://${fqdn}:42424/v1/healthz" >/dev/null; then
|
if curl --fail --silent "http://${fqdn}:${api_port}/v1/healthz" >/dev/null 2>&1; then
|
||||||
echo "moments-api healthy"
|
echo "moments-api healthy"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "moments-api did not become healthy" >&2
|
echo "moments-api did not become healthy" >&2
|
||||||
journalctl -u moments-api.service -n 50 --no-pager >&2
|
curl --fail --silent --show-error "http://${fqdn}:${api_port}/v1/healthz" >/dev/null || true
|
||||||
|
journalctl --unit=moments-api.service --lines=50 --no-pager >&2
|
||||||
exit 1
|
exit 1
|
||||||
REMOTE_EOF
|
REMOTE_EOF
|
||||||
}
|
}
|
||||||
@@ -210,69 +252,112 @@ deploy_worker() {
|
|||||||
local host="$1"
|
local host="$1"
|
||||||
log "worker -> $host"
|
log "worker -> $host"
|
||||||
|
|
||||||
|
# Manifest entries under `worker.secrets` map env-var name -> pass store path.
|
||||||
|
# The script fetches each via `pass` and substitutes the matching {{NAME}}
|
||||||
|
# placeholder in worker.env.tmpl. Adding a new secret is then a manifest +
|
||||||
|
# template change; no script edit required.
|
||||||
|
local -a secret_lines secret_keys
|
||||||
|
mapfile -t secret_lines < <(yq --raw-output \
|
||||||
|
"${env_path}.components.worker.secrets // {} | to_entries | .[] | \"\(.key)=\(.value)\"" \
|
||||||
|
"$manifest")
|
||||||
|
local line
|
||||||
|
for line in "${secret_lines[@]}"; do
|
||||||
|
[[ -n "$line" ]] && secret_keys+=("${line%%=*}")
|
||||||
|
done
|
||||||
|
|
||||||
if (( dry_run )); then
|
if (( dry_run )); then
|
||||||
printf '\033[2m[dry-run]\033[0m render worker.env (HOSTNAME=%s, GITHUB_TOKEN from pass) + units, rsync to %s:/, run sysusers/restorecon/systemctl on %s\n' \
|
printf '\033[2m[dry-run]\033[0m render worker.env (HOSTNAME=%s, secrets [%s] from pass) + units, stage to %s:/tmp/, install via heredoc, run sysusers/restorecon/systemctl on %s\n' \
|
||||||
"$host" "$host" "$host" >&2
|
"$host" "${secret_keys[*]:-none}" "$host" "$host" >&2
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local fqdn
|
local fqdn="$host"
|
||||||
fqdn="$host"
|
|
||||||
|
|
||||||
local github_token=""
|
|
||||||
if pass show github.com/grenade/admin-token >/dev/null 2>&1; then
|
|
||||||
github_token="$(pass show github.com/grenade/admin-token)"
|
|
||||||
else
|
|
||||||
warn "no github admin-token in pass; worker will run without GITHUB_TOKEN"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local stage
|
local stage
|
||||||
stage="$(mktemp -d)"
|
stage="$(mktemp --directory)"
|
||||||
trap "rm -rf '$stage'" RETURN
|
trap "rm --recursive --force '$stage'" RETURN
|
||||||
|
|
||||||
install -d "$stage/etc/moments" "$stage/etc/systemd/system" "$stage/etc/sysusers.d" "$stage/usr/local/bin"
|
install --directory \
|
||||||
|
"$stage/etc/moments" \
|
||||||
|
"$stage/etc/systemd/system" \
|
||||||
|
"$stage/etc/sysusers.d" \
|
||||||
|
"$stage/usr/local/bin"
|
||||||
|
|
||||||
sed -e "s|{{HOSTNAME}}|${fqdn}|g" \
|
# Render templates in-memory so secrets never appear on a command line
|
||||||
-e "s|{{GITHUB_TOKEN}}|${github_token}|g" \
|
# (sed would expose them to anything that can read /proc/<pid>/cmdline).
|
||||||
"${repo_root}/asset/config/worker.env.tmpl" > "$stage/etc/moments/worker.env"
|
local rendered
|
||||||
|
rendered="$(<"${repo_root}/asset/config/worker.env.tmpl")"
|
||||||
|
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
|
||||||
|
local key pass_path value
|
||||||
|
for line in "${secret_lines[@]}"; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
pass_path="${line#*=}"
|
||||||
|
if pass show "$pass_path" >/dev/null 2>&1; then
|
||||||
|
value="$(pass show "$pass_path")"
|
||||||
|
else
|
||||||
|
warn "no secret in pass at '${pass_path}' for ${key}; worker will run without ${key}"
|
||||||
|
value=""
|
||||||
|
fi
|
||||||
|
rendered=${rendered//"{{${key}}}"/$value}
|
||||||
|
done
|
||||||
|
printf '%s\n' "$rendered" > "$stage/etc/moments/worker.env"
|
||||||
|
|
||||||
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/systemd/moments-worker-cert.path" \
|
rendered="$(<"${repo_root}/asset/systemd/moments-worker-cert.path")"
|
||||||
> "$stage/etc/systemd/system/moments-worker-cert.path"
|
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
|
||||||
|
printf '%s\n' "$rendered" > "$stage/etc/systemd/system/moments-worker-cert.path"
|
||||||
|
|
||||||
install -m 0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
|
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
|
||||||
install -m 0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
|
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
|
||||||
install -m 0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
|
||||||
install -m 0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
|
install --mode=0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
|
||||||
|
|
||||||
chmod 0640 "$stage/etc/moments/worker.env"
|
chmod 0640 "$stage/etc/moments/worker.env"
|
||||||
|
|
||||||
if (( dry_run )); then
|
# Stage to a tmpdir on the remote, then `install` each file at its final
|
||||||
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
|
# path via the heredoc. Never rsync into /.
|
||||||
else
|
local remote_stage="/tmp/moments-deploy.worker.${$}.${RANDOM}"
|
||||||
rsync \
|
|
||||||
--archive \
|
|
||||||
--hard-links \
|
|
||||||
--acls \
|
|
||||||
--xattrs \
|
|
||||||
--numeric-ids \
|
|
||||||
--chown root:root \
|
|
||||||
--rsync-path 'sudo rsync' \
|
|
||||||
"$stage/" \
|
|
||||||
"${host}:/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
|
rsync \
|
||||||
|
--archive \
|
||||||
|
--hard-links \
|
||||||
|
--numeric-ids \
|
||||||
|
--rsh='ssh -o BatchMode=yes' \
|
||||||
|
"$stage/" \
|
||||||
|
"${host}:${remote_stage}/"
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s -- ${remote_stage@Q}" <<'REMOTE_EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
fqdn="$(hostname -f)"
|
remote_stage="$1"
|
||||||
|
trap 'rm --recursive --force "$remote_stage"' EXIT
|
||||||
|
|
||||||
|
fqdn="$(hostname --fqdn)"
|
||||||
|
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/sysusers.d/moments.conf" \
|
||||||
|
/etc/sysusers.d/moments.conf
|
||||||
systemd-sysusers /etc/sysusers.d/moments.conf
|
systemd-sysusers /etc/sysusers.d/moments.conf
|
||||||
|
|
||||||
install -d -o root -g moments -m 0750 /etc/moments
|
install --directory --owner=root --group=moments --mode=0750 /etc/moments
|
||||||
install -d -o moments -g moments -m 0750 /var/lib/moments
|
install --directory --owner=moments --group=moments --mode=0750 /var/lib/moments
|
||||||
chown root:moments /etc/moments/worker.env
|
|
||||||
chmod 0640 /etc/moments/worker.env
|
|
||||||
|
|
||||||
setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
|
install --owner=root --group=moments --mode=0640 \
|
||||||
|
"$remote_stage/etc/moments/worker.env" \
|
||||||
|
/etc/moments/worker.env
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/systemd/system/moments-worker.service" \
|
||||||
|
/etc/systemd/system/moments-worker.service
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/systemd/system/moments-worker-cert.path" \
|
||||||
|
/etc/systemd/system/moments-worker-cert.path
|
||||||
|
install --owner=root --group=root --mode=0644 \
|
||||||
|
"$remote_stage/etc/systemd/system/moments-worker-cert-reload.service" \
|
||||||
|
/etc/systemd/system/moments-worker-cert-reload.service
|
||||||
|
install --owner=root --group=root --mode=0755 \
|
||||||
|
"$remote_stage/usr/local/bin/moments-worker" \
|
||||||
|
/usr/local/bin/moments-worker
|
||||||
|
|
||||||
|
setfacl --modify=u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
|
||||||
|
|
||||||
restorecon -Rv /usr/local/bin/moments-worker /etc/moments /var/lib/moments
|
restorecon -Rv /usr/local/bin/moments-worker /etc/moments /var/lib/moments
|
||||||
|
|
||||||
@@ -281,9 +366,8 @@ systemctl enable --now moments-worker-cert.path
|
|||||||
systemctl enable --now moments-worker.service
|
systemctl enable --now moments-worker.service
|
||||||
systemctl restart moments-worker.service
|
systemctl restart moments-worker.service
|
||||||
|
|
||||||
# Liveness probe — worker doesn't expose a port, so check is-active.
|
|
||||||
if ! systemctl is-active --quiet moments-worker.service; then
|
if ! systemctl is-active --quiet moments-worker.service; then
|
||||||
journalctl -u moments-worker.service -n 50 --no-pager >&2
|
journalctl --unit=moments-worker.service --lines=50 --no-pager >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "moments-worker active"
|
echo "moments-worker active"
|
||||||
@@ -294,61 +378,93 @@ deploy_web() {
|
|||||||
local host="$1"
|
local host="$1"
|
||||||
log "web -> $host"
|
log "web -> $host"
|
||||||
|
|
||||||
|
local server_name web_root api_upstream
|
||||||
|
server_name="$(yq --raw-output "${env_path}.components.web.config.server_name" "$manifest")"
|
||||||
|
web_root="$(yq --raw-output "${env_path}.components.web.config.root" "$manifest")"
|
||||||
|
api_upstream="$(yq --raw-output "${env_path}.components.web.config.api_upstream" "$manifest")"
|
||||||
|
[[ -n "$server_name" && "$server_name" != "null" ]] || die "web.config.server_name missing in manifest"
|
||||||
|
[[ -n "$web_root" && "$web_root" != "null" ]] || die "web.config.root missing in manifest"
|
||||||
|
[[ -n "$api_upstream" && "$api_upstream" != "null" ]] || die "web.config.api_upstream missing in manifest"
|
||||||
|
[[ "$web_root" == /* ]] \
|
||||||
|
|| die "web.config.root must be an absolute path: '$web_root'"
|
||||||
|
[[ "$api_upstream" == http://* || "$api_upstream" == https://* ]] \
|
||||||
|
|| die "web.config.api_upstream must be a http(s) URL: '$api_upstream'"
|
||||||
|
|
||||||
|
local api_upstream_scheme api_upstream_addr api_upstream_port
|
||||||
|
api_upstream_scheme="${api_upstream%%://*}"
|
||||||
|
api_upstream_addr="${api_upstream#*://}"
|
||||||
|
[[ "$api_upstream_addr" == *:* ]] \
|
||||||
|
|| die "web.config.api_upstream must include an explicit port: '$api_upstream'"
|
||||||
|
api_upstream_port="${api_upstream_addr##*:}"
|
||||||
|
[[ "$api_upstream_port" =~ ^[0-9]+$ ]] \
|
||||||
|
|| die "extracted upstream port is not numeric: '$api_upstream_port'"
|
||||||
|
|
||||||
|
local site_conf_path="/etc/nginx/conf.d/${server_name}.conf"
|
||||||
|
|
||||||
if (( dry_run )); then
|
if (( dry_run )); then
|
||||||
printf '\033[2m[dry-run]\033[0m rsync ui/dist/ to %s:/var/www/rob.tn/ + nginx config, run nginx -t/reload on %s\n' \
|
printf '\033[2m[dry-run]\033[0m render %s (server_name=%s, docroot=%s, upstream=%s://%s) + rsync ui/dist/ to %s:%s/, run nginx -t/reload on %s\n' \
|
||||||
"$host" "$host" >&2
|
"$site_conf_path" "$server_name" "$web_root" \
|
||||||
|
"$api_upstream_scheme" "$api_upstream_addr" \
|
||||||
|
"$host" "$web_root" "$host" >&2
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local stage
|
local stage
|
||||||
stage="$(mktemp -d)"
|
stage="$(mktemp --directory)"
|
||||||
trap "rm -rf '$stage'" RETURN
|
trap "rm --recursive --force '$stage'" RETURN
|
||||||
|
|
||||||
install -d "$stage/var/www/rob.tn" "$stage/etc/nginx/conf.d"
|
install --directory "${stage}${web_root}" "$stage/etc/nginx/conf.d"
|
||||||
|
|
||||||
rsync -a "${repo_root}/ui/dist/" "$stage/var/www/rob.tn/"
|
rsync --archive "${repo_root}/ui/dist/" "${stage}${web_root}/"
|
||||||
install -m 0644 "${repo_root}/asset/nginx/rob.tn.conf" "$stage/etc/nginx/conf.d/rob.tn.conf"
|
|
||||||
|
|
||||||
if (( dry_run )); then
|
local rendered
|
||||||
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
|
rendered="$(<"${repo_root}/asset/nginx/site.conf.tmpl")"
|
||||||
else
|
rendered=${rendered//'{{SERVER_NAME}}'/$server_name}
|
||||||
rsync \
|
rendered=${rendered//'{{DOCROOT}}'/$web_root}
|
||||||
--archive \
|
rendered=${rendered//'{{API_UPSTREAM_SCHEME}}'/$api_upstream_scheme}
|
||||||
--hard-links \
|
rendered=${rendered//'{{API_UPSTREAM_ADDR}}'/$api_upstream_addr}
|
||||||
--acls \
|
printf '%s\n' "$rendered" > "${stage}${site_conf_path}"
|
||||||
--xattrs \
|
chmod 0644 "${stage}${site_conf_path}"
|
||||||
--numeric-ids \
|
|
||||||
--chown root:root \
|
|
||||||
--rsync-path 'sudo rsync' \
|
|
||||||
--delete \
|
|
||||||
"$stage/var/www/rob.tn/" \
|
|
||||||
"${host}:/var/www/rob.tn/"
|
|
||||||
rsync \
|
|
||||||
--archive \
|
|
||||||
--hard-links \
|
|
||||||
--acls \
|
|
||||||
--xattrs \
|
|
||||||
--numeric-ids \
|
|
||||||
--chown root:root \
|
|
||||||
--rsync-path 'sudo rsync' \
|
|
||||||
"$stage/etc/nginx/conf.d/rob.tn.conf" \
|
|
||||||
"${host}:/etc/nginx/conf.d/rob.tn.conf"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
|
# Both targets are leaf paths (the docroot itself, and a single named
|
||||||
|
# file) so rsync does not traverse /var or /etc parents — `--chown` is
|
||||||
|
# enough; -A/-X are intentionally absent.
|
||||||
|
rsync \
|
||||||
|
--archive \
|
||||||
|
--hard-links \
|
||||||
|
--numeric-ids \
|
||||||
|
--chown root:root \
|
||||||
|
--rsh='ssh -o BatchMode=yes' \
|
||||||
|
--rsync-path 'sudo rsync' \
|
||||||
|
--delete \
|
||||||
|
"${stage}${web_root}/" \
|
||||||
|
"${host}:${web_root}/"
|
||||||
|
rsync \
|
||||||
|
--archive \
|
||||||
|
--hard-links \
|
||||||
|
--numeric-ids \
|
||||||
|
--chown root:root \
|
||||||
|
--rsh='ssh -o BatchMode=yes' \
|
||||||
|
--rsync-path 'sudo rsync' \
|
||||||
|
"${stage}${site_conf_path}" \
|
||||||
|
"${host}:${site_conf_path}"
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s -- ${web_root@Q} ${site_conf_path@Q} ${api_upstream_port@Q}" <<'REMOTE_EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
web_root="$1"
|
||||||
|
site_conf_path="$2"
|
||||||
|
api_upstream_port="$3"
|
||||||
|
|
||||||
# Allow nginx to make outbound connections to the moments-api upstream
|
# Allow nginx to make outbound connections to the moments-api upstream
|
||||||
# across the WG mesh.
|
# across the WG mesh.
|
||||||
setsebool -P httpd_can_network_connect on
|
setsebool -P httpd_can_network_connect on
|
||||||
|
|
||||||
# Label the upstream port so httpd_t may name_connect to it.
|
# Idempotent label: --add fails if the port is already labelled (we suppress
|
||||||
if ! semanage port -l | awk '{print $1, $3}' | grep -qE "^http_port_t .*42424"; then
|
# that one stderr line); --modify is then a no-op or fixes a stale type.
|
||||||
semanage port -a -t http_port_t -p tcp 42424 || \
|
semanage port --add --type=http_port_t --proto=tcp "$api_upstream_port" 2>/dev/null \
|
||||||
semanage port -m -t http_port_t -p tcp 42424
|
|| semanage port --modify --type=http_port_t --proto=tcp "$api_upstream_port"
|
||||||
fi
|
|
||||||
|
|
||||||
restorecon -Rv /var/www/rob.tn /etc/nginx/conf.d/rob.tn.conf
|
restorecon -Rv "$web_root" "$site_conf_path"
|
||||||
|
|
||||||
if ! nginx -t; then
|
if ! nginx -t; then
|
||||||
echo "nginx config check failed" >&2
|
echo "nginx config check failed" >&2
|
||||||
@@ -363,7 +479,7 @@ REMOTE_EOF
|
|||||||
|
|
||||||
failed=()
|
failed=()
|
||||||
for component in "${components[@]}"; do
|
for component in "${components[@]}"; do
|
||||||
mapfile -t hosts < <(yq -r "${env_path}.components.${component}.hosts[]" "$manifest")
|
mapfile -t hosts < <(yq --raw-output "${env_path}.components.${component}.hosts[]" "$manifest")
|
||||||
for host in "${hosts[@]}"; do
|
for host in "${hosts[@]}"; do
|
||||||
case "$component" in
|
case "$component" in
|
||||||
api) deploy_api "$host" || failed+=("api@$host") ;;
|
api) deploy_api "$host" || failed+=("api@$host") ;;
|
||||||
|
|||||||
288
script/teardown.sh
Executable file
288
script/teardown.sh
Executable file
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# moments teardown script.
|
||||||
|
#
|
||||||
|
# ./script/teardown.sh <environment> <host> [component...] [--dry-run]
|
||||||
|
# ./script/teardown.sh prod anjie.kosherinata.internal api worker
|
||||||
|
# ./script/teardown.sh prod oolon.kosherinata.internal web --remove-docroot
|
||||||
|
# ./script/teardown.sh prod anjie.kosherinata.internal all --dry-run
|
||||||
|
#
|
||||||
|
# Removes moments unit files, binaries, env files, firewalld service +
|
||||||
|
# definition, SELinux port label, and (when no moments component env files
|
||||||
|
# remain) the shared /etc/moments + /var/lib/moments dirs and the sysusers
|
||||||
|
# entry. Idempotent — safe to re-run.
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - The host argument is explicit on purpose: you typically tear down on
|
||||||
|
# hosts you've already removed from manifest.components.<c>.hosts.
|
||||||
|
# - Manifest is still read for env-wide config (api port, server_name,
|
||||||
|
# docroot path), so $environment must still resolve.
|
||||||
|
# - The `moments` user/group is intentionally NOT removed: any leftover
|
||||||
|
# file owned by it would become orphan-owned. Run `userdel moments`
|
||||||
|
# manually if you're certain there are none.
|
||||||
|
# - Web docroot is left intact unless --remove-docroot is given.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
manifest="${repo_root}/asset/manifest.yml"
|
||||||
|
dry_run=0
|
||||||
|
remove_docroot=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF >&2
|
||||||
|
usage: $(basename "$0") <environment> <host> [component...] [--dry-run] [--remove-docroot]
|
||||||
|
$(basename "$0") prod anjie.kosherinata.internal api worker
|
||||||
|
$(basename "$0") prod oolon.kosherinata.internal web --remove-docroot
|
||||||
|
$(basename "$0") prod anjie.kosherinata.internal all
|
||||||
|
|
||||||
|
components: api | worker | web | all
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m[teardown]\033[0m %s\n' "$*" >&2; }
|
||||||
|
warn() { printf '\033[1;33m[teardown]\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31m[teardown]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
ssh_run() {
|
||||||
|
local host="$1"; shift
|
||||||
|
if (( dry_run )); then
|
||||||
|
printf '\033[2m[dry-run]\033[0m ssh %s -- %s\n' "$host" "$*" >&2
|
||||||
|
else
|
||||||
|
ssh -o BatchMode=yes "$host" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -ge 2 ]] || usage
|
||||||
|
environment="$1"; shift
|
||||||
|
target_host="$1"; shift
|
||||||
|
|
||||||
|
components=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--remove-docroot) remove_docroot=1 ;;
|
||||||
|
*) components+=("$1") ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
|
||||||
|
command -v yq >/dev/null 2>&1 || die "yq is required"
|
||||||
|
|
||||||
|
env_path=".environments.${environment}"
|
||||||
|
yq --exit-status "${env_path}" "$manifest" >/dev/null \
|
||||||
|
|| die "environment '$environment' not found in manifest"
|
||||||
|
|
||||||
|
if [[ ${#components[@]} -eq 0 ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
if [[ "${components[0]:-}" == "all" ]]; then
|
||||||
|
components=(api worker web)
|
||||||
|
fi
|
||||||
|
|
||||||
|
teardown_api() {
|
||||||
|
local host="$1"
|
||||||
|
log "api -> $host"
|
||||||
|
|
||||||
|
local bind api_port=""
|
||||||
|
bind="$(yq --raw-output "${env_path}.components.api.config.bind" "$manifest")"
|
||||||
|
if [[ -n "$bind" && "$bind" != "null" && "$bind" == *:* ]]; then
|
||||||
|
api_port="${bind##*:}"
|
||||||
|
[[ "$api_port" =~ ^[0-9]+$ ]] || api_port=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( dry_run )); then
|
||||||
|
printf '\033[2m[dry-run]\033[0m stop+disable moments-api units, remove unit files, /etc/moments/api.env, /usr/local/bin/moments-api, firewalld svc moments-api, SELinux label tcp/%s on %s\n' \
|
||||||
|
"${api_port:-<unknown>}" "$host" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s -- ${api_port@Q}" <<'REMOTE_EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
api_port="$1"
|
||||||
|
|
||||||
|
# Stop + disable units. `disable --now` quietly does nothing on a unit that
|
||||||
|
# isn't loaded, but emits non-zero exit on some systemd versions when the
|
||||||
|
# file is already gone — swallow that so re-runs are clean.
|
||||||
|
for unit in moments-api.service moments-api-cert.path moments-api-cert-reload.service; do
|
||||||
|
systemctl disable --now "$unit" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
rm --force \
|
||||||
|
/etc/systemd/system/moments-api.service \
|
||||||
|
/etc/systemd/system/moments-api-cert.path \
|
||||||
|
/etc/systemd/system/moments-api-cert-reload.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
rm --force /etc/moments/api.env /usr/local/bin/moments-api
|
||||||
|
|
||||||
|
# Firewalld: remove service from default zone, then drop service definition.
|
||||||
|
zone="$(firewall-cmd --get-default-zone)"
|
||||||
|
if firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
|
||||||
|
firewall-cmd --permanent --zone="$zone" --remove-service=moments-api
|
||||||
|
firewall-cmd --zone="$zone" --remove-service=moments-api 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm --force /etc/firewalld/services/moments-api.xml
|
||||||
|
firewall-cmd --reload
|
||||||
|
|
||||||
|
# SELinux: remove the port label, if we know which port. --delete fails when
|
||||||
|
# the port wasn't user-labelled — that's fine, swallow it.
|
||||||
|
if [[ -n "$api_port" ]]; then
|
||||||
|
semanage port --delete --proto=tcp "$api_port" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "moments-api torn down"
|
||||||
|
REMOTE_EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_worker() {
|
||||||
|
local host="$1"
|
||||||
|
log "worker -> $host"
|
||||||
|
|
||||||
|
if (( dry_run )); then
|
||||||
|
printf '\033[2m[dry-run]\033[0m stop+disable moments-worker units, remove unit files, /etc/moments/worker.env, /usr/local/bin/moments-worker on %s\n' \
|
||||||
|
"$host" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
for unit in moments-worker.service moments-worker-cert.path moments-worker-cert-reload.service; do
|
||||||
|
systemctl disable --now "$unit" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
rm --force \
|
||||||
|
/etc/systemd/system/moments-worker.service \
|
||||||
|
/etc/systemd/system/moments-worker-cert.path \
|
||||||
|
/etc/systemd/system/moments-worker-cert-reload.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
rm --force /etc/moments/worker.env /usr/local/bin/moments-worker
|
||||||
|
|
||||||
|
echo "moments-worker torn down"
|
||||||
|
REMOTE_EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_web() {
|
||||||
|
local host="$1"
|
||||||
|
log "web -> $host"
|
||||||
|
|
||||||
|
local server_name web_root
|
||||||
|
server_name="$(yq --raw-output "${env_path}.components.web.config.server_name" "$manifest")"
|
||||||
|
web_root="$(yq --raw-output "${env_path}.components.web.config.root" "$manifest")"
|
||||||
|
[[ -n "$server_name" && "$server_name" != "null" ]] || die "web.config.server_name missing in manifest"
|
||||||
|
[[ -n "$web_root" && "$web_root" != "null" ]] || die "web.config.root missing in manifest"
|
||||||
|
[[ "$web_root" == /* ]] || die "web.config.root must be an absolute path: '$web_root'"
|
||||||
|
|
||||||
|
# Refuse to recursively remove a shallow or system path even if the
|
||||||
|
# manifest says so.
|
||||||
|
if (( remove_docroot )); then
|
||||||
|
case "$web_root" in
|
||||||
|
/|/bin|/bin/*|/boot|/boot/*|/dev|/dev/*|/etc|/etc/*|/home|/home/*|/lib|/lib/*|/lib64|/lib64/*|/proc|/proc/*|/root|/root/*|/run|/run/*|/sbin|/sbin/*|/srv|/srv/*|/sys|/sys/*|/tmp|/tmp/*|/usr|/usr/*|/var|/var/lib|/var/log|/var/run|/var/spool|/var/www)
|
||||||
|
die "refusing to recursively remove a system path: '$web_root'"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# Require at least three path components (e.g. /var/www/<site>) to
|
||||||
|
# rule out things like /opt or /srv directly.
|
||||||
|
[[ "$web_root" =~ ^/[^/]+/[^/]+/[^/]+ ]] \
|
||||||
|
|| die "refusing to recursively remove a path with fewer than 3 components: '$web_root'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local site_conf_path="/etc/nginx/conf.d/${server_name}.conf"
|
||||||
|
|
||||||
|
if (( dry_run )); then
|
||||||
|
if (( remove_docroot )); then
|
||||||
|
printf '\033[2m[dry-run]\033[0m remove %s, recursively remove %s, nginx -t/reload on %s\n' \
|
||||||
|
"$site_conf_path" "$web_root" "$host" >&2
|
||||||
|
else
|
||||||
|
printf '\033[2m[dry-run]\033[0m remove %s, nginx -t/reload on %s (docroot %s left intact; pass --remove-docroot to also clear it)\n' \
|
||||||
|
"$site_conf_path" "$host" "$web_root" >&2
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s -- ${site_conf_path@Q} ${web_root@Q} ${remove_docroot@Q}" <<'REMOTE_EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
site_conf_path="$1"
|
||||||
|
web_root="$2"
|
||||||
|
remove_docroot="$3"
|
||||||
|
|
||||||
|
rm --force "$site_conf_path"
|
||||||
|
|
||||||
|
if nginx -t 2>&1; then
|
||||||
|
systemctl reload nginx
|
||||||
|
echo "nginx reloaded without ${site_conf_path}"
|
||||||
|
else
|
||||||
|
echo "nginx -t failed AFTER removing ${site_conf_path}; check other site configs" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$remove_docroot" == "1" && -d "$web_root" ]]; then
|
||||||
|
rm --recursive --force "$web_root"
|
||||||
|
echo "removed docroot ${web_root}"
|
||||||
|
fi
|
||||||
|
REMOTE_EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_shared() {
|
||||||
|
local host="$1"
|
||||||
|
log "shared (post-component cleanup) -> $host"
|
||||||
|
|
||||||
|
if (( dry_run )); then
|
||||||
|
printf '\033[2m[dry-run]\033[0m if no api.env/worker.env remain: remove /etc/sysusers.d/moments.conf and rmdir /etc/moments + /var/lib/moments on %s (moments user left in place)\n' \
|
||||||
|
"$host" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# If any component env still exists, leave shared state alone — another
|
||||||
|
# moments component is still using /etc/moments and the moments user.
|
||||||
|
if [[ -e /etc/moments/api.env || -e /etc/moments/worker.env ]]; then
|
||||||
|
echo "moments env files still present; leaving /etc/moments + /var/lib/moments + sysusers entry in place"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# rmdir refuses non-empty dirs — defensive against unknown stragglers.
|
||||||
|
rmdir /etc/moments 2>/dev/null || true
|
||||||
|
rmdir /var/lib/moments 2>/dev/null || true
|
||||||
|
|
||||||
|
rm --force /etc/sysusers.d/moments.conf
|
||||||
|
|
||||||
|
echo "shared state cleared (where empty); moments user/group intentionally left in place"
|
||||||
|
REMOTE_EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dispatch ------------------------------------------------------------------
|
||||||
|
|
||||||
|
failed=()
|
||||||
|
did_app=0
|
||||||
|
for component in "${components[@]}"; do
|
||||||
|
case "$component" in
|
||||||
|
api) teardown_api "$target_host" || failed+=("api@$target_host") ;;
|
||||||
|
worker) teardown_worker "$target_host" || failed+=("worker@$target_host") ;;
|
||||||
|
web) teardown_web "$target_host" || failed+=("web@$target_host") ;;
|
||||||
|
*) warn "unknown component: $component" ;;
|
||||||
|
esac
|
||||||
|
case "$component" in
|
||||||
|
api|worker) did_app=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Shared cleanup runs after api/worker teardown. It's a no-op if either
|
||||||
|
# component still has its env file present on the host.
|
||||||
|
if (( did_app )); then
|
||||||
|
teardown_shared "$target_host" || failed+=("shared@$target_host")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#failed[@]} -gt 0 ]]; then
|
||||||
|
die "failed: ${failed[*]}"
|
||||||
|
fi
|
||||||
|
log "teardown complete on $target_host"
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"react-bootstrap-icons": "^1.11.4",
|
"react-bootstrap-icons": "^1.11.4",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
"react-vertical-timeline-component": "^3.6.0"
|
"react-vertical-timeline-component": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
45
ui/pnpm-lock.yaml
generated
45
ui/pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
|||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^9.0.1
|
specifier: ^9.0.1
|
||||||
version: 9.1.0(@types/react@19.2.14)(react@19.2.5)
|
version: 9.1.0(@types/react@19.2.14)(react@19.2.5)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^7.14.2
|
||||||
|
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
react-vertical-timeline-component:
|
react-vertical-timeline-component:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0(react@19.2.5)
|
version: 3.6.0(react@19.2.5)
|
||||||
@@ -593,6 +596,10 @@ packages:
|
|||||||
comma-separated-tokens@2.0.3:
|
comma-separated-tokens@2.0.3:
|
||||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||||
|
|
||||||
|
cookie@1.1.1:
|
||||||
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -867,6 +874,23 @@ packages:
|
|||||||
'@types/react': '>=18'
|
'@types/react': '>=18'
|
||||||
react: '>=18'
|
react: '>=18'
|
||||||
|
|
||||||
|
react-router-dom@7.14.2:
|
||||||
|
resolution: {integrity: sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
|
||||||
|
react-router@7.14.2:
|
||||||
|
resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-stately@3.46.0:
|
react-stately@3.46.0:
|
||||||
resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
|
resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -899,6 +923,9 @@ packages:
|
|||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2:
|
||||||
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1378,6 +1405,8 @@ snapshots:
|
|||||||
|
|
||||||
comma-separated-tokens@2.0.3: {}
|
comma-separated-tokens@2.0.3: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
@@ -1841,6 +1870,20 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
react-router-dom@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
|
||||||
|
react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
react: 19.2.5
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
react-stately@3.46.0(react@19.2.5):
|
react-stately@3.46.0(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@internationalized/date': 3.12.1
|
'@internationalized/date': 3.12.1
|
||||||
@@ -1920,6 +1963,8 @@ snapshots:
|
|||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
space-separated-tokens@2.0.2: {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
body {
|
body {
|
||||||
background-color: #2c3e50;
|
background-color: #2c3e50;
|
||||||
color: #ecf0f1;
|
color: #ecf0f1;
|
||||||
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -36,3 +37,11 @@ a.hot-pink {
|
|||||||
.vertical-timeline-element-content a {
|
.vertical-timeline-element-content a {
|
||||||
color: #1565c0;
|
color: #1565c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
134
ui/src/App.tsx
134
ui/src/App.tsx
@@ -1,132 +1,24 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import Col from 'react-bootstrap/Col';
|
|
||||||
import Container from 'react-bootstrap/Container';
|
|
||||||
import Row from 'react-bootstrap/Row';
|
|
||||||
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
|
||||||
|
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import 'rc-slider/assets/index.css';
|
import 'rc-slider/assets/index.css';
|
||||||
import 'react-vertical-timeline-component/style.min.css';
|
import 'react-vertical-timeline-component/style.min.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
import { fetchEvents, fetchSources, type Source } from './api/client';
|
import { TimelineHome } from './pages/TimelineHome';
|
||||||
import { Filters } from './components/Filters';
|
import { CvPage } from './pages/CvPage';
|
||||||
import { TimelineEntry } from './components/TimelineEntry';
|
|
||||||
|
|
||||||
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
|
||||||
const RANGE_MAX = Date.now();
|
|
||||||
|
|
||||||
const externalLinks: { url: string; alt: string }[] = [
|
|
||||||
{ url: 'https://linkedin.com/in/thijssen/', alt: 'linkedin' },
|
|
||||||
{ url: 'https://stackoverflow.com/users/68115/grenade', alt: 'stackoverflow' },
|
|
||||||
{ url: 'https://github.com/grenade', alt: 'github' },
|
|
||||||
{ url: 'https://git.lair.cafe/grenade', alt: 'gitea' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
|
|
||||||
github: true,
|
|
||||||
gitea: true,
|
|
||||||
hg: true,
|
|
||||||
bugzilla: true,
|
|
||||||
});
|
|
||||||
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
|
||||||
return [thirtyDaysAgo, now];
|
|
||||||
});
|
|
||||||
const [limit, setLimit] = useState<number>(100);
|
|
||||||
|
|
||||||
const sourcesQ = useQuery({
|
|
||||||
queryKey: ['sources'],
|
|
||||||
queryFn: fetchSources,
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeSources = useMemo(
|
|
||||||
() =>
|
|
||||||
(Object.keys(enabledSources) as Source[]).filter((s) => enabledSources[s]),
|
|
||||||
[enabledSources],
|
|
||||||
);
|
|
||||||
|
|
||||||
const eventsQ = useQuery({
|
|
||||||
queryKey: ['events', rangeValue, activeSources, limit],
|
|
||||||
queryFn: () =>
|
|
||||||
fetchEvents({
|
|
||||||
from: new Date(rangeValue[0]),
|
|
||||||
to: new Date(rangeValue[1]),
|
|
||||||
sources: activeSources,
|
|
||||||
limit,
|
|
||||||
}),
|
|
||||||
refetchInterval: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = eventsQ.data ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-4">
|
<>
|
||||||
<Row className="mb-3">
|
<Routes>
|
||||||
<Col>
|
<Route path="/" element={<TimelineHome />} />
|
||||||
<h1>hi, i'm rob</h1>
|
<Route path="/cv" element={<CvPage />} />
|
||||||
</Col>
|
</Routes>
|
||||||
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center">
|
<footer className="site-footer">
|
||||||
{externalLinks.map((el) => (
|
no cookies are set or read by this site, which is why no consent banner
|
||||||
<a
|
is shown.
|
||||||
key={el.url}
|
</footer>
|
||||||
href={el.url}
|
</>
|
||||||
title={el.alt}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{el.alt}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row className="mb-4">
|
|
||||||
<Col>
|
|
||||||
<p>
|
|
||||||
i rarely say anything that warrants capital letters. if you're here
|
|
||||||
to see my resume, please go to{' '}
|
|
||||||
<a className="hot-pink" href="https://rob.tn/cv/">
|
|
||||||
https://rob.tn/cv
|
|
||||||
</a>
|
|
||||||
. a peek into the projects i'm working on is below.
|
|
||||||
</p>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Filters
|
|
||||||
enabledSources={enabledSources}
|
|
||||||
onSourceToggle={(s, on) =>
|
|
||||||
setEnabledSources((prev) => ({ ...prev, [s]: on }))
|
|
||||||
}
|
|
||||||
rangeMin={RANGE_MIN}
|
|
||||||
rangeMax={RANGE_MAX}
|
|
||||||
rangeValue={rangeValue}
|
|
||||||
onRangeChange={setRangeValue}
|
|
||||||
limit={limit}
|
|
||||||
onLimitChange={setLimit}
|
|
||||||
summaries={sourcesQ.data}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Row>
|
|
||||||
<Col>
|
|
||||||
<p className="text-center" style={{ fontSize: '85%' }}>
|
|
||||||
{eventsQ.isLoading
|
|
||||||
? 'loading…'
|
|
||||||
: eventsQ.isError
|
|
||||||
? `error: ${(eventsQ.error as Error).message}`
|
|
||||||
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
|
|
||||||
</p>
|
|
||||||
<VerticalTimeline>
|
|
||||||
{events.map((item) => (
|
|
||||||
<TimelineEntry key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</VerticalTimeline>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
ui/src/api/cv.ts
Normal file
82
ui/src/api/cv.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Fetches the CV gist at runtime and returns the parsed config + file
|
||||||
|
// list. The legacy implementation (cv/src/App.js) hits the same endpoint
|
||||||
|
// and relies entirely on the inline `content` field — no per-file raw_url
|
||||||
|
// fetches. We do the same: one request, dedup'd via TanStack Query.
|
||||||
|
|
||||||
|
const GIST_OWNER = 'grenade';
|
||||||
|
const GIST_ID = '8e487477663c8e57c7bf31e8371f454a';
|
||||||
|
const GIST_API_URL = `https://api.github.com/gists/${GIST_ID}`;
|
||||||
|
const GIST_RAW_BASE = `https://gist.githubusercontent.com/${GIST_OWNER}/${GIST_ID}/raw`;
|
||||||
|
|
||||||
|
export const CV_PHOTO_URL = `${GIST_RAW_BASE}/rob.png`;
|
||||||
|
|
||||||
|
const CONFIG_FILENAME = 'cv-config.json';
|
||||||
|
|
||||||
|
export type SectionPlacement = 'body' | 'nav';
|
||||||
|
export type SortDirection = 'ascending' | 'descending';
|
||||||
|
|
||||||
|
export interface CvSectionConfig {
|
||||||
|
name: string;
|
||||||
|
filename_prefix: string;
|
||||||
|
order: number;
|
||||||
|
show_section_name: boolean;
|
||||||
|
placement: SectionPlacement;
|
||||||
|
sort?: {
|
||||||
|
on: 'filename';
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CvConfig {
|
||||||
|
sections: CvSectionConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GistFile {
|
||||||
|
filename: string;
|
||||||
|
type: string;
|
||||||
|
language: string | null;
|
||||||
|
raw_url: string;
|
||||||
|
size: number;
|
||||||
|
truncated: boolean;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GistResponse {
|
||||||
|
files: Record<string, GistFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CvData {
|
||||||
|
config: CvConfig;
|
||||||
|
files: Record<string, GistFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCv(): Promise<CvData> {
|
||||||
|
const resp = await fetch(GIST_API_URL);
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`gist: HTTP ${resp.status} ${resp.statusText}`);
|
||||||
|
}
|
||||||
|
const gist = (await resp.json()) as GistResponse;
|
||||||
|
|
||||||
|
const cfgFile = gist.files[CONFIG_FILENAME];
|
||||||
|
if (!cfgFile) {
|
||||||
|
throw new Error(`gist: missing ${CONFIG_FILENAME}`);
|
||||||
|
}
|
||||||
|
const config = JSON.parse(cfgFile.content) as CvConfig;
|
||||||
|
|
||||||
|
return { config, files: gist.files };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick out the gist files whose names start with the given prefix, applying
|
||||||
|
// the section's sort order. Mirrors the legacy filter at cv/src/App.js:67-68.
|
||||||
|
export function filesForSection(
|
||||||
|
data: CvData,
|
||||||
|
section: CvSectionConfig,
|
||||||
|
): GistFile[] {
|
||||||
|
const matches = Object.keys(data.files)
|
||||||
|
.filter((name) => name.startsWith(section.filename_prefix))
|
||||||
|
.sort();
|
||||||
|
if (section.sort?.direction === 'descending') {
|
||||||
|
matches.reverse();
|
||||||
|
}
|
||||||
|
return matches.map((name) => data.files[name]);
|
||||||
|
}
|
||||||
22
ui/src/components/cv/CvHeader.tsx
Normal file
22
ui/src/components/cv/CvHeader.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Image from 'react-bootstrap/Image';
|
||||||
|
import { CV_PHOTO_URL } from '../../api/cv';
|
||||||
|
|
||||||
|
export function CvHeader() {
|
||||||
|
return (
|
||||||
|
<div className="cv-header d-flex flex-column flex-md-row align-items-md-center gap-3 mb-4">
|
||||||
|
<Image
|
||||||
|
src={CV_PHOTO_URL}
|
||||||
|
alt="rob"
|
||||||
|
roundedCircle
|
||||||
|
className="cv-photo"
|
||||||
|
/>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<h1 className="mb-1">curriculum vitae</h1>
|
||||||
|
<Link to="/" className="hot-pink">
|
||||||
|
← timeline
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
ui/src/components/cv/CvSection.tsx
Normal file
66
ui/src/components/cv/CvSection.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import Card from 'react-bootstrap/Card';
|
||||||
|
import { type CvSectionConfig, type GistFile } from '../../api/cv';
|
||||||
|
import { entryAnchorId } from '../../lib/cvDates';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: CvSectionConfig;
|
||||||
|
files: GistFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipe-delimited fields (e.g. "email | phone | github, linkedin" in the
|
||||||
|
// contact section) become one paragraph per field, so each lands on its own
|
||||||
|
// line with a paragraph gap. Within each pipe-segment, comma-separated values
|
||||||
|
// are stacked with a soft line break (markdown ` \n` -> `<br/>`) so multiple
|
||||||
|
// emails / phones / urls each get their own line at a tighter spacing.
|
||||||
|
function splitPipes(content: string): string {
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
if (!line.includes(' | ')) return line;
|
||||||
|
return line
|
||||||
|
.split(' | ')
|
||||||
|
.map((segment) =>
|
||||||
|
segment.includes(', ') ? segment.split(', ').join(' \n') : segment,
|
||||||
|
)
|
||||||
|
.join('\n\n');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a single section. Each .md file becomes its own block. When
|
||||||
|
// `show_section_name` is true (e.g. experience, education) the entries are
|
||||||
|
// wrapped in cards and given anchor ids so the timeline sidebar can deep-link
|
||||||
|
// to them; otherwise (e.g. summary, contact) they render as flat markdown.
|
||||||
|
export function CvSection({ section, files }: Props) {
|
||||||
|
return (
|
||||||
|
<section id={section.name} className="cv-section mb-4">
|
||||||
|
{section.show_section_name && <h2 className="cv-section-name">{section.name}</h2>}
|
||||||
|
{files.map((file) => {
|
||||||
|
const content = section.show_section_name
|
||||||
|
? file.content
|
||||||
|
: splitPipes(file.content);
|
||||||
|
if (section.show_section_name) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.filename}
|
||||||
|
id={entryAnchorId(section.name, file.content)}
|
||||||
|
className="cv-entry mb-3"
|
||||||
|
>
|
||||||
|
<Card className="cv-card">
|
||||||
|
<Card.Body>
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={file.filename} className="cv-entry">
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
ui/src/components/cv/CvTimeline.tsx
Normal file
98
ui/src/components/cv/CvTimeline.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
VerticalTimeline,
|
||||||
|
VerticalTimelineElement,
|
||||||
|
} from 'react-vertical-timeline-component';
|
||||||
|
import { type CvData, filesForSection } from '../../api/cv';
|
||||||
|
import { entryAnchorId, parseEntryHeader } from '../../lib/cvDates';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: CvData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline parser: turns "[text](url)" segments into <a> elements while
|
||||||
|
// leaving surrounding text alone. Used so the timeline title renders the
|
||||||
|
// company name as plain text and the linked website as an external link
|
||||||
|
// (matching how a markdown parser would render the same source).
|
||||||
|
function renderInlineLinks(text: string): ReactNode[] {
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
if (m.index > last) parts.push(text.slice(last, m.index));
|
||||||
|
parts.push(
|
||||||
|
<a key={m.index} href={m[2]} target="_blank" rel="noopener noreferrer">
|
||||||
|
{m[1]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) parts.push(text.slice(last));
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar timeline rendered from every body section that has `show_section_name`
|
||||||
|
// (i.e. timeline-eligible sections — experience and education in the current
|
||||||
|
// gist). Each element offers a small "→" link to the matching anchor in the
|
||||||
|
// body; the title and subtitle preserve any inline markdown links so they
|
||||||
|
// behave as proper external anchors.
|
||||||
|
export function CvTimeline({ data }: Props) {
|
||||||
|
const elements = data.config.sections
|
||||||
|
.filter((s) => s.placement === 'body' && s.show_section_name)
|
||||||
|
.flatMap((section) =>
|
||||||
|
filesForSection(data, section).map((file) => ({ section, file })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (elements.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cv-timeline">
|
||||||
|
<h2 className="cv-section-name">timeline</h2>
|
||||||
|
<VerticalTimeline layout="1-column-left" lineColor="#ecf0f1">
|
||||||
|
{elements.map(({ section, file }) => {
|
||||||
|
const parsed = parseEntryHeader(file.content);
|
||||||
|
const anchor = `#${entryAnchorId(section.name, file.content)}`;
|
||||||
|
return (
|
||||||
|
<VerticalTimelineElement
|
||||||
|
key={file.filename}
|
||||||
|
date={parsed.interval.replace(/\s*\([^)]*\)/g, '')}
|
||||||
|
iconStyle={
|
||||||
|
parsed.iconUrl
|
||||||
|
? { background: '#ffffff', boxShadow: 'none' }
|
||||||
|
: { background: '#ff4081', color: '#ffffff' }
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
parsed.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={parsed.iconUrl}
|
||||||
|
alt={parsed.title}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 className="vertical-timeline-element-title">
|
||||||
|
{renderInlineLinks(parsed.titleMd)}
|
||||||
|
</h3>
|
||||||
|
{parsed.locationRoleMd && (
|
||||||
|
<h4 className="vertical-timeline-element-subtitle">
|
||||||
|
{renderInlineLinks(parsed.locationRoleMd)}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<a href={anchor} className="cv-timeline-anchor">
|
||||||
|
→ details
|
||||||
|
</a>
|
||||||
|
</VerticalTimelineElement>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VerticalTimeline>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
ui/src/lib/cvDates.ts
Normal file
91
ui/src/lib/cvDates.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Normalizes the date interval line of a CV entry. The legacy implementation
|
||||||
|
// at cv/src/App.js:139 chained .replace() calls per month name; this collapses
|
||||||
|
// that into a single regex pass.
|
||||||
|
|
||||||
|
const MONTH_ABBREV: Record<string, string> = {
|
||||||
|
january: 'jan',
|
||||||
|
february: 'feb',
|
||||||
|
march: 'mar',
|
||||||
|
april: 'apr',
|
||||||
|
// may stays "may"
|
||||||
|
june: 'jun',
|
||||||
|
july: 'jul',
|
||||||
|
august: 'aug',
|
||||||
|
september: 'sep',
|
||||||
|
october: 'oct',
|
||||||
|
november: 'nov',
|
||||||
|
december: 'dec',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_RE = new RegExp(
|
||||||
|
`\\b(${Object.keys(MONTH_ABBREV).join('|')})\\b`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip leading/trailing markdown # / whitespace, abbreviate months. Casing
|
||||||
|
// is left to the body-level `text-transform: lowercase` so a future toggle can
|
||||||
|
// flip it from a single place.
|
||||||
|
export function normalizeInterval(line: string): string {
|
||||||
|
return line
|
||||||
|
.replace(/^[\s#]+|[\s#]+$/g, '')
|
||||||
|
.replace(MONTH_RE, (m) => MONTH_ABBREV[m.toLowerCase()] ?? m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip markdown link syntax (e.g. "[text](url)") down to just the text.
|
||||||
|
export function stripMdLinks(s: string): string {
|
||||||
|
return s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the title / location-role / interval triple out of a CV entry's
|
||||||
|
// markdown content. If the first line embeds a PNG URL (icon-style entry),
|
||||||
|
// indices shift down by one.
|
||||||
|
export interface ParsedHeader {
|
||||||
|
// Plain text, markdown links stripped — for alt= attributes and similar.
|
||||||
|
title: string;
|
||||||
|
locationRole: string;
|
||||||
|
// Markdown source with leading #s/whitespace stripped — for inline rendering
|
||||||
|
// so [text](url) links render as proper anchors.
|
||||||
|
titleMd: string;
|
||||||
|
locationRoleMd: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
interval: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEntryHeader(content: string): ParsedHeader {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const firstLine = lines[0] ?? '';
|
||||||
|
const hasIcon = firstLine.includes('.png');
|
||||||
|
const iconUrl = hasIcon
|
||||||
|
? (firstLine.match(/https:[^ )\]]+\.png/)?.[0] ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const titleLine = hasIcon ? (lines[1] ?? '') : firstLine;
|
||||||
|
const locRoleLine = hasIcon ? (lines[2] ?? '') : (lines[1] ?? '');
|
||||||
|
const intervalLine = hasIcon ? (lines[3] ?? '') : (lines[2] ?? '');
|
||||||
|
|
||||||
|
const titleMd = titleLine.replace(/^[\s#]+|[\s#]+$/g, '');
|
||||||
|
const locationRoleMd = locRoleLine.replace(/^[\s#]+|[\s#]+$/g, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: stripMdLinks(titleMd),
|
||||||
|
locationRole: stripMdLinks(locationRoleMd),
|
||||||
|
titleMd,
|
||||||
|
locationRoleMd,
|
||||||
|
iconUrl,
|
||||||
|
interval: normalizeInterval(intervalLine),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor id for an entry: combines the section name and a slug of the title
|
||||||
|
// line. Mirrors the legacy id format at cv/src/App.js:71.
|
||||||
|
export function entryAnchorId(sectionName: string, content: string): string {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const firstLine = lines[0] ?? '';
|
||||||
|
const titleLine = firstLine.includes('.png') ? (lines[1] ?? '') : firstLine;
|
||||||
|
const slug = stripMdLinks(titleLine.replace(/^[\s#]+|[\s#]+$/g, ''))
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
return `${sectionName}-${slug}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
@@ -15,7 +16,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
98
ui/src/pages/CvPage.css
Normal file
98
ui/src/pages/CvPage.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
.cv-photo {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-section-name {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(236, 241, 241, 0.2);
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card {
|
||||||
|
background-color: #34495e;
|
||||||
|
color: #ecf0f1;
|
||||||
|
border: 1px solid rgba(236, 241, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card a {
|
||||||
|
color: #ff80ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card a:hover {
|
||||||
|
color: #ff4081;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card img {
|
||||||
|
max-width: 96px;
|
||||||
|
max-height: 48px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card h4 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-card h5 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline .vertical-timeline-element-content {
|
||||||
|
background-color: #34495e;
|
||||||
|
color: #ecf0f1;
|
||||||
|
box-shadow: 0 3px 0 #ff4081;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline .vertical-timeline-element-content a {
|
||||||
|
color: #ff80ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline .vertical-timeline-element-content a:hover {
|
||||||
|
color: #ff4081;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline h3.vertical-timeline-element-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline h4.vertical-timeline-element-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
margin: 0.15rem 0 0.4rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline .cv-timeline-anchor {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
float: right;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline .vertical-timeline-element-content::before {
|
||||||
|
border-right-color: #34495e;
|
||||||
|
border-left-color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-timeline .vertical-timeline-element-date {
|
||||||
|
color: #ecf0f1 !important;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.cv-timeline {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
ui/src/pages/CvPage.tsx
Normal file
108
ui/src/pages/CvPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import Alert from 'react-bootstrap/Alert';
|
||||||
|
import Col from 'react-bootstrap/Col';
|
||||||
|
import Container from 'react-bootstrap/Container';
|
||||||
|
import Row from 'react-bootstrap/Row';
|
||||||
|
import Spinner from 'react-bootstrap/Spinner';
|
||||||
|
|
||||||
|
import { fetchCv, filesForSection } from '../api/cv';
|
||||||
|
import { CvHeader } from '../components/cv/CvHeader';
|
||||||
|
import { CvSection } from '../components/cv/CvSection';
|
||||||
|
import { CvTimeline } from '../components/cv/CvTimeline';
|
||||||
|
import './CvPage.css';
|
||||||
|
|
||||||
|
export function CvPage() {
|
||||||
|
const { hash } = useLocation();
|
||||||
|
const cvQ = useQuery({
|
||||||
|
queryKey: ['cv-gist'],
|
||||||
|
queryFn: fetchCv,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to the anchored entry once the gist resolves and the section
|
||||||
|
// body has rendered its ids. Re-runs if the user changes the hash while
|
||||||
|
// already on the page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cvQ.data || !hash) return;
|
||||||
|
const target = document.getElementById(hash.slice(1));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, [cvQ.data, hash]);
|
||||||
|
|
||||||
|
if (cvQ.isLoading) {
|
||||||
|
return (
|
||||||
|
<Container className="py-4">
|
||||||
|
<CvHeader />
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Spinner animation="border" role="status" size="sm" />
|
||||||
|
<span>loading cv…</span>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cvQ.isError) {
|
||||||
|
const msg = (cvQ.error as Error).message;
|
||||||
|
const rateHint = /403|rate limit/i.test(msg)
|
||||||
|
? ' (github limits unauthenticated requests to 60/hour per ip — try again shortly)'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<Container className="py-4">
|
||||||
|
<CvHeader />
|
||||||
|
<Alert variant="danger">
|
||||||
|
<Alert.Heading>cv unavailable</Alert.Heading>
|
||||||
|
<p className="mb-2">
|
||||||
|
{msg}
|
||||||
|
{rateHint}
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-outline-light" onClick={() => cvQ.refetch()}>
|
||||||
|
retry
|
||||||
|
</button>
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = cvQ.data!;
|
||||||
|
const bodySections = data.config.sections.filter((s) => s.placement === 'body');
|
||||||
|
const navSections = data.config.sections.filter((s) => s.placement === 'nav');
|
||||||
|
|
||||||
|
if (bodySections.length === 0 && navSections.length === 0) {
|
||||||
|
return (
|
||||||
|
<Container className="py-4">
|
||||||
|
<CvHeader />
|
||||||
|
<Alert variant="warning">cv unavailable: no sections in config</Alert>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="py-4">
|
||||||
|
<CvHeader />
|
||||||
|
<Row>
|
||||||
|
<Col lg={9} className="cv-body">
|
||||||
|
{bodySections.map((section) => (
|
||||||
|
<CvSection
|
||||||
|
key={section.name}
|
||||||
|
section={section}
|
||||||
|
files={filesForSection(data, section)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
<Col lg={3} className="cv-sidebar">
|
||||||
|
{navSections.map((section) => (
|
||||||
|
<CvSection
|
||||||
|
key={section.name}
|
||||||
|
section={section}
|
||||||
|
files={filesForSection(data, section)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<CvTimeline data={data} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
ui/src/pages/TimelineHome.tsx
Normal file
128
ui/src/pages/TimelineHome.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Col from 'react-bootstrap/Col';
|
||||||
|
import Container from 'react-bootstrap/Container';
|
||||||
|
import Row from 'react-bootstrap/Row';
|
||||||
|
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
||||||
|
|
||||||
|
import { fetchEvents, fetchSources, type Source } from '../api/client';
|
||||||
|
import { Filters } from '../components/Filters';
|
||||||
|
import { TimelineEntry } from '../components/TimelineEntry';
|
||||||
|
|
||||||
|
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
||||||
|
const RANGE_MAX = Date.now();
|
||||||
|
|
||||||
|
const externalLinks: { url: string; alt: string }[] = [
|
||||||
|
{ url: 'https://linkedin.com/in/thijssen/', alt: 'linkedin' },
|
||||||
|
{ url: 'https://stackoverflow.com/users/68115/grenade', alt: 'stackoverflow' },
|
||||||
|
{ url: 'https://github.com/grenade', alt: 'github' },
|
||||||
|
{ url: 'https://git.lair.cafe/grenade', alt: 'gitea' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TimelineHome() {
|
||||||
|
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
|
||||||
|
github: true,
|
||||||
|
gitea: true,
|
||||||
|
hg: true,
|
||||||
|
bugzilla: true,
|
||||||
|
});
|
||||||
|
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
return [thirtyDaysAgo, now];
|
||||||
|
});
|
||||||
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
|
||||||
|
const sourcesQ = useQuery({
|
||||||
|
queryKey: ['sources'],
|
||||||
|
queryFn: fetchSources,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSources = useMemo(
|
||||||
|
() =>
|
||||||
|
(Object.keys(enabledSources) as Source[]).filter((s) => enabledSources[s]),
|
||||||
|
[enabledSources],
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventsQ = useQuery({
|
||||||
|
queryKey: ['events', rangeValue, activeSources, limit],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchEvents({
|
||||||
|
from: new Date(rangeValue[0]),
|
||||||
|
to: new Date(rangeValue[1]),
|
||||||
|
sources: activeSources,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = eventsQ.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="py-4">
|
||||||
|
<Row className="mb-3">
|
||||||
|
<Col>
|
||||||
|
<h1>hi, i'm rob</h1>
|
||||||
|
</Col>
|
||||||
|
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center">
|
||||||
|
{externalLinks.map((el) => (
|
||||||
|
<a
|
||||||
|
key={el.url}
|
||||||
|
href={el.url}
|
||||||
|
title={el.alt}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{el.alt}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row className="mb-4">
|
||||||
|
<Col>
|
||||||
|
<p>
|
||||||
|
i rarely say anything that warrants capital letters. if you're here
|
||||||
|
to see my resume, please go to{' '}
|
||||||
|
<Link className="hot-pink" to="/cv">
|
||||||
|
/cv
|
||||||
|
</Link>
|
||||||
|
. a peek into the projects i'm working on is below.
|
||||||
|
</p>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Filters
|
||||||
|
enabledSources={enabledSources}
|
||||||
|
onSourceToggle={(s, on) =>
|
||||||
|
setEnabledSources((prev) => ({ ...prev, [s]: on }))
|
||||||
|
}
|
||||||
|
rangeMin={RANGE_MIN}
|
||||||
|
rangeMax={RANGE_MAX}
|
||||||
|
rangeValue={rangeValue}
|
||||||
|
onRangeChange={setRangeValue}
|
||||||
|
limit={limit}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
summaries={sourcesQ.data}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<p className="text-center" style={{ fontSize: '85%' }}>
|
||||||
|
{eventsQ.isLoading
|
||||||
|
? 'loading…'
|
||||||
|
: eventsQ.isError
|
||||||
|
? `error: ${(eventsQ.error as Error).message}`
|
||||||
|
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
|
||||||
|
</p>
|
||||||
|
<VerticalTimeline>
|
||||||
|
{events.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</VerticalTimeline>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user