# 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` and pushes. CI (`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and attaches `portal-v.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//releases/`, flips `current`, rewrites the instance env from that repo's own Actions variables and secrets, restarts `app@` 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 | | `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens | | `SEED_ADMIN_EMAIL`, `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; they get an account at start 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 -attachments garage key create -portal-uploads garage bucket allow --read --write -attachments --key -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. ## 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.