From 92850abec89e0ac3e28521470c4baac701d8bb5d Mon Sep 17 00:00:00 2001 From: Ian Coutinho <75703845+IanCou@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:01:53 -0400 Subject: [PATCH] Add one-command local dev stack and update local development docs Adds peopleportal/dev-stack: authentik, Gitea, MongoDB, Redis, Postgres and Mailpit via Docker Compose, with idempotent bootstrap scripts, a smoke test (verify.sh), and project-scoped reset. Conventional default ports, all overridable and bound to 127.0.0.1. webui/vite.config.ts reads optional PP_DEV_* port overrides and is unchanged in behavior when they are unset. Rewrites the local development guide around the stack and keeps the manual instructions in a Manual setup section. S3 storage is a documented follow-up. --- README.md | 25 +++ .../contribution-guidelines/git/branching.md | 2 +- .../docs/contribution-guidelines/git/pr.md | 4 +- .../deployment/environment-variables.md | 49 ++++- .../deployment/local-development.md | 180 +++++++++++++--- peopleportal/dev-stack/.env.example | 42 ++++ peopleportal/dev-stack/.gitignore | 9 + peopleportal/dev-stack/README.md | 137 ++++++++++++ .../dev-stack/blueprints/people-portal.yaml | 117 +++++++++++ peopleportal/dev-stack/docker-compose.yml | 195 ++++++++++++++++++ .../dev-stack/scripts/authentik-bootstrap.py | 79 +++++++ .../dev-stack/scripts/bootstrap-authentik.sh | 13 ++ .../dev-stack/scripts/bootstrap-gitea.sh | 77 +++++++ peopleportal/dev-stack/scripts/check-ports.py | 54 +++++ peopleportal/dev-stack/scripts/common.sh | 104 ++++++++++ peopleportal/dev-stack/scripts/gen-env.sh | 55 +++++ peopleportal/dev-stack/scripts/oidc-bridge.py | 57 +++++ peopleportal/dev-stack/scripts/portal-env.sh | 97 +++++++++ peopleportal/dev-stack/scripts/reset.sh | 21 ++ peopleportal/dev-stack/scripts/up.sh | 32 +++ peopleportal/dev-stack/scripts/verify.sh | 73 +++++++ peopleportal/webui/vite.config.ts | 29 +-- 22 files changed, 1400 insertions(+), 51 deletions(-) create mode 100644 peopleportal/dev-stack/.env.example create mode 100644 peopleportal/dev-stack/.gitignore create mode 100644 peopleportal/dev-stack/README.md create mode 100644 peopleportal/dev-stack/blueprints/people-portal.yaml create mode 100644 peopleportal/dev-stack/docker-compose.yml create mode 100644 peopleportal/dev-stack/scripts/authentik-bootstrap.py create mode 100755 peopleportal/dev-stack/scripts/bootstrap-authentik.sh create mode 100755 peopleportal/dev-stack/scripts/bootstrap-gitea.sh create mode 100644 peopleportal/dev-stack/scripts/check-ports.py create mode 100755 peopleportal/dev-stack/scripts/common.sh create mode 100755 peopleportal/dev-stack/scripts/gen-env.sh create mode 100644 peopleportal/dev-stack/scripts/oidc-bridge.py create mode 100755 peopleportal/dev-stack/scripts/portal-env.sh create mode 100755 peopleportal/dev-stack/scripts/reset.sh create mode 100755 peopleportal/dev-stack/scripts/up.sh create mode 100755 peopleportal/dev-stack/scripts/verify.sh diff --git a/README.md b/README.md index 620e099..11716a6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,31 @@ Scaffolding and initial architecture for the new App Dev **Horizon** project. Th --- +## Quick start for contributors + +For People Portal, install Docker with Compose v2.20+, Bash, curl, OpenSSL and +Python 3.9+, plus Node.js 22.12+ and npm for the application. Windows contributors +need WSL2 with Docker integration. From a fresh clone: + +```bash +cd peopleportal/dev-stack +./scripts/up.sh --write-env +cd ../.. +./nx serve pplserver +# In a second terminal, from the repo root: +./nx serve pplui +``` + +Open and log in as `akadmin` using +`AUTHENTIK_BOOTSTRAP_PASSWORD` from the generated `peopleportal/dev-stack/.env`. +Mail lands in [Mailpit](http://localhost:8025). The stack also provides Gitea, +MongoDB and Redis. S3 object storage is not included; resume/avatar uploads +will fail until a local S3 option is added. Garage, SeaweedFS and versitygw are +candidates for a follow-up; no replacement has been selected. S3 env variables +remain placeholders. +AWS provisioning is also unsupported locally; see the [dev-stack README](./peopleportal/dev-stack/README.md) and +[local development guide](./corpwiki/docs/people-portal-dev-guide/deployment/local-development.md). + ## Workflows & CI/CD We rely on GitHub Actions and Nx's powerful task running capabilities to automate our development lifecycle. The available workflows include: diff --git a/corpwiki/docs/contribution-guidelines/git/branching.md b/corpwiki/docs/contribution-guidelines/git/branching.md index 21559ea..d17f37d 100644 --- a/corpwiki/docs/contribution-guidelines/git/branching.md +++ b/corpwiki/docs/contribution-guidelines/git/branching.md @@ -5,7 +5,7 @@ sidebar_position: 1 # Branching Strategy -The default branch will be main which should always be stable and deployable. Changes to main can +The default branch is `master`, which should always be stable and deployable. Changes to `master` can only occur through PRs. ## Branch Naming diff --git a/corpwiki/docs/contribution-guidelines/git/pr.md b/corpwiki/docs/contribution-guidelines/git/pr.md index bb72807..1af032b 100644 --- a/corpwiki/docs/contribution-guidelines/git/pr.md +++ b/corpwiki/docs/contribution-guidelines/git/pr.md @@ -12,7 +12,7 @@ Axios: added for making requests to backend - Any known issues - If feature has complicated implementation that has not been discussed, overview of functionality -Before making a PR, always merge main into your branch prior and test, it is the individual -developer’s responsibility to stay up to date with main and deal with merge conflicts with their +Before making a PR, always merge `master` into your branch prior and test, it is the individual +developer’s responsibility to stay up to date with `master` and deal with merge conflicts with their features. diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/environment-variables.md b/corpwiki/docs/people-portal-dev-guide/deployment/environment-variables.md index 22df2d3..4fa8fd5 100644 --- a/corpwiki/docs/people-portal-dev-guide/deployment/environment-variables.md +++ b/corpwiki/docs/people-portal-dev-guide/deployment/environment-variables.md @@ -6,10 +6,23 @@ sidebar_position: 3 Every variable the People Portal server reads, grouped by what it configures. -Loading is layered, lowest priority first, in `src/config/environment.ts`: -`.env`, then `.env.`, then `.env..local`. Anything already in -the real process environment always wins, so container and CI values are never -clobbered by a stray file. +`src/config/environment.ts` reads `.env`, then `.env.`, then +`.env..local`. The first value found wins because the loader uses +`override: false`. Anything already in the real process environment always wins. + +For local development, run `peopleportal/dev-stack/scripts/up.sh --write-env` +to create `peopleportal/server/.env.development` only if absent. It also prints +the exact block for a server `.env`. Existing env files are never overwritten; +an older `.env` can shadow these generated development values. See +[Local Development](./local-development.md). + +The dev stack uses `PORTAL_PORT=3000` and `WEBUI_PORT=5173` by default. +For alternate ports, its env renderer emits `PP_DEV_PORTAL_PORT` and/or +`PP_DEV_WEBUI_PORT` only for changed values. `--write-env` creates the UI +overrides in `peopleportal/webui/.env.development.local` if absent. These +variables configure Vite, not the server. Existing env files are preserved; +remove stale overrides when returning to defaults. See the local development +guide for every backing-service port variable and the override procedure. :::warning Required in production Outside development and test, the server refuses to start unless all of these are @@ -46,6 +59,11 @@ ephemeral one and warns, so sessions do not survive a restart. | `PEOPLEPORTAL_OIDC_CLIENTID` | Client id from the Authentik provider | | `PEOPLEPORTAL_OIDC_CLIENTSECRET` | Client secret | +The dev stack uses `http://localhost:9000` for Authentik and +`http://localhost:9000/application/o/people-portal/.well-known/openid-configuration` +for discovery. It generates the admin token and separate portal/Gitea clients; +no TLS verification override is needed for local HTTP. + The app requests `openid profile email people_portal offline_access`, and reads `pk`, `is_superuser` and `attributes` from the custom scope. See [OpenID Provider Configuration](./oidc-provider-config.md) for the provider side, @@ -59,6 +77,10 @@ including what logout needs. | `PEOPLEPORTAL_GITEA_TOKEN` | Admin token. Needs `write:admin` for system webhooks | | `PEOPLEPORTAL_GITEA_WEBHOOK_SECRET` | Shared secret. **Minimum 32 characters** | +The dev stack uses `http://localhost:3001` for Gitea. Its generated webhook +origin is `http://host.docker.internal:3000`, so container callbacks reach the +host server on Linux and macOS. + The webhook secret is not optional once Gitea is enabled. `GiteaHookSetup` builds the `Authorization: Bearer` header from it before creating either hook, and throws below 32 characters, so a short value means **zero** hooks rather than partial @@ -88,7 +110,7 @@ requested separately from `users:read` and is easy to miss. | Variable | Notes | |---|---| | `PEOPLEPORTAL_SMTP_HOST` | SMTP server | -| `PEOPLEPORTAL_SMTP_PORT` | `465` for SSL, `587` for STARTTLS | +| `PEOPLEPORTAL_SMTP_PORT` | `465` for SSL, `587` for STARTTLS; dev-stack Mailpit uses `1025` | | `PEOPLEPORTAL_SMTP_SECURE` | `true` or `false` | | `PEOPLEPORTAL_SMTP_USER` | SMTP username | | `PEOPLEPORTAL_SMTP_PASS` | SMTP password | @@ -99,6 +121,10 @@ requested separately from `users:read` and is easy to miss. Set `PEOPLEPORTAL_EMAIL_CONREROUTE` on any environment seeded with real member addresses, or onboarding mail reaches real people. +The dev stack sets SMTP to `127.0.0.1:1025`, `SECURE=false`, with development +credentials accepted by Mailpit. Messages stay in the local inbox at +`http://localhost:8025` without external delivery. + ## Photo check | Variable | Notes | @@ -145,6 +171,19 @@ accounts created under the old name. Policy documents live in `peopleportal/server/aws/`. +The local dev environment includes no S3 object storage service. Resume/avatar +uploads will fail until a local S3 option is added. Garage, SeaweedFS and +versitygw are candidates for a follow-up; no replacement has been selected. +The stack keeps S3 settings as +placeholders: S3_BUCKET_NAME=people-portal, S3_ENDPOINT=http://s3.example.invalid, +S3_FORCE_PATH_STYLE=true, and dummy AWS access/secret values. That placeholder +endpoint is deliberately nonfunctional. The current server ignores +endpoint/path-style settings +and AWS_ENABLED=false. Dummy organization IDs satisfy startup checks only. +AWS provisioning and uploads are unsupported locally. This stack changes no +server S3/AWS behavior. verify.sh prints a NOTE for this known gap and verifies +only the included backing services. + ## Node | Variable | Notes | diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md index c27054a..2b1dc59 100644 --- a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md +++ b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md @@ -4,34 +4,152 @@ sidebar_position: 1 # Local Development Guide -:::info Default Ports -The Docusaurus documentation server (`npm start` in `corpwiki/`) is pinned to port `3001` to avoid colliding with the People Portal backend on port `3000`. If you need to change either port, update the `start` script in `corpwiki/package.json` and the `PORT` value in your server `.env`. +This stack provides Authentik, Gitea, MongoDB 7, Redis, Postgres and Mailpit, +including an OIDC bridge for Gitea. The portal server and UI run on the host +through the repository Nx wrapper. S3 object storage is a known gap. + +## Quick start + +Install Docker with Compose v2.20+, Bash, curl, OpenSSL, Python 3.9+, and +Node.js 22.12+ with npm. Python is used for the host port preflight; the +Authentik bootstrap and OIDC bridge use Python inside the pinned image. +Use macOS or Linux; Windows contributors need WSL2 with Docker integration +and must run these commands inside WSL2. + +From a fresh clone: + +```bash +cd peopleportal/dev-stack +./scripts/up.sh --write-env +cd ../.. +./nx serve pplserver +# In a separate terminal from the repository root: +./nx serve pplui +``` + +Setup is noninteractive. It generates local credentials once, checks ports +before starting containers, starts healthy services, bootstraps Authentik and +Gitea, and runs `scripts/verify.sh`. Both up and verify print the selected ports. +The first image pull, bootstrap and Nx dependency install can take several minutes. + +Open . Sign in as `akadmin` with the generated +`AUTHENTIK_BOOTSTRAP_PASSWORD` in this directory's `.env`. Skip initial Authentik +setup. The backend callback is . +Use `localhost` consistently. Return to the Vite URL for hot reload. + +`--write-env` creates `peopleportal/server/.env.development` only when absent +and prints the equivalent server env block. Keep that output private. Existing +server env files may shadow new settings: process environment wins, then `.env`, +`.env.development` and `.env.development.local` in first-value order. + +## Ports and overrides + + +:::note Port clash with the wiki dev server +The Docusaurus wiki dev server (`npm start` in `corpwiki/`) is pinned to port `3001`, the same as the dev stack's default `GITEA_PORT`. If you run both, set `GITEA_PORT=3002` for the dev stack. ::: -This guide covers setting up dependencies for running the People Portal locally. +| Service | Default port | Variable in dev-stack/.env | +| --- | ---: | --- | +| Portal backend | 3000 | `PORTAL_PORT` | +| Vite UI | 5173 | `WEBUI_PORT` | +| MongoDB | 27017 | `MONGO_PORT` | +| Redis | 6379 | `REDIS_PORT` | +| Authentik | 9000 | `AUTHENTIK_PORT` | +| Gitea | 3001 | `GITEA_PORT` | +| Gitea SSH | 2222 | `GITEA_SSH_PORT` | +| Mailpit inbox | 8025 | `MAILPIT_PORT` | +| SMTP | 1025 | `SMTP_PORT` | + +Postgres is internal only. All published Docker ports bind to `127.0.0.1`; +`BIND_ADDRESS` must remain `127.0.0.1`. Vite keeps its normal localhost binding. +Use distinct free ports. `up.sh` fails before starting containers when a port is +unavailable, naming the exact variable to change. Ports already published by +running containers in this Compose project are accepted on a repeat run. +Stop the host portal and UI before rerunning setup. + +Before the first setup, export overrides, for example: -## Cloning the Repositories +```bash +export PORTAL_PORT=3100 WEBUI_PORT=5174 GITEA_PORT=3101 REDIS_PORT=6380 +./scripts/up.sh --write-env +``` -Everything lives in one repository. The separate `PeoplePortalServer`, -`PeoplePortalUI` and `AppDev-CorpWiki` repos were folded into the **TechOps** -monorepo and are archived history. +For later changes, stop this Compose project and the host apps, edit this +stack's `.env`, and clear `PORTAL_BASE_URL`, `AUTHENTIK_URL` or `GITEA_URL` when +changing their corresponding port. `gen-env.sh` derives empty URLs from +`PUBLIC_HOST` and the selected ports, while preserving nonempty settings. +Then rerun `up.sh --write-env` and update existing server/UI env files from the +printed block, since existing files are preserved. Do not stop other Docker +projects to resolve conflicts. + +For alternate application ports, `portal-env.sh` prints `PP_DEV_PORTAL_PORT` +only when `PORTAL_PORT` differs from 3000, and `PP_DEV_WEBUI_PORT` only when +`WEBUI_PORT` differs from 5173. `--write-env` also creates +`peopleportal/webui/.env.development.local` with just those overrides if absent. +Without `--write-env`, copy the printed `PP_DEV_*` lines to that UI file yourself. +When returning to default ports, remove stale `PP_DEV_*` entries from existing +UI env files and your shell. Restart both host apps after changes. + +Without `PP_DEV_*` variables, Vite behaves as before: `/api` proxies to +`http://localhost:3000`, its default port is 5173, and no host or `strictPort` +override is applied. An explicit `PP_DEV_WEBUI_PORT` enables `strictPort` so the +UI does not silently move away from its configured port. `PP_DEV_PORTAL_PORT` +changes only the API proxy target. + +## Mail, Git and credentials + +All portal, Authentik and Gitea mail lands in [Mailpit](http://localhost:8025), +with no external delivery. [Gitea](http://localhost:3001) has a generated +`devadmin` account with `GITEA_ADMIN_PASSWORD` in `.env` and a separate Authentik +OIDC client. The IPv4/IPv6 loopback bridge permits public discovery inside Gitea. +Host callbacks use `host.docker.internal` and the configured portal port. + +`.env`, `.oidc`, `.gitea-token` and server/UI env files contain local settings +or generated secrets and are gitignored. Do not publish, share publicly or +reuse the credentials for real work. + +## Known gaps + +**S3 object storage is not included; resume/avatar uploads will fail.** +Garage, SeaweedFS and versitygw are candidates for a follow-up; no replacement +has been selected. No S3 image is pulled, started or checked. `S3_ENDPOINT`, +`S3_FORCE_PATH_STYLE`, `S3_BUCKET_NAME` and dummy credentials remain placeholders; +`http://s3.example.invalid` is deliberately nonfunctional. The server currently +ignores endpoint/path-style overrides and `AWS_ENABLED=false`, so adding a +storage service alone will not wire uploads. Server S3/AWS behavior is unchanged. + +AWS provisioning, Organizations/Budgets, Slack actions and other external +integrations are unsupported locally. Dummy organization IDs and Slack values +satisfy startup checks only. Do not enable AWS provisioning on local teams. +Discord and photo checks remain disabled. Browser login/logout needs a separate +end-to-end test; the included smoke checks verify service APIs and OIDC discovery. + +## Verify, stop and reset + +From `peopleportal/dev-stack`: ```bash -git clone https://github.com/candiedoperation/TechOps.git -cd TechOps +./scripts/verify.sh +# Stops this project and preserves its data: +docker compose stop +./scripts/reset.sh +# Explicit noninteractive deletion of only the selected project's service data: +./scripts/reset.sh --yes ``` -The pieces you will touch: +`verify.sh` checks all eight included containers and backing-service APIs with +16 checks, and exits 0 when all pass. It always prints a visible NOTE about S3. +Reset preserves stack and server/UI env files, deletes service data and exported +OIDC/token files, and changes the Gitea token on the next bootstrap. Update the +server env from the new output. Keep the same `COMPOSE_PROJECT_NAME` between +starting, stopping and resetting; the default project name is `ppdev`. -| Path | Nx project | What it is | -|---|---|---| -| `peopleportal/server` | `pplserver` | Express API, also serves the built UI | -| `peopleportal/webui` | `pplui` | Vite + React frontend | -| `corpwiki` | | This wiki | +## Manual setup (without the dev stack) -Tasks run through Nx from the repo root, never from inside a project directory. +The steps below are the original manual instructions. Prefer the dev stack above; use this section only if you cannot run Docker or need to understand each piece. -## MongoDB Atlas +### MongoDB Atlas For the database, we use MongoDB Atlas. To set it up for local development: 1. Create an account on [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). @@ -40,11 +158,11 @@ For the database, we use MongoDB Atlas. To set it up for local development: 4. In the Network Access section, add your IP address (or `0.0.0.0/0` for broad access during development). 5. Copy the connection string and set it as `PEOPLEPORTAL_MONGO_URL` in your server `.env` file. -## Authentik Setup +### Authentik Setup People Portal relies heavily on Authentik for OIDC authentication. You can host an Authentik instance locally using Docker Compose. -### Docker Compose +#### Docker Compose Create a `docker-compose.yml` file for Authentik in a local directory configuration of your choosing: @@ -116,7 +234,7 @@ volumes: driver: local ``` -### Environment Variables (.env) +#### Environment Variables (.env) Next to your `docker-compose.yml`, create a `.env` file that includes the necessary variables. Ensure you replace the placeholder values securely: @@ -132,7 +250,7 @@ AUTHENTIK_ERROR_REPORTING=true Run `docker compose up -d` to start Authentik. Then, navigate to `http://localhost:9000/if/flow/initial-setup/` to complete the initial setup and retrieve your initial user details (the default admin user is `akadmin`). -### Authentik Application and Provider Configuration +#### Authentik Application and Provider Configuration Once Authentik is running, configure the Application and Provider to allow People Portal to authenticate users. @@ -147,7 +265,7 @@ Once Authentik is running, configure the Application and Provider to allow Peopl - **Redirect URIs**: `http://localhost:3000/api/auth/redirect` (Replace `3000` with your backend server's port if running on a different port locally). 5. Take note of the **Client ID** and **Client Secret**. These correspond to the `PEOPLEPORTAL_OIDC_CLIENTID` and `PEOPLEPORTAL_OIDC_CLIENTSECRET` environment variables on your People Portal server. -### Authentik Admin API Token +#### Authentik Admin API Token The People Portal server makes administrative calls to Authentik (creating groups, listing users, managing memberships) using a long-lived API token. @@ -157,7 +275,7 @@ The People Portal server makes administrative calls to Authentik (creating group 4. Uncheck **Expiring** so the token never expires. 5. Set the token as `PEOPLEPORTAL_AUTHENTIK_TOKEN` in your server `.env`. -### Defining the `people_portal` Scope +#### Defining the `people_portal` Scope For People Portal to work, ensure the `openid`, `profile`, `email`, and a custom `people_portal` scope are configured in your OpenID Provider. @@ -180,7 +298,7 @@ To create the custom `people_portal` scope in Authentik: *(For further details, refer to the [OpenID Provider Configuration](./oidc-provider-config.md) and [Environment Variables](./environment-variables.md) docs).* -## Gitea Setup +### Gitea Setup Gitea is utilized for localized source version control integrated within People Portal. You can easily get it running via another Docker Compose file: @@ -239,7 +357,7 @@ Webhooks are deliberately absent from both scripts: the server reconciles those itself on every start, via `GiteaClient/hooksetup.ts`. ::: -## Redis +### Redis The server uses Redis as a cache. It is optional, and without it startup logs `PEOPLEPORTAL_REDIS_URL is unset; running without a cache` and carries on, but @@ -251,15 +369,15 @@ docker run -d --name people-portal-redis -p 6379:6379 --restart unless-stopped r Then set `PEOPLEPORTAL_REDIS_URL=redis://localhost:6379`. -## Running the Applications Locally +### Running the Applications Locally Server and UI are two Nx projects in one repository, run from the repo root. -### 1. People Portal Server (Backend) +#### 1. People Portal Server (Backend) The backend handles all business logic, database connections, and authentication workflows. -#### Environment Variables +##### Environment Variables Configuration lives in `peopleportal/server/.env.development`, which is gitignored. The loader layers `.env`, then `.env.`, then @@ -318,7 +436,7 @@ AWS_BILLING_ALERT_EMAIL=you@example.com S3_BUCKET_NAME=your-s3-bucket-name ``` -#### Starting the Server +##### Starting the Server With your `.env` configured inside the backend repo, install the dependencies and start the local development server: @@ -329,11 +447,11 @@ With your `.env` configured inside the backend repo, install the dependencies an The backend server should now be live at `http://localhost:3000`. -### 2. People Portal UI (Frontend) +#### 2. People Portal UI (Frontend) The frontend is built with Vite and React. In local development, the UI server will automatically proxy `/api` requests to your running backend (`http://localhost:3000`). -#### Starting the UI +##### Starting the UI No separate `.env` file is required for local UI development. diff --git a/peopleportal/dev-stack/.env.example b/peopleportal/dev-stack/.env.example new file mode 100644 index 0000000..cadbe13 --- /dev/null +++ b/peopleportal/dev-stack/.env.example @@ -0,0 +1,42 @@ +# scripts/gen-env.sh creates .env automatically and fills empty secrets once. +# Initial overrides can be exported before scripts/up.sh; later edit .env. +# Use simple KEY=value or KEY='value' lines, with no inline comments/expansion. +COMPOSE_PROJECT_NAME=ppdev +PUBLIC_HOST=localhost +BIND_ADDRESS=127.0.0.1 +AUTHENTIK_PORT=9000 +GITEA_PORT=3001 +GITEA_SSH_PORT=2222 +MONGO_PORT=27017 +REDIS_PORT=6379 +MAILPIT_PORT=8025 +SMTP_PORT=1025 +PORTAL_PORT=3000 +WEBUI_PORT=5173 +# Derived from PUBLIC_HOST and ports when empty. No trailing slash. +PORTAL_BASE_URL= +AUTHENTIK_URL= +GITEA_URL= +S3_ENDPOINT=http://s3.example.invalid +S3_CORS_ORIGINS= + +# Local credentials only. Never reuse staging or production credentials. +POSTGRES_PASSWORD= +AUTHENTIK_SECRET_KEY= +AUTHENTIK_BOOTSTRAP_PASSWORD= +AUTHENTIK_BOOTSTRAP_TOKEN= +AUTHENTIK_BOOTSTRAP_EMAIL=akadmin@example.invalid +PORTAL_OIDC_CLIENT_ID=people-portal-dev +PORTAL_OIDC_CLIENT_SECRET= +PORTAL_TOKEN_SECRET= +GITEA_OIDC_CLIENT_ID=gitea-dev +GITEA_OIDC_CLIENT_SECRET= +GITEA_ADMIN_USER=devadmin +GITEA_ADMIN_PASSWORD= +GITEA_ADMIN_EMAIL=devadmin@example.invalid +GITEA_WEBHOOK_SECRET= +S3_ACCESS_KEY=local-placeholder +S3_SECRET_KEY=local-placeholder +# Reserved placeholders. No S3 service is included. +S3_BUCKET_NAME=people-portal +AWS_REGION=us-east-1 diff --git a/peopleportal/dev-stack/.gitignore b/peopleportal/dev-stack/.gitignore new file mode 100644 index 0000000..09cd863 --- /dev/null +++ b/peopleportal/dev-stack/.gitignore @@ -0,0 +1,9 @@ +.env +.env.* +!.env.example +.oidc* +.gitea-token* +.bootstrap.lock/ +secrets/ +*.key +*.pem diff --git a/peopleportal/dev-stack/README.md b/peopleportal/dev-stack/README.md new file mode 100644 index 0000000..62c2da3 --- /dev/null +++ b/peopleportal/dev-stack/README.md @@ -0,0 +1,137 @@ +# People Portal local development stack + +This stack provides Authentik, Gitea, MongoDB 7, Redis, Postgres and Mailpit, +including an OIDC bridge for Gitea. The portal server and UI run on the host +through the repository Nx wrapper. S3 object storage is a known gap. + +## Quick start + +Install Docker with Compose v2.20+, Bash, curl, OpenSSL, Python 3.9+, and +Node.js 22.12+ with npm. Python is used for the host port preflight; the +Authentik bootstrap and OIDC bridge use Python inside the pinned image. +Use macOS or Linux; Windows contributors need WSL2 with Docker integration +and must run these commands inside WSL2. + +From a fresh clone: + +```bash +cd peopleportal/dev-stack +./scripts/up.sh --write-env +cd ../.. +./nx serve pplserver +# In a separate terminal from the repository root: +./nx serve pplui +``` + +Setup is noninteractive. It generates local credentials once, checks ports +before starting containers, starts healthy services, bootstraps Authentik and +Gitea, and runs `scripts/verify.sh`. Both up and verify print the selected ports. +The first image pull, bootstrap and Nx dependency install can take several minutes. + +Open . Sign in as `akadmin` with the generated +`AUTHENTIK_BOOTSTRAP_PASSWORD` in this directory's `.env`. Skip initial Authentik +setup. The backend callback is . +Use `localhost` consistently. Return to the Vite URL for hot reload. + +`--write-env` creates `peopleportal/server/.env.development` only when absent +and prints the equivalent server env block. Keep that output private. Existing +server env files may shadow new settings: process environment wins, then `.env`, +`.env.development` and `.env.development.local` in first-value order. + +## Ports and overrides + +| Service | Default port | Variable in dev-stack/.env | +| --- | ---: | --- | +| Portal backend | 3000 | `PORTAL_PORT` | +| Vite UI | 5173 | `WEBUI_PORT` | +| MongoDB | 27017 | `MONGO_PORT` | +| Redis | 6379 | `REDIS_PORT` | +| Authentik | 9000 | `AUTHENTIK_PORT` | +| Gitea | 3001 | `GITEA_PORT` | +| Gitea SSH | 2222 | `GITEA_SSH_PORT` | +| Mailpit inbox | 8025 | `MAILPIT_PORT` | +| SMTP | 1025 | `SMTP_PORT` | + +Postgres is internal only. All published Docker ports bind to `127.0.0.1`; +`BIND_ADDRESS` must remain `127.0.0.1`. Vite keeps its normal localhost binding. +Use distinct free ports. `up.sh` fails before starting containers when a port is +unavailable, naming the exact variable to change. Ports already published by +running containers in this Compose project are accepted on a repeat run. +Stop the host portal and UI before rerunning setup. + +Before the first setup, export overrides, for example: + +```bash +export PORTAL_PORT=3100 WEBUI_PORT=5174 GITEA_PORT=3101 REDIS_PORT=6380 +./scripts/up.sh --write-env +``` + +For later changes, stop this Compose project and the host apps, edit this +stack's `.env`, and clear `PORTAL_BASE_URL`, `AUTHENTIK_URL` or `GITEA_URL` when +changing their corresponding port. `gen-env.sh` derives empty URLs from +`PUBLIC_HOST` and the selected ports, while preserving nonempty settings. +Then rerun `up.sh --write-env` and update existing server/UI env files from the +printed block, since existing files are preserved. Do not stop other Docker +projects to resolve conflicts. + +For alternate application ports, `portal-env.sh` prints `PP_DEV_PORTAL_PORT` +only when `PORTAL_PORT` differs from 3000, and `PP_DEV_WEBUI_PORT` only when +`WEBUI_PORT` differs from 5173. `--write-env` also creates +`peopleportal/webui/.env.development.local` with just those overrides if absent. +Without `--write-env`, copy the printed `PP_DEV_*` lines to that UI file yourself. +When returning to default ports, remove stale `PP_DEV_*` entries from existing +UI env files and your shell. Restart both host apps after changes. + +Without `PP_DEV_*` variables, Vite behaves as before: `/api` proxies to +`http://localhost:3000`, its default port is 5173, and no host or `strictPort` +override is applied. An explicit `PP_DEV_WEBUI_PORT` enables `strictPort` so the +UI does not silently move away from its configured port. `PP_DEV_PORTAL_PORT` +changes only the API proxy target. + +## Mail, Git and credentials + +All portal, Authentik and Gitea mail lands in [Mailpit](http://localhost:8025), +with no external delivery. [Gitea](http://localhost:3001) has a generated +`devadmin` account with `GITEA_ADMIN_PASSWORD` in `.env` and a separate Authentik +OIDC client. The IPv4/IPv6 loopback bridge permits public discovery inside Gitea. +Host callbacks use `host.docker.internal` and the configured portal port. + +`.env`, `.oidc`, `.gitea-token` and server/UI env files contain local settings +or generated secrets and are gitignored. Do not publish, share publicly or +reuse the credentials for real work. + +## Known gaps + +**S3 object storage is not included; resume/avatar uploads will fail.** +Garage, SeaweedFS and versitygw are candidates for a follow-up; no replacement +has been selected. No S3 image is pulled, started or checked. `S3_ENDPOINT`, +`S3_FORCE_PATH_STYLE`, `S3_BUCKET_NAME` and dummy credentials remain placeholders; +`http://s3.example.invalid` is deliberately nonfunctional. The server currently +ignores endpoint/path-style overrides and `AWS_ENABLED=false`, so adding a +storage service alone will not wire uploads. Server S3/AWS behavior is unchanged. + +AWS provisioning, Organizations/Budgets, Slack actions and other external +integrations are unsupported locally. Dummy organization IDs and Slack values +satisfy startup checks only. Do not enable AWS provisioning on local teams. +Discord and photo checks remain disabled. Browser login/logout needs a separate +end-to-end test; the included smoke checks verify service APIs and OIDC discovery. + +## Verify, stop and reset + +From `peopleportal/dev-stack`: + +```bash +./scripts/verify.sh +# Stops this project and preserves its data: +docker compose stop +./scripts/reset.sh +# Explicit noninteractive deletion of only the selected project's service data: +./scripts/reset.sh --yes +``` + +`verify.sh` checks all eight included containers and backing-service APIs with +16 checks, and exits 0 when all pass. It always prints a visible NOTE about S3. +Reset preserves stack and server/UI env files, deletes service data and exported +OIDC/token files, and changes the Gitea token on the next bootstrap. Update the +server env from the new output. Keep the same `COMPOSE_PROJECT_NAME` between +starting, stopping and resetting; the default project name is `ppdev`. diff --git a/peopleportal/dev-stack/blueprints/people-portal.yaml b/peopleportal/dev-stack/blueprints/people-portal.yaml new file mode 100644 index 0000000..9e94120 --- /dev/null +++ b/peopleportal/dev-stack/blueprints/people-portal.yaml @@ -0,0 +1,117 @@ +# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json +# Loaded by the worker. Secrets come from its environment, never this file. +version: 1 +metadata: + name: AppDev People Portal and Gitea + labels: + blueprints.goauthentik.io/instantiate: "true" +# Keep the required signing_key lookups below: never fall back to HS256. +# The certificate is created by authentik crypto reconciliation, not a blueprint. +# bootstrap-authentik.sh waits for it, then reapplies this blueprint if needed. +entries: + - model: authentik_blueprints.metaapplyblueprint + attrs: + identifiers: + path: default/flow-default-provider-authorization-implicit-consent.yaml + required: true + + - model: authentik_blueprints.metaapplyblueprint + attrs: + identifiers: + path: default/flow-default-provider-invalidation.yaml + required: true + + - model: authentik_blueprints.metaapplyblueprint + attrs: + identifiers: + path: system/providers-oauth2.yaml + required: true + + - model: authentik_blueprints.metaapplyblueprint + attrs: + identifiers: + path: default/flow-default-invalidation-flow.yaml + required: true + + - model: authentik_providers_oauth2.scopemapping + id: people-portal-scope + identifiers: + name: AppDev People Portal claims + attrs: + scope_name: people_portal + description: People Portal identity and authorization claims + expression: | + return { + "pk": request.user.pk, + "is_superuser": request.user.is_superuser, + "attributes": request.user.attributes, + } + + - model: authentik_providers_oauth2.oauth2provider + id: people-portal-provider + identifiers: + name: AppDev People Portal + attrs: + client_type: confidential + client_id: !Env PORTAL_OIDC_CLIENT_ID + client_secret: !Env PORTAL_OIDC_CLIENT_SECRET + grant_types: [authorization_code, refresh_token] + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + sub_mode: user_username + include_claims_in_id_token: true + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] + - !KeyOf people-portal-scope + # Strict URLs match the host server's base URL exactly. + redirect_uris: + - matching_mode: strict + redirect_uri_type: authorization + url: !Format ["%s/api/auth/redirect", !Env PORTAL_BASE_URL] + - matching_mode: strict + redirect_uri_type: logout + url: !Format ["%s/", !Env PORTAL_BASE_URL] + + - model: authentik_core.application + identifiers: + slug: people-portal + attrs: + name: People Portal (dev) + provider: !KeyOf people-portal-provider + meta_launch_url: !Env PORTAL_BASE_URL + + # Gitea gets its own client, so portal redirects do not authorize Gitea logins. + - model: authentik_providers_oauth2.oauth2provider + id: gitea-provider + identifiers: + name: AppDev Gitea + attrs: + client_type: confidential + client_id: !Env GITEA_OIDC_CLIENT_ID + client_secret: !Env GITEA_OIDC_CLIENT_SECRET + grant_types: [authorization_code, refresh_token] + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + sub_mode: user_username + include_claims_in_id_token: true + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] + - !KeyOf people-portal-scope + redirect_uris: + - matching_mode: strict + url: !Format ["%s/user/oauth2/authentik/callback", !Env GITEA_URL] + + - model: authentik_core.application + identifiers: + slug: gitea + attrs: + name: Gitea (dev) + provider: !KeyOf gitea-provider + meta_launch_url: !Env GITEA_URL diff --git a/peopleportal/dev-stack/docker-compose.yml b/peopleportal/dev-stack/docker-compose.yml new file mode 100644 index 0000000..dcaf276 --- /dev/null +++ b/peopleportal/dev-stack/docker-compose.yml @@ -0,0 +1,195 @@ +# Local backing services. Application server and UI run through Nx on the host. +# scripts/up.sh bootstraps this project before printing the portal environment. +name: ${COMPOSE_PROJECT_NAME:-peopleportal-dev} + +x-authentik-environment: &authentik-environment + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?run scripts/gen-env.sh} + AUTHENTIK_POSTGRESQL__HOST: postgres + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD:?run scripts/gen-env.sh} + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + AUTHENTIK_EMAIL__HOST: mailpit + AUTHENTIK_EMAIL__PORT: "1025" + AUTHENTIK_EMAIL__USE_TLS: "false" + AUTHENTIK_EMAIL__USE_SSL: "false" + AUTHENTIK_EMAIL__FROM: authentik@example.invalid + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: authentik + POSTGRES_USER: authentik + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?run scripts/gen-env.sh} + volumes: + - postgres:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 5s + timeout: 5s + retries: 60 + + authentik-server: + image: ghcr.io/goauthentik/server:2026.5.2 + restart: unless-stopped + command: server + environment: *authentik-environment + depends_on: + postgres: + condition: service_healthy + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${AUTHENTIK_PORT:-9000}:9000" + volumes: + - authentik-media:/media + healthcheck: + test: [CMD, ak, healthcheck] + interval: 10s + timeout: 10s + retries: 60 + start_period: 60s + + authentik-worker: + image: ghcr.io/goauthentik/server:2026.5.2 + restart: unless-stopped + command: worker + environment: + <<: *authentik-environment + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:?run scripts/gen-env.sh} + AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:?run scripts/gen-env.sh} + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} + PORTAL_BASE_URL: ${PORTAL_BASE_URL:?run scripts/gen-env.sh} + GITEA_URL: ${GITEA_URL:?run scripts/gen-env.sh} + PORTAL_OIDC_CLIENT_ID: ${PORTAL_OIDC_CLIENT_ID} + PORTAL_OIDC_CLIENT_SECRET: ${PORTAL_OIDC_CLIENT_SECRET:?run scripts/gen-env.sh} + GITEA_OIDC_CLIENT_ID: ${GITEA_OIDC_CLIENT_ID} + GITEA_OIDC_CLIENT_SECRET: ${GITEA_OIDC_CLIENT_SECRET:?run scripts/gen-env.sh} + depends_on: + postgres: + condition: service_healthy + volumes: + - authentik-media:/media + - ./blueprints:/blueprints/custom:ro + healthcheck: + test: [CMD, ak, healthcheck] + interval: 10s + timeout: 10s + retries: 60 + start_period: 60s + + gitea: + image: docker.gitea.com/gitea:1.24.3 + restart: unless-stopped + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__database__DB_TYPE: sqlite3 + GITEA__database__PATH: /data/gitea/gitea.db + GITEA__security__INSTALL_LOCK: "true" + GITEA__server__DOMAIN: ${PUBLIC_HOST:-localhost} + GITEA__server__ROOT_URL: ${GITEA_URL:?run scripts/gen-env.sh}/ + GITEA__server__SSH_DOMAIN: ${PUBLIC_HOST:-localhost} + GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222} + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true" + GITEA__webhook__ALLOWED_HOST_LIST: host.docker.internal,${PUBLIC_HOST:-localhost} + GITEA__mailer__ENABLED: "true" + GITEA__mailer__PROTOCOL: smtp + GITEA__mailer__SMTP_ADDR: mailpit + GITEA__mailer__SMTP_PORT: "1025" + GITEA__mailer__FROM: gitea@example.invalid + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${GITEA_PORT:-3001}:3000" + - "${BIND_ADDRESS:-127.0.0.1}:${GITEA_SSH_PORT:-2222}:22" + extra_hosts: + # OIDC loopback bridge below resolves the public host within Gitea. + - "${PUBLIC_HOST:-localhost}:127.0.0.1" + - "host.docker.internal:host-gateway" + volumes: + - gitea:/data + healthcheck: + test: [CMD, wget, -q, --spider, http://127.0.0.1:3000/api/healthz] + interval: 5s + timeout: 5s + retries: 60 + + mongo: + image: mongo:7 + restart: unless-stopped + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${MONGO_PORT:-27017}:27017" + volumes: + - mongo:/data/db + - mongo-config:/data/configdb + healthcheck: + test: [CMD, mongosh, --quiet, --eval, "quit(db.adminCommand('ping').ok ? 0 : 1)"] + interval: 5s + timeout: 10s + retries: 30 + + redis: + image: redis:alpine + restart: unless-stopped + command: [redis-server, --appendonly, "yes"] + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${REDIS_PORT:-6379}:6379" + volumes: + - redis:/data + healthcheck: + test: [CMD, redis-cli, ping] + interval: 5s + timeout: 5s + retries: 30 + + mailpit: + image: axllent/mailpit:v1.27.8 + restart: unless-stopped + environment: + MP_DATABASE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: "true" + MP_SMTP_AUTH_ALLOW_INSECURE: "true" + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${MAILPIT_PORT:-8025}:8025" + - "${BIND_ADDRESS:-127.0.0.1}:${SMTP_PORT:-1025}:1025" + volumes: + - mailpit:/data + healthcheck: + test: [CMD, /mailpit, readyz] + interval: 5s + timeout: 5s + retries: 30 + + # Gitea must reach the same OIDC URLs as the browser. In its network namespace, + # localhost otherwise means Gitea itself. This tiny proxy forwards OIDC only, + # preserving Host so authentik advertises browser-reachable public endpoints. + # Reuses the pinned image's Python; no host runtime or extra image is needed. + gitea-oidc: + image: ghcr.io/goauthentik/server:2026.5.2 + restart: unless-stopped + network_mode: service:gitea + entrypoint: [python, /scripts/oidc-bridge.py] + environment: + AUTHENTIK_PORT: ${AUTHENTIK_PORT:-9000} + volumes: + - ./scripts/oidc-bridge.py:/scripts/oidc-bridge.py:ro + depends_on: + gitea: + condition: service_healthy + authentik-server: + condition: service_healthy + healthcheck: + test: [CMD, python, -c, "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ['AUTHENTIK_PORT'] + '/health', timeout=4)"] + interval: 5s + timeout: 5s + retries: 30 + +volumes: + postgres: + authentik-media: + gitea: + mongo: + mongo-config: + redis: + mailpit: diff --git a/peopleportal/dev-stack/scripts/authentik-bootstrap.py b/peopleportal/dev-stack/scripts/authentik-bootstrap.py new file mode 100644 index 0000000..ed5740a --- /dev/null +++ b/peopleportal/dev-stack/scripts/authentik-bootstrap.py @@ -0,0 +1,79 @@ +# Runs only inside the pinned authentik image, which supplies Python. +import json +import os +import sys +import time +import urllib.error +import urllib.request +from urllib.parse import urlencode + +base = 'http://authentik-server:9000' +token = os.environ['AUTHENTIK_BOOTSTRAP_TOKEN'] + +def get(path, method='GET'): + req = urllib.request.Request(base + path, method=method, + headers={'Authorization': f'Bearer {token}'}) + with urllib.request.urlopen(req, timeout=10) as response: + return json.load(response) + +def provider(slug, client_id): + app = get(f'/api/v3/core/applications/{slug}/') + obj = get(f"/api/v3/providers/oauth2/{app['provider']}/") + if not obj.get('signing_key'): + raise ValueError('Asymmetric signing certificate not ready') + if obj['client_id'] != client_id or not obj.get('client_secret'): + raise ValueError('Blueprint client credentials not ready') + expected_secret = os.environ['PORTAL_OIDC_CLIENT_SECRET' if slug == 'people-portal' else 'GITEA_OIDC_CLIENT_SECRET'] + if obj['client_secret'] != expected_secret: + raise ValueError('Blueprint client secret not reconciled yet') + if not {'authorization_code', 'refresh_token'} <= set(obj['grant_types']): + raise ValueError('Blueprint grant_types not ready') + return obj + +# Crypto reconciliation creates this keypair independently of blueprint defaults. +# Wait for it before requesting a retry of a blueprint that ran too early. +reapplied = False +for attempt in range(180): + try: + certs = get('/api/v3/crypto/certificatekeypairs/?' + urlencode( + {'name': 'authentik Self-signed Certificate'}))['results'] + if not any(cert['name'] == 'authentik Self-signed Certificate' + and cert.get('private_key_available') for cert in certs): + raise ValueError('Default signing certificate not ready') + if not reapplied: + for path in ( + 'default/flow-default-provider-authorization-implicit-consent.yaml', + 'default/flow-default-provider-invalidation.yaml', + 'default/flow-default-invalidation-flow.yaml', + 'system/providers-oauth2.yaml', + ): + if not get('/api/v3/managed/blueprints/?' + urlencode({'path': path}))['results']: + raise ValueError('Default blueprint instance not discovered yet') + instances = get('/api/v3/managed/blueprints/?' + urlencode( + {'path': 'custom/people-portal.yaml'}))['results'] + if not instances: + raise ValueError('Custom blueprint instance not discovered yet') + get(f"/api/v3/managed/blueprints/{instances[0]['pk']}/apply/", method='POST') + reapplied = True + portal = provider('people-portal', os.environ['PORTAL_OIDC_CLIENT_ID']) + gitea = provider('gitea', os.environ['GITEA_OIDC_CLIENT_ID']) + break + except (urllib.error.URLError, ValueError, KeyError, TypeError) as error: + if attempt == 179: + raise SystemExit(f'authentik bootstrap timed out: {error}; check worker blueprints and bootstrap token') + print(f'Waiting for authentik defaults/blueprint ({attempt + 1}/180): {error}', file=sys.stderr) + time.sleep(2) + +lines = [] + +def emit(name, value): + # Both Bash and Compose can safely source/read this generated file. + if "'" in value or '\n' in value or '\r' in value: + raise SystemExit('Unexpected character in OIDC credential') + lines.append(f"{name}='{value}'") + +emit('PORTAL_OIDC_CLIENT_ID', portal['client_id']) +emit('PORTAL_OIDC_CLIENT_SECRET', portal['client_secret']) +emit('GITEA_OIDC_CLIENT_ID', gitea['client_id']) +emit('GITEA_OIDC_CLIENT_SECRET', gitea['client_secret']) +print('\n'.join(lines)) diff --git a/peopleportal/dev-stack/scripts/bootstrap-authentik.sh b/peopleportal/dev-stack/scripts/bootstrap-authentik.sh new file mode 100755 index 0000000..84cb14b --- /dev/null +++ b/peopleportal/dev-stack/scripts/bootstrap-authentik.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Reapply the worker blueprint after default flows and signing keys are ready. +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +require_docker +load_env +wait_url "http://127.0.0.1:$AUTHENTIK_PORT/-/health/ready/" +# Python comes from the pinned authentik image, never from the host. +credentials=$(compose exec -T authentik-worker python - <"$STACK_DIR/scripts/authentik-bootstrap.py") +[[ $credentials == *PORTAL_OIDC_CLIENT_SECRET=* && $credentials == *GITEA_OIDC_CLIENT_SECRET=* ]] || die "Authentik returned incomplete credentials." +private_file "$STACK_DIR/.oidc" <<<"$credentials" +echo "Authentik applications verified; credentials saved in .oidc (mode 600)." >&2 diff --git a/peopleportal/dev-stack/scripts/bootstrap-gitea.sh b/peopleportal/dev-stack/scripts/bootstrap-gitea.sh new file mode 100755 index 0000000..7a58fe3 --- /dev/null +++ b/peopleportal/dev-stack/scripts/bootstrap-gitea.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Reconcile Gitea's admin, recoverable local API token and OIDC auth source. +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +require_tools openssl +require_docker +load_env +[[ -s .oidc ]] || die "Run scripts/bootstrap-authentik.sh first." +# The blueprint uses the stable credentials in .env; .oidc records API verification. +url="http://127.0.0.1:$GITEA_PORT" +wait_url "$url/api/healthz" +users=$(gitea_admin user list) +if ! awk -v user="$GITEA_ADMIN_USER" '$2 == user { found=1 } END { exit !found }' <<<"$users"; then + gitea_admin user create --username "$GITEA_ADMIN_USER" \ + --password "$GITEA_ADMIN_PASSWORD" --email "$GITEA_ADMIN_EMAIL" \ + --admin --must-change-password=false >&2 +fi + +token_valid=false +if [[ -s .gitea-token ]]; then + token=$(<.gitea-token) + if curl --fail --silent --max-time 10 -H "Authorization: token $token" "$url/api/v1/user" >/dev/null; then + token_valid=true + fi +fi +if [[ $token_valid == false ]]; then + # Run JSON parsing inside authentik's image. HTTP failures must abort rather + # than masquerade as "already exists". Only delete this stack's named token. + token_id=$(compose exec -T authentik-worker python - "$GITEA_ADMIN_USER" "$GITEA_ADMIN_PASSWORD" <<'PY' +import base64, json, sys, urllib.request +user, password = sys.argv[1:] +request = urllib.request.Request(f'http://gitea:3000/api/v1/users/{user}/tokens') +request.add_header('Authorization', 'Basic ' + base64.b64encode(f'{user}:{password}'.encode()).decode()) +with urllib.request.urlopen(request, timeout=10) as response: + tokens = json.load(response) +print(next((t['id'] for t in tokens if t['name'] == 'people-portal-dev'), '')) +PY +) + if [[ -n $token_id ]]; then + curl --fail --silent --show-error --max-time 10 --request DELETE \ + --user "$GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD" \ + "$url/api/v1/users/$GITEA_ADMIN_USER/tokens/$token_id" >/dev/null + fi + token=$(gitea_admin user generate-access-token --username "$GITEA_ADMIN_USER" \ + --token-name people-portal-dev --scopes all --raw) + token=$(awk '/^[0-9a-fA-F]+$/ && length($0) == 40 {print}' <<<"$token") + [[ $token =~ ^[0-9a-fA-F]{40}$ ]] || die "Gitea did not return a single raw token." + private_file "$STACK_DIR/.gitea-token" <<<"$token" +fi +chmod 600 .gitea-token + +sources=$(gitea_admin auth list) +source_id=$(awk '$2 == "authentik" {print $1}' <<<"$sources") +args=(--name authentik --provider openidConnect --key "$GITEA_OIDC_CLIENT_ID" + --secret "$GITEA_OIDC_CLIENT_SECRET" + --auto-discover-url "$AUTHENTIK_URL/application/o/gitea/.well-known/openid-configuration" + --scopes openid --scopes email --scopes profile --scopes people_portal) +# OpenSSL is present on both supported hosts; avoid GNU sha256sum. +wanted=$(printf '%s\n' "${args[@]}" | openssl dgst -sha256 | awk '{print $NF}') +save_hash=false +if [[ -z $source_id ]]; then + gitea_admin auth add-oauth "${args[@]}" >&2 + save_hash=true +else + current=$(compose exec -T --user git gitea sh -c \ + 'if [ -f /data/gitea/.portal-oidc-hash ]; then cat /data/gitea/.portal-oidc-hash; fi') + if [[ $current != "$wanted" ]]; then + gitea_admin auth update-oauth --id "$source_id" "${args[@]}" >&2 + save_hash=true + fi +fi +if [[ $save_hash == true ]]; then + printf '%s\n' "$wanted" | compose exec -T --user git gitea sh -c \ + 'umask 077; cat > /data/gitea/.portal-oidc-hash' +fi +echo "Gitea admin, token and authentik OIDC source ready (.gitea-token mode 600)." >&2 diff --git a/peopleportal/dev-stack/scripts/check-ports.py b/peopleportal/dev-stack/scripts/check-ports.py new file mode 100644 index 0000000..a3096d6 --- /dev/null +++ b/peopleportal/dev-stack/scripts/check-ports.py @@ -0,0 +1,54 @@ +"""Check host ports before startup, preserving this project's running services.""" + +import errno +import json +import os +import socket +import sys + + +def main(): + containers = json.load(sys.stdin) + owned_ports = { + int(binding["HostPort"]) + for container in containers + for bindings in (container.get("NetworkSettings", {}).get("Ports") or {}).values() + for binding in bindings or [] + if binding["HostIp"] == "127.0.0.1" + } + failures = [] + for key in ( + "AUTHENTIK_PORT", "GITEA_PORT", "GITEA_SSH_PORT", "MONGO_PORT", + "REDIS_PORT", "MAILPIT_PORT", "SMTP_PORT", "PORTAL_PORT", "WEBUI_PORT", + ): + port = int(os.environ[key]) + if port in owned_ports and key not in ("PORTAL_PORT", "WEBUI_PORT"): + continue + # localhost may resolve to either loopback family for the host apps. + for family, address in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")): + if family == socket.AF_INET6 and not socket.has_ipv6: + continue + try: + with socket.socket(family, socket.SOCK_STREAM) as probe: + if family == socket.AF_INET6: + probe.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + probe.bind((address, port)) + except OSError as error: + if family == socket.AF_INET6 and error.errno in ( + errno.EAFNOSUPPORT, errno.EADDRNOTAVAIL, errno.EPROTONOSUPPORT, + ): + continue + failures.append( + f"ERROR: {key}={port} is unavailable on {address}: {error}. " + f"Set {key} to a free port in dev-stack/.env " + f"(or export {key} before first setup), then rerun scripts/up.sh." + ) + break + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/peopleportal/dev-stack/scripts/common.sh b/peopleportal/dev-stack/scripts/common.sh new file mode 100755 index 0000000..6c76583 --- /dev/null +++ b/peopleportal/dev-stack/scripts/common.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Shared helpers. No jq, GNU-only tools or global container names. +set -euo pipefail +umask 077 +STACK_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +export STACK_DIR + +die() { echo "ERROR: $*" >&2; exit 1; } +trap 'echo "ERROR: ${BASH_SOURCE[0]}:$LINENO: command failed; see the preceding output." >&2' ERR + +require_tools() { + local bin + for bin in "$@"; do + command -v "$bin" >/dev/null 2>&1 || die "Missing required command: $bin" + done +} + +require_docker() { + require_tools docker curl + docker compose version >/dev/null 2>&1 || die "Docker Compose v2 or later is required." + docker info >/dev/null 2>&1 || die "Docker daemon is unavailable. Start Docker and check access." +} + +# Parse data rather than execute .env. Only template keys and simple literals +# are accepted, making the same file safe for both Bash and Compose. +read_settings() { + local file=$1 line key value seen='|' + [[ ! -L $file ]] || die "Refusing symlink: $file" + [[ -f $file ]] || return 0 + while IFS= read -r line || [[ -n $line ]]; do + case "$line" in ''|\#*) continue ;; esac + [[ $line =~ ^[A-Z][A-Z0-9_]*= ]] || die "Use KEY=value lines in $file." + key=${line%%=*} + grep -q "^${key}=" "$STACK_DIR/.env.example" || die "Unknown setting $key in $file." + [[ $seen != *"|$key|"* ]] || die "Duplicate setting $key in $file." + seen="$seen$key|" + value=${line#*=} + if [[ $value == \'*\' ]]; then value=${value:1:${#value}-2}; fi + case "$value" in *[!a-zA-Z0-9_.,:/@%+=?-]*) die "Unsupported characters in $key; use simple literal values." ;; esac + printf -v "$key" '%s' "$value" + export "${key?}" + done <"$file" +} + +load_env() { + cd -- "$STACK_DIR" + [[ -f .env ]] || die "Run scripts/gen-env.sh first." + read_settings "$STACK_DIR/.env" +} + +compose() { + docker compose --project-directory "$STACK_DIR" --env-file "$STACK_DIR/.env" \ + -f "$STACK_DIR/docker-compose.yml" "$@" +} + +print_ports() { + local key + echo "Local ports for $COMPOSE_PROJECT_NAME (Docker bindings: 127.0.0.1):" >&2 + for key in PORTAL_PORT WEBUI_PORT MONGO_PORT REDIS_PORT AUTHENTIK_PORT GITEA_PORT GITEA_SSH_PORT MAILPIT_PORT SMTP_PORT; do + printf ' %s=%s\n' "$key" "${!key}" >&2 + done +} + +check_ports() { + local ids containers='[]' + ids=$(compose ps --status running --quiet) + if [[ -n $ids ]]; then + # Compose supplies container IDs, one per line. Inspect only this project. + # shellcheck disable=SC2086 + containers=$(docker inspect $ids) + fi + python3 "$STACK_DIR/scripts/check-ports.py" <<<"$containers" +} + +wait_url() { + local url=$1 attempts=${2:-120} i + for ((i = 0; i < attempts; i++)); do + if curl --fail --silent --max-time 5 "$url" >/dev/null; then return 0; fi + sleep 2 + done + die "Timed out waiting for $url. Check docker compose ps and logs in $STACK_DIR." +} + +private_file() { + local target=$1 tmp + [[ ! -L $target ]] || die "Refusing symlink: $target" + tmp=$(mktemp "${target}.XXXXXX") + if cat >"$tmp"; then + chmod 600 "$tmp" + mv -f -- "$tmp" "$target" + else + rm -f -- "$tmp" + return 1 + fi +} + +gitea_admin() { + compose exec -T --user git gitea gitea --config /data/gitea/conf/app.ini admin "$@" +} + +lock_stack() { + mkdir "$STACK_DIR/.bootstrap.lock" 2>/dev/null || die "Another bootstrap/reset is running. Check before removing a stale .bootstrap.lock." + trap 'rmdir "$STACK_DIR/.bootstrap.lock"' EXIT +} diff --git a/peopleportal/dev-stack/scripts/gen-env.sh b/peopleportal/dev-stack/scripts/gen-env.sh new file mode 100755 index 0000000..c326d41 --- /dev/null +++ b/peopleportal/dev-stack/scripts/gen-env.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Fill missing/empty settings once. Existing nonempty values always win. +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +require_tools openssl +cd -- "$STACK_DIR" +read_settings "$STACK_DIR/.env" + +# Template defaults and exported overrides precede derived URLs and secrets. +while IFS= read -r line || [[ -n $line ]]; do + case "$line" in ''|\#*) continue ;; esac + key=${line%%=*} + value=${!key:-${line#*=}} + printf -v "$key" '%s' "$value" + export "${key?}" +done <.env.example + +[[ $PUBLIC_HOST =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || die "PUBLIC_HOST must be a hostname or IPv4 address, without scheme or port." +[[ $BIND_ADDRESS == 127.0.0.1 ]] || die "BIND_ADDRESS must be 127.0.0.1 to keep backing services local." +seen_ports='|' +for key in AUTHENTIK_PORT GITEA_PORT GITEA_SSH_PORT MONGO_PORT REDIS_PORT MAILPIT_PORT SMTP_PORT PORTAL_PORT WEBUI_PORT; do + value=${!key} + [[ $value =~ ^[1-9][0-9]*$ && ${#value} -le 5 ]] || die "Invalid port: $key" + (( value <= 65535 )) || die "Port out of range: $key" + [[ $seen_ports != *"|$value|"* ]] || die "$key=$value conflicts with another stack port; choose distinct ports." + seen_ports="$seen_ports$value|" +done +[[ $AUTHENTIK_PORT != 3000 && $AUTHENTIK_PORT != 22 ]] || die "AUTHENTIK_PORT conflicts with Gitea's internal listener; set AUTHENTIK_PORT to another port." +: "${PORTAL_BASE_URL:=http://$PUBLIC_HOST:$PORTAL_PORT}" +: "${AUTHENTIK_URL:=http://$PUBLIC_HOST:$AUTHENTIK_PORT}" +: "${GITEA_URL:=http://$PUBLIC_HOST:$GITEA_PORT}" +: "${S3_ENDPOINT:=http://s3.example.invalid}" +: "${S3_CORS_ORIGINS:=$PORTAL_BASE_URL,http://$PUBLIC_HOST:$WEBUI_PORT,http://localhost:3000,http://localhost:5173}" +for key in POSTGRES_PASSWORD AUTHENTIK_SECRET_KEY AUTHENTIK_BOOTSTRAP_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN PORTAL_OIDC_CLIENT_SECRET PORTAL_TOKEN_SECRET GITEA_OIDC_CLIENT_SECRET GITEA_ADMIN_PASSWORD GITEA_WEBHOOK_SECRET; do + if [[ -z ${!key} ]]; then + size=32 + value=$(openssl rand -hex "$size") + printf -v "$key" '%s' "$value" + fi +done + +# Validate exported overrides too before saving them as shell/Compose literals. +render_settings() { + while IFS= read -r line || [[ -n $line ]]; do + case "$line" in ''|\#*) printf '%s\n' "$line"; continue ;; esac + key=${line%%=*} + value=${!key} + case "$value" in *[!a-zA-Z0-9_.,:/@%+=?-]*) die "Unsupported characters in $key; use simple literal values." ;; esac + printf "%s='%s'\n" "$key" "$value" + done <.env.example +} +settings=$(render_settings) +private_file "$STACK_DIR/.env" <<<"$settings" +echo "Local .env ready (mode 600); existing nonempty values preserved." >&2 diff --git a/peopleportal/dev-stack/scripts/oidc-bridge.py b/peopleportal/dev-stack/scripts/oidc-bridge.py new file mode 100644 index 0000000..975c133 --- /dev/null +++ b/peopleportal/dev-stack/scripts/oidc-bridge.py @@ -0,0 +1,57 @@ +"""Loopback-only OIDC forwarding inside Gitea's network namespace.""" +import http.client +import os +import socket +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Bridge(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.end_headers() + return + self.forward() + + def do_POST(self): + self.forward() + + def forward(self): + if not self.path.startswith("/application/o/"): + self.send_error(404) + return + upstream = http.client.HTTPConnection("authentik-server", 9000, timeout=15) + try: + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + # Preserve public Host for discovery, issuer and JWT consistency. + headers = {k: v for k, v in self.headers.items() + if k.lower() not in {"connection", "transfer-encoding"}} + upstream.request(self.command, self.path, body=body, headers=headers) + response = upstream.getresponse() + data = response.read() + self.send_response(response.status) + for key, value in response.getheaders(): + if key.lower() not in {"connection", "transfer-encoding", "content-length"}: + self.send_header(key, value) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except (OSError, http.client.HTTPException): + self.send_error(502, "Authentik upstream unavailable") + finally: + upstream.close() + + def log_message(self, *_args): + # OAuth query strings contain authorization codes. Do not log them. + pass + + +class IPv6LoopbackServer(ThreadingHTTPServer): + address_family = socket.AF_INET6 + + +port = int(os.environ["AUTHENTIK_PORT"]) +ipv6 = IPv6LoopbackServer(("::1", port), Bridge) +threading.Thread(target=ipv6.serve_forever, daemon=True).start() +ThreadingHTTPServer(("127.0.0.1", port), Bridge).serve_forever() diff --git a/peopleportal/dev-stack/scripts/portal-env.sh b/peopleportal/dev-stack/scripts/portal-env.sh new file mode 100755 index 0000000..445fc34 --- /dev/null +++ b/peopleportal/dev-stack/scripts/portal-env.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +load_env +write_env=false +case "${1:-}" in + '') [[ $# -eq 0 ]] || die "Usage: $0 [--write-env]" ;; + --write-env) [[ $# -eq 1 ]] || die "Usage: $0 [--write-env]"; write_env=true ;; + *) die "Usage: $0 [--write-env]" ;; +esac +gitea_token=$(<"$STACK_DIR/.gitea-token") +ui_env='' +if [[ $PORTAL_PORT != 3000 ]]; then + ui_env="PP_DEV_PORTAL_PORT=$PORTAL_PORT" +fi +if [[ $WEBUI_PORT != 5173 ]]; then + ui_env="${ui_env:+$ui_env$'\n'}PP_DEV_WEBUI_PORT=$WEBUI_PORT" +fi + +block=$(cat <&2 + else + (set -o noclobber; printf '%s\n' "$block" >"$target") + chmod 600 "$target" + echo 'Created peopleportal/server/.env.development (mode 600).' >&2 + fi + target="$STACK_DIR/../webui/.env.development.local" + if [[ -n $ui_env ]]; then + if [[ -e $target || -L $target ]]; then + echo 'Existing peopleportal/webui/.env.development.local preserved; compare its PP_DEV_* values with the printed block.' >&2 + else + (set -o noclobber; printf '%s\n' "$ui_env" >"$target") + chmod 600 "$target" + echo 'Created peopleportal/webui/.env.development.local with alternate ports (mode 600).' >&2 + fi + fi +fi +echo "Ready: authentik $AUTHENTIK_URL | Gitea $GITEA_URL | Mailpit http://$PUBLIC_HOST:$MAILPIT_PORT" >&2 +echo 'Log in as akadmin; password is AUTHENTIK_BOOTSTRAP_PASSWORD in dev-stack/.env.' >&2 +echo 'Paste the following into peopleportal/server/.env, or use --write-env for .env.development:' >&2 +if [[ -n $ui_env ]]; then + echo 'Also copy the PP_DEV_* lines into peopleportal/webui/.env.development.local, or use --write-env.' >&2 +fi +printf '%s\n' "$block" diff --git a/peopleportal/dev-stack/scripts/reset.sh b/peopleportal/dev-stack/scripts/reset.sh new file mode 100755 index 0000000..4179df8 --- /dev/null +++ b/peopleportal/dev-stack/scripts/reset.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Delete only this Compose project's backing volumes, keeping generated secrets. +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +require_docker +load_env +case "${1:-}" in + --yes) [[ $# -eq 1 ]] || die "Usage: $0 [--yes]" ;; + '') + [[ $# -eq 0 && -t 0 ]] || die "Reset deletes all local stack data. Use --yes for noninteractive reset." + read -r -p "Delete ALL volumes for $COMPOSE_PROJECT_NAME? Type reset: " answer + [[ $answer == reset ]] || die "Reset cancelled." + ;; + *) die "Usage: $0 [--yes]" ;; +esac +lock_stack +compose down --volumes --remove-orphans +rm -f -- "$STACK_DIR/.oidc" "$STACK_DIR/.gitea-token" +echo 'Volumes removed; .env and the server env were preserved. Run scripts/up.sh to bootstrap again.' >&2 +echo 'The Gitea API token changes after reset. Replace it in the server env using the printed block.' >&2 diff --git a/peopleportal/dev-stack/scripts/up.sh b/peopleportal/dev-stack/scripts/up.sh new file mode 100755 index 0000000..5c3569c --- /dev/null +++ b/peopleportal/dev-stack/scripts/up.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# stdout: exact portal env block. Progress and verification go to stderr. +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +write_env=false +case "${1:-}" in + '') [[ $# -eq 0 ]] || die "Usage: $0 [--write-env]" ;; + --write-env) [[ $# -eq 1 ]] || die "Usage: $0 [--write-env]"; write_env=true ;; + --help|-h) echo "Usage: $0 [--write-env]"; exit 0 ;; + *) die "Usage: $0 [--write-env]" ;; +esac +require_tools openssl python3 +require_docker +cd -- "$STACK_DIR" +lock_stack +"$STACK_DIR/scripts/gen-env.sh" +load_env +compose config --quiet +print_ports +check_ports +echo 'Starting backing services (first pull and bootstrap can take several minutes)...' >&2 +compose up -d --wait --wait-timeout 600 \ + postgres authentik-server authentik-worker gitea mongo redis mailpit gitea-oidc >&2 +"$STACK_DIR/scripts/bootstrap-authentik.sh" +"$STACK_DIR/scripts/bootstrap-gitea.sh" +"$STACK_DIR/scripts/verify.sh" >&2 +if [[ $write_env == true ]]; then + "$STACK_DIR/scripts/portal-env.sh" --write-env +else + "$STACK_DIR/scripts/portal-env.sh" +fi diff --git a/peopleportal/dev-stack/scripts/verify.sh b/peopleportal/dev-stack/scripts/verify.sh new file mode 100755 index 0000000..e39e4a2 --- /dev/null +++ b/peopleportal/dev-stack/scripts/verify.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Read-only checks for every included backing service. +set -euo pipefail +# shellcheck source=peopleportal/dev-stack/scripts/common.sh +source "$(dirname -- "${BASH_SOURCE[0]}")/common.sh" +require_docker +load_env +print_ports +failures=0 +check() { + local label=$1 + shift + if "$@"; then + echo "PASS: $label" + else + echo "FAIL: $label" >&2 + failures=$((failures + 1)) + fi +} + +healthy() { + local id state + id=$(compose ps --all --quiet "$1") || return 1 + [[ -n $id ]] || return 1 + state=$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$id") || return 1 + [[ $state == 'running healthy' ]] +} + +http_200() { + local code + code=$(curl --silent --show-error --fail --max-time 10 --output /dev/null --write-out '%{http_code}' "$1") || return 1 + [[ $code == 200 ]] +} + +discovery_scope() { + curl --silent --show-error --fail --max-time 10 \ + "http://127.0.0.1:$AUTHENTIK_PORT/application/o/people-portal/.well-known/openid-configuration" | \ + compose exec -T authentik-worker python -c \ + 'import json,sys; sys.exit(0 if "people_portal" in json.load(sys.stdin)["scopes_supported"] else 1)' +} + +gitea_source() { + local sources + sources=$(gitea_admin auth list) || return 1 + awk '$2 == "authentik" {found=1} END {exit !found}' <<<"$sources" +} + +gitea_discovery() { + # Exercises the network namespace bridge and public URL, not just its health. + compose exec -T gitea wget -q --spider \ + "$AUTHENTIK_URL/application/o/gitea/.well-known/openid-configuration" +} + +redis_ping() { + local response + response=$(compose exec -T redis redis-cli ping) || return 1 + [[ $response == PONG ]] +} + +for service in postgres authentik-server authentik-worker gitea gitea-oidc mongo redis mailpit; do + check "container $service healthy" healthy "$service" +done +check 'authentik discovery HTTP 200' http_200 "http://127.0.0.1:$AUTHENTIK_PORT/application/o/people-portal/.well-known/openid-configuration" +check 'authentik discovery includes people_portal scope' discovery_scope +check 'Gitea API version HTTP 200' http_200 "http://127.0.0.1:$GITEA_PORT/api/v1/version" +check 'Gitea authentik auth source exists' gitea_source +check 'Gitea can discover authentik via public OIDC URL' gitea_discovery +check 'MongoDB ping' compose exec -T mongo mongosh --quiet --eval "quit(db.adminCommand('ping').ok ? 0 : 1)" +check 'Redis PONG' redis_ping +check 'Mailpit API HTTP 200' http_200 "http://127.0.0.1:$MAILPIT_PORT/api/v1/messages" +if (( failures > 0 )); then die "$failures smoke check(s) failed."; fi +echo 'NOTE: S3 object storage is not included in this environment yet; resume/avatar uploads will fail until a local S3 option is added.' +echo 'All included-service smoke checks passed.' diff --git a/peopleportal/webui/vite.config.ts b/peopleportal/webui/vite.config.ts index 119fbd2..6f51ba2 100644 --- a/peopleportal/webui/vite.config.ts +++ b/peopleportal/webui/vite.config.ts @@ -1,22 +1,27 @@ import path from "path" import tailwindcss from "@tailwindcss/vite" -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react-swc' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - "@root": path.resolve(__dirname), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, 'PP_DEV_') + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@root": path.resolve(__dirname), + }, }, - }, - server: { - proxy: { - '/api': { - target: 'http://localhost:3000', + server: { + ...(env.PP_DEV_WEBUI_PORT ? { port: Number(env.PP_DEV_WEBUI_PORT), strictPort: true } : {}), + proxy: { + '/api': { + target: `http://localhost:${env.PP_DEV_PORTAL_PORT || 3000}`, + } } } }