Files
portal/docs/operations.md
T
2026-09-30 17:34:21 +02:00

173 lines
7.4 KiB
Markdown

# Operations
Portal does not deploy itself. It publishes versions; each site
decides when to take one.
## Cut a release
```sh
cargo release patch # or minor / major
```
That bumps `Cargo.toml`, tags `v<version>` and pushes. CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal`, `iris`, `hash.txt`,
`site/`) to the Gitea release. Plain pushes to `main` only run the
tests: nothing reaches production from this repo.
The same job re-pins, tests and tags [iris](https://project.uhhm.no/uhhm/iris)
to match, so a portal change to anything the lint reads fails the
release rather than the next content push.
Before cutting, move the `Unreleased` lines in `CHANGELOG.md` under a
heading for the new version. That section becomes the release notes,
and the table at the end of the file records which version each site
runs.
Pkg files are content-hashed (`hash-files = true`, instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm.
## Roll it out
In the content repo, one line of `.gitea/workflows/deploy.yml`:
```yaml
env:
PORTAL_RELEASE: v0.4.0
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables and secrets,
restarts `app@<instance>` and refreshes the Caddy route. Every rollout
is a commit, so reverting a bad version is `git revert` and a push.
Sites upgrade independently and may pin different versions.
Content changes never come through any of this: they hot-reload live
from the content repos over NATS.
## Add a site
A new content repo with a copy of an existing `deploy.yml` (change
`INSTANCE`, the port and the domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`, `PORTAL_GITEA_API_TOKEN`
— Gitea reserves the `GITEA_` prefix for secret names), a Kanidm
OAuth2 client, and DNS. `site.yaml` handles all branding; no portal
change is needed.
## Environment
Runtime configuration is environment variables; `src/main.rs` is the
authority and each content repo's `deploy.yml` holds the production
values.
| | |
|---|---|
| `NATS_URL` | events, projections, sessions, content reload, access questions |
| `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site |
| `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in |
| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise |
| `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm |
| `KANIDM_GROUP_PREFIX` | for a Kanidm shared with other sites: the content's `kunder` is the group `<prefix>kunder` there, and the OAuth2 client's claim map gives it back to portal as `kunder` |
| `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set |
| `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
| `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) |
| `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way |
| `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail |
| `GITEA_API_TOKEN` | authenticated resource pulls |
| `AUTOMATION_READ_TOKEN` | the automation KV read endpoint |
| `GARAGE_*` | uploads |
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
separate unit on the same host reading the same NATS.
## Rate limiting
Every request passes a per-caller-IP limit before a session is loaded
or a handler is entered: a burst of 30, refilled one every two
seconds. A visitor filling in a form never reaches it (a page load
fetches several resources at once); a script does.
| | |
|---|---|
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
Either set to `0` turns it off, for an instance behind something that
already does this. The caller is read from `X-Forwarded-For` or
`X-Real-IP`, falling back to the socket — all of these instances are
behind Caddy.
This is a fence, not an accounting system. What keeps uploads honest
is the policy check on the route itself.
## Attachments
A `type: file` field stores an object key in the record. The file
comes back out at:
GET /attachment/{bucket}/{record}/{field}
which reads the key out of that record rather than taking one from the
caller, so a file can only be fetched through a record somebody is
allowed to see, and a leaked key is a link to nothing. Reading it
needs the same policy permission as reading the bucket on a desk.
Uploads need a bucket and a key in the Garage on the host:
```sh
garage bucket create <site>-attachments
garage key create <site>-portal-uploads
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads
```
then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
fails closed rather than appearing to work and storing nowhere.
## The report
`GET /report` is public and answers with counts only: per bucket, per
[storyline](storylines.md), per state. It is what the trainer reads to
compare the storylines on what people did.
## The docs site
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
with mdBook (`book.toml`, `docs/SUMMARY.md`) by `.gitea/workflows/docs.yml`
on every push to main that touches them, and put to git-pages the way
every static site on uhhm.no is (uhhm/infrastructure `PAGES.md`). The
changelog is the book's last page. `mdbook serve` previews it locally.
## Who deploys what
Both hosts deploy from CI now, and a rollout is the same commit either
way: bump `PORTAL_RELEASE` in the site's own content repo.
| | runner | how it is allowed to |
|---|---|---|
| ergo | `runs-on: bare` | the runner is privileged and writes `/srv/app` and `/etc/app` itself |
| kasse | `runs-on: fish` | the runner is not privileged. It may run one root script, `/usr/local/bin/deploy-portal-instance`, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything |
The kasse grant is a single sudoers line
(`/etc/sudoers.d/30-portal-deploy`). It exists so a workflow can pick
which of three known instances gets which published portal tag, and
nothing else.
Both paths health-check the instance afterwards **without** a forwarded
header. A check that sends the header Caddy would send cannot tell you
the site is broken for everything that is not Caddy, which is how
v0.5.1 reached a host.
`IRIS_RELEASE` in a repo's lint workflow should equal its
`PORTAL_RELEASE`. Bump it only once iris's own publish job has attached
the binary for that tag - portal's release tags iris, but iris builds
and uploads on its own run, and a pin bumped in between fails the lint
on a download that is not there yet.