Compare commits
No commits in common. "5765c6be8b8daac30ba4d86c8832ec7265103604" and "5344f249c7e453fdb7a3cf3664979305a286764b" have entirely different histories.
5765c6be8b
...
5344f249c7
80 changed files with 720 additions and 3930 deletions
42
.github/copilot-instructions.md
vendored
42
.github/copilot-instructions.md
vendored
|
|
@ -44,15 +44,19 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use
|
|||
domain types, and `src/styles/global.css` contains application-wide styles.
|
||||
Keep feature-specific components inside their feature directory. Vite proxies
|
||||
`/api` to the Go service in development.
|
||||
- Multi-tenant URLs use `<tenant>.<app domain>`, where the app domain is
|
||||
`guess-dev.urpi.be` in local development and the Kubernetes development
|
||||
profile, and `guess.urpi.be` in production. The frontend derives the tenant from
|
||||
the hostname, calls same-origin `/api` through the HTTPS proxy, and sends
|
||||
`X-Tenant-Id` on every request type. Generated participant links must use the
|
||||
validated matching tenant origin. Keep this contract documented in `README.md`
|
||||
and `frontend/README.md`.
|
||||
- Protected interviewer routes require a bearer JWT only. Go Loose is used for
|
||||
browser login, not for API-key authorization.
|
||||
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
|
||||
tenant from the hostname, calls same-origin `/api` through the HTTPS proxy, and
|
||||
sends `X-Tenant-Id` on every request type. Other deployments use same-origin
|
||||
`/api`. Generated participant links must use the validated matching tenant
|
||||
origin. Keep this contract documented in `README.md` and `frontend/README.md`.
|
||||
- Protected interviewer routes also require Go Loose `X-API-Key` authorization
|
||||
for application `guess`. The tenant passed to Go Loose comes from
|
||||
`X-Tenant-Id`. `GO_LOOSE_BASE_URL` defaults to `https://%s.auth.dev`;
|
||||
`%s` is that tenant and must be one DNS label. Do not require the API key on
|
||||
`/api/health`, `/api/openapi.json`, `/api/auth/login`, or opaque-token
|
||||
participant routes. The frontend sends the key only when
|
||||
`VITE_GO_LOOSE_API_KEY` is present at build time. Document both variables when
|
||||
their contract changes.
|
||||
|
||||
Goose files in `backend/migrations` are schema-only and run in every environment.
|
||||
`internal/database/seed/common.sql` contains shared reference data;
|
||||
|
|
@ -63,19 +67,13 @@ test updates. Never put demo users or test records in schema migrations.
|
|||
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
|
||||
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
|
||||
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
|
||||
participant interview routes. Any tenant host starts browser login with
|
||||
`github.com/danyel/go-loose/client`: `/api/auth/login` redirects to
|
||||
`https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize` and Go Loose returns to
|
||||
`/api/auth/callback`. `GO_LOOSE_AUTH_DOMAIN` is `auth-dev.urpi.be` locally and in
|
||||
the development profile, and `auth.urpi.be` in production; `GO_LOOSE_APP_DOMAIN`
|
||||
is the matching application domain. Both default to the development domains.
|
||||
`GET /api/auth/session` answers `404` when no Go Loose login is configured for the
|
||||
request host, and the frontend uses that to fall back to the password form instead
|
||||
of redirecting. Go Loose client credentials come from uncommitted environment
|
||||
variables. The tenant comes from `X-Tenant-Id`, or from the request host and
|
||||
`X-Forwarded-Host` when the header is absent; missing tenant context uses the
|
||||
public schema. Never expose password hashes, CV/photo bytes, or interviewer
|
||||
reference answers in public JSON.
|
||||
participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev`
|
||||
start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects
|
||||
to `https://<tenant>.auth.dev/connect/authorize` and Go Loose returns to
|
||||
`/api/auth/callback`. Go Loose client credentials come from uncommitted
|
||||
environment variables. Missing tenant context uses the public schema; a
|
||||
`*.guess.dev` host supplies the schema when `X-Tenant-Id` is absent. Never expose
|
||||
password hashes, CV/photo bytes, or interviewer reference answers in public JSON.
|
||||
|
||||
Questions are permanent reusable records linked to jobs through
|
||||
`job_posting_questions`; detach links rather than deleting questions. Deprecated
|
||||
|
|
|
|||
7
.github/workflows/publish-image.yml
vendored
7
.github/workflows/publish-image.yml
vendored
|
|
@ -52,7 +52,6 @@ jobs:
|
|||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ forgejo.sha }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
|
|||
|
|
@ -36,11 +36,6 @@ COPY --chown=app:app backend/migrations/ ./migrations/
|
|||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint
|
||||
|
||||
# One user, and it owns everything the runtime writes to. A second USER line here
|
||||
# silently replaced this one with a uid that owns neither /app nor /tmp/nginx, so
|
||||
# nginx could not create its pid file or its temp paths and the container failed to
|
||||
# start. nginx also ignores its own user directive when the master is not root, so
|
||||
# its workers run as whoever this names.
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
ENV ADDRESS=:8081 \
|
||||
|
|
|
|||
71
Makefile
71
Makefile
|
|
@ -1,5 +1,5 @@
|
|||
SHELL := /bin/sh
|
||||
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
|
||||
|
||||
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
|
||||
JWT_SECRET ?= local-development-secret-change-me-12345
|
||||
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
||||
|
|
@ -7,15 +7,10 @@ BDD_COMPOSE := docker compose -f docker-compose.bdd.yml
|
|||
HELM ?= helm
|
||||
HELM_CHART := deploy/helm/go-guess
|
||||
HELM_RELEASE ?= go-guess
|
||||
HELM_NAMESPACE ?= go-insane
|
||||
KUBECONFIG ?= $(HOME)/.kube/config
|
||||
HELM_KUBECTL := KUBECONFIG="$(KUBECONFIG)" $(HELM)
|
||||
GO_LOOSE_SET ?= --set-string api.goLoose.tenants.nmbs.clientID="$${GO_LOOSE_NMBS_CLIENT_ID:-}" \
|
||||
--set-string api.goLoose.tenants.nmbs.clientSecret="$${GO_LOOSE_NMBS_CLIENT_SECRET:-}" \
|
||||
--set-string api.goLoose.tenants.ypto.clientID="$${GO_LOOSE_YPTO_CLIENT_ID:-}" \
|
||||
--set-string api.goLoose.tenants.ypto.clientSecret="$${GO_LOOSE_YPTO_CLIENT_SECRET:-}"
|
||||
HELM_NAMESPACE ?= go-guess
|
||||
KUBECONFIG ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml
|
||||
|
||||
.PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build rancher-storage ingress-nginx-install cert-manager-install cluster-prepare helm-lint helm-deploy-development helm-deploy-production helm-down-production helm-down-development
|
||||
.PHONY: help dev db-up db-down migrate migrate-down seed backend frontend frontend-install test test-backend test-frontend test-integration test-bdd test-bdd-down test-env test-env-down lint build rancher-storage helm-lint helm-deploy-development helm-deploy-production helm-down-production helm-down-development
|
||||
|
||||
help:
|
||||
@echo "make dev builds the demo docker image"
|
||||
|
|
@ -37,13 +32,7 @@ help:
|
|||
@echo "make test-env-down stops the test image"
|
||||
@echo "make lint runs lint on the frontend"
|
||||
@echo "make build builds the entire project"
|
||||
@echo "make docker build the all-in-one image"
|
||||
@echo "make docker-push build and push the image to the private registry"
|
||||
@echo "make docker-run run the image on :8080"
|
||||
@echo "make rancher-storage installs the persistent local-path StorageClass"
|
||||
@echo "make ingress-nginx-install installs the ingress-nginx controller serving 80/443"
|
||||
@echo "make cert-manager-install installs cert-manager for chart-issued TLS certificates"
|
||||
@echo "make cluster-prepare runs every cluster prerequisite in order"
|
||||
@echo "make helm-lint lints development and production Helm configurations"
|
||||
@echo "make helm-deploy-development deploys the development Helm release"
|
||||
@echo "make helm-deploy-production deploys the production Helm release"
|
||||
|
|
@ -112,41 +101,9 @@ build:
|
|||
cd backend && go build ./...
|
||||
cd frontend && npm run build
|
||||
|
||||
docker:
|
||||
docker build -t $(IMAGE):$(TAG) .
|
||||
|
||||
docker-push: docker
|
||||
docker tag $(IMAGE):$(TAG) $(REGISTRY_IMAGE):$(TAG)
|
||||
docker push $(REGISTRY_IMAGE):$(TAG)
|
||||
|
||||
docker-run:
|
||||
docker run --rm -p 8080:8080 --env-file .env $(IMAGE):$(TAG)
|
||||
|
||||
|
||||
rancher-storage:
|
||||
KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml
|
||||
|
||||
ingress-nginx-install:
|
||||
$(HELM_KUBECTL) repo add ingress-nginx https://kubernetes.github.io/ingress-nginx --force-update
|
||||
$(HELM_KUBECTL) repo update
|
||||
$(HELM_KUBECTL) upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
|
||||
--namespace ingress-nginx --create-namespace \
|
||||
--set controller.service.type=NodePort \
|
||||
--set controller.hostPort.enabled=true \
|
||||
--set controller.kind=DaemonSet \
|
||||
--set controller.admissionWebhooks.enabled=false \
|
||||
--wait --timeout 10m
|
||||
|
||||
cert-manager-install:
|
||||
$(HELM_KUBECTL) repo add jetstack https://charts.jetstack.io --force-update
|
||||
$(HELM_KUBECTL) repo update
|
||||
$(HELM_KUBECTL) upgrade --install cert-manager jetstack/cert-manager \
|
||||
--namespace cert-manager --create-namespace \
|
||||
--set crds.enabled=true \
|
||||
--wait --timeout 10m
|
||||
|
||||
cluster-prepare: rancher-storage ingress-nginx-install cert-manager-install
|
||||
|
||||
helm-lint:
|
||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
|
||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-production.yaml
|
||||
|
|
@ -156,25 +113,17 @@ helm-lint:
|
|||
-f $(HELM_CHART)/values-production.yaml >/dev/null
|
||||
|
||||
helm-deploy-development:
|
||||
$(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||
--namespace $(HELM_NAMESPACE)-development --create-namespace \
|
||||
-f $(HELM_CHART)/values-development.yaml \
|
||||
--set secrets.postgresqlPassword="go_guess" \
|
||||
--set secrets.rabbitmqPassword="guest" \
|
||||
$(GO_LOOSE_SET) \
|
||||
--rollback-on-failure --wait --timeout 10m
|
||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) --namespace $(HELM_NAMESPACE)-development --create-namespace -f $(HELM_CHART)/values-development.yaml --rollback-on-failure --wait --timeout 10m
|
||||
|
||||
helm-deploy-production:
|
||||
$(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
||||
--namespace $(HELM_NAMESPACE)-production --create-namespace \
|
||||
-f $(HELM_CHART)/values-production.yaml \
|
||||
-f $(HELM_CHART)/values-production.yaml --wait --timeout 10m\
|
||||
--set secrets.postgresqlPassword="go_guess" \
|
||||
--set secrets.rabbitmqPassword="guest" \
|
||||
$(GO_LOOSE_SET) \
|
||||
--wait --timeout 10m
|
||||
--set secrets.rabbitmqPassword="guest"
|
||||
|
||||
helm-down-production:
|
||||
$(HELM_KUBECTL) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
|
||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
|
||||
|
||||
helm-down-development:
|
||||
$(HELM_KUBECTL) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
|
||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
|
||||
|
|
|
|||
182
README.md
182
README.md
|
|
@ -21,46 +21,33 @@ make dev
|
|||
```
|
||||
|
||||
`docker compose up -d --build` serves the UI through the local Traefik edge at
|
||||
<https://nmbs.guess-dev.urpi.be> and <https://ypto.guess-dev.urpi.be>. Browser
|
||||
sign-in goes through the development Go Loose installation at
|
||||
<https://nmbs.auth-dev.urpi.be>. Both domains must resolve to the local edge:
|
||||
<https://nmbs.guess.dev> and <https://ypto.guess.dev>. Add the hostnames once:
|
||||
|
||||
```text
|
||||
127.0.0.1 nmbs.guess-dev.urpi.be ypto.guess-dev.urpi.be
|
||||
127.0.0.1 nmbs.auth-dev.urpi.be ypto.auth-dev.urpi.be
|
||||
127.0.0.1 nmbs.guess.dev ypto.guess.dev
|
||||
127.0.0.1 auth.dev nmbs.auth.dev ypto.auth.dev
|
||||
```
|
||||
|
||||
Production uses `guess.urpi.be` and `auth.urpi.be` instead. `GO_LOOSE_AUTH_DOMAIN`
|
||||
and `GO_LOOSE_APP_DOMAIN` select the pair; both default to the development
|
||||
domains so a local run never touches the production identity provider.
|
||||
|
||||
Start the shared TLS proxy described in
|
||||
`/home/dnoulet/go/infrastructure/docs/APPLICATIONS.md`, then open
|
||||
<https://nmbs.guess-dev.urpi.be> or <https://ypto.guess-dev.urpi.be>. The
|
||||
hostname's first label is the tenant ID: the frontend calls same-origin `/api` and
|
||||
sends the same value in the `X-Tenant-Id` header on JSON, multipart,
|
||||
file-download, and event-stream requests. The backend uses that tenant ID as the
|
||||
isolated PostgreSQL schema and builds participant invitation and meeting links
|
||||
from the matching tenant browser origin instead of the static localhost fallback.
|
||||
`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open
|
||||
<https://nmbs.guess.dev> or <https://ypto.guess.dev>. The hostname's first
|
||||
label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the
|
||||
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream
|
||||
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and
|
||||
builds participant invitation and meeting links from the matching tenant browser
|
||||
origin instead of the static localhost fallback.
|
||||
|
||||
Compose mounts the infrastructure CA from
|
||||
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API at
|
||||
`/certs/local-ca.crt`, which `GO_LOOSE_CA_FILE` points at so the API trusts the
|
||||
local Traefik certificate. The browser is redirected through the matching Go Loose
|
||||
tenant and returns to `/api/auth/callback`. Set the per-tenant
|
||||
`GO_LOOSE_*_CLIENT_ID` and `GO_LOOSE_*_CLIENT_SECRET` variables in the
|
||||
environment; never commit them.
|
||||
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The
|
||||
browser is redirected through the matching Go Loose tenant and returns to
|
||||
`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and
|
||||
`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them.
|
||||
|
||||
Sign in with:
|
||||
|
||||
- Email: `interviewer@ypto.local`
|
||||
- Password: `admin123`
|
||||
|
||||
Those credentials are only for hosts without Go Loose login. When Go Loose
|
||||
client credentials are configured, the frontend exchanges the Go Loose session
|
||||
for a JWT silently and redirects to the identity provider instead of showing the
|
||||
password form.
|
||||
|
||||
To run the applications outside containers:
|
||||
|
||||
```bash
|
||||
|
|
@ -73,8 +60,7 @@ make frontend
|
|||
|
||||
The API listens on port `8080` inside Docker and is exposed to browsers only
|
||||
through Traefik and the frontend's same-origin `/api` proxy. Vite permits the
|
||||
documented local tenant hostnames; the API CORS policy permits the configured
|
||||
tenant domain and `*.guess.local` development origins and
|
||||
documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and
|
||||
the `Authorization` and `X-Tenant-Id` request headers. Non-local
|
||||
deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET`
|
||||
outside local development.
|
||||
|
|
@ -82,7 +68,7 @@ outside local development.
|
|||
## API contract
|
||||
|
||||
The backend exposes its current OpenAPI 3.0 contract at
|
||||
<https://nmbs.guess-dev.urpi.be/api/openapi.json>. The document is assembled in memory
|
||||
<https://nmbs.guess.dev/api/openapi.json>. The document is assembled in memory
|
||||
from the same typed registrations that add routes to Chi, including request and
|
||||
response schemas, status codes, authentication, tenant headers, path/query
|
||||
parameters, multipart uploads, downloads, and event streams. It therefore
|
||||
|
|
@ -92,24 +78,16 @@ requires no generation command and cannot miss a newly registered route.
|
|||
tenant header. Every other route declares required `X-Tenant-Id`. Protected
|
||||
operations also declare bearer authentication.
|
||||
|
||||
## Go Loose browser login
|
||||
## Go Loose authorization
|
||||
|
||||
Tenant hosts start browser authentication at `GET /api/auth/login`. The API
|
||||
redirects to `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`; Go Loose
|
||||
Tenant hosts start browser authentication at `GET /api/auth/login`. Go Loose
|
||||
returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the
|
||||
resulting secure browser session for the application's bearer JWT. The tenant is
|
||||
derived from the request host, or from `X-Forwarded-Host` when an ingress proxy
|
||||
replaces it, and from `X-Tenant-Id` for API calls.
|
||||
resulting secure browser session for the application's bearer JWT. The tenant
|
||||
is derived from the request host, or from `X-Tenant-Id` for API calls.
|
||||
|
||||
| Environment | `GO_LOOSE_AUTH_DOMAIN` | `GO_LOOSE_APP_DOMAIN` |
|
||||
| --- | --- | --- |
|
||||
| Local development | `auth-dev.urpi.be` | `guess-dev.urpi.be` |
|
||||
| Kubernetes development | `auth-dev.urpi.be` | `guess-dev.urpi.be` |
|
||||
| Production | `auth.urpi.be` | `guess.urpi.be` |
|
||||
|
||||
Those two variables, `GO_LOOSE_CA_FILE`, and per-tenant client ID and secret
|
||||
variables are configured by Compose and by the chart. These routes do not
|
||||
require a JWT:
|
||||
Compose configures `GO_LOOSE_AUTH_DOMAIN`, `GO_LOOSE_APP_DOMAIN`,
|
||||
`GO_LOOSE_CA_FILE`, and per-tenant client ID and secret variables. These routes
|
||||
do not require a JWT:
|
||||
|
||||
- `GET /api/health` and `GET /api/openapi.json`
|
||||
- browser login, callback, logout, and session routes under `/api/auth`
|
||||
|
|
@ -120,13 +98,6 @@ require a JWT:
|
|||
Login and participant routes still require `X-Tenant-Id`. Health and OpenAPI do
|
||||
not. Browser authentication routes derive the tenant from the configured host.
|
||||
|
||||
The frontend decides whether to attempt browser login from the host it is served
|
||||
on, then asks the API whether Go Loose is configured there: `GET /api/auth/session`
|
||||
answers `404` for any host without a Go Loose login, which is the signal to show
|
||||
the password form instead of redirecting. A `401` means Go Loose is configured and
|
||||
the browser has no valid session yet, so the frontend redirects to
|
||||
`GET /api/auth/login`.
|
||||
|
||||
## Switch the Git remote
|
||||
|
||||
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between
|
||||
|
|
@ -185,72 +156,36 @@ make test-env-down
|
|||
|
||||
## Production image
|
||||
|
||||
Two images are published to the private registry: `go-guess-api:0.0.1` for the Go
|
||||
service and `go-guess-web:0.0.1` for the React front end behind nginx.
|
||||
Build the complete application as a single image:
|
||||
|
||||
```bash
|
||||
docker build -t batty1039.startdedicated.net:5000/go-guess-api:0.0.1 ./backend
|
||||
docker build -t batty1039.startdedicated.net:5000/go-guess-web:0.0.1 ./frontend
|
||||
docker build -t go-guess .
|
||||
```
|
||||
|
||||
The API image listens on `8080` and applies database migrations and shared
|
||||
fixtures before serving. The web image listens on `80`, serves the built assets,
|
||||
and proxies `/api/` to `http://api:8080`, so both containers must share a
|
||||
network with the API reachable under the DNS name `api`. PostgreSQL, RabbitMQ,
|
||||
and these environment variables must be provided to the API:
|
||||
The image serves the frontend and API on port `8080`. It applies database
|
||||
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
|
||||
and these environment variables must be provided:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \
|
||||
-e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \
|
||||
-e JWT_SECRET='replace-with-at-least-32-characters' \
|
||||
-e FRONTEND_URL='https://nmbs.guess-dev.urpi.be' \
|
||||
batty1039.startdedicated.net:5000/go-guess-api:0.0.1
|
||||
-e FRONTEND_URL='https://go-guess.example.com' \
|
||||
go-guess
|
||||
```
|
||||
|
||||
The `Dockerfile` at the repository root still builds one combined image that
|
||||
serves both halves on port `8080`. It is convenient for local runs and browser
|
||||
BDD tests, while Kubernetes deploys the two images separately.
|
||||
|
||||
That combined image runs as the `app` user, which owns everything the runtime
|
||||
writes to. There is deliberately only one `USER` line: a second one silently
|
||||
replaced it with a uid owning neither `/app` nor `/tmp/nginx`, and nginx could
|
||||
then not create its pid file or its temp paths, so the container failed to start.
|
||||
nginx ignores its own `user` directive when the master is not root, so its
|
||||
workers run as whoever this names.
|
||||
|
||||
`THEME_BASE_URL` points the page at the [go-bananas](https://github.com/urpi/go-bananas)
|
||||
design-token service, which supplies the palette and the light/dark switch at run
|
||||
time. It is optional and empty by default, so an image without a theme service
|
||||
renders exactly as before. The entrypoint writes it into `runtime-config.js` on
|
||||
every start, so one image serves every environment. See
|
||||
[the front-end README](frontend/README.md#theming) for the deployment
|
||||
requirements.
|
||||
|
||||
Pushes to `master` publish `go-guess:latest` and `go-guess:<commit-sha>` to the
|
||||
private registry configured in `.github/workflows/publish-image.yml`. The
|
||||
repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets.
|
||||
|
||||
## Rancher and Helm
|
||||
|
||||
The chart under `deploy/helm/go-guess` deploys the API image, the web image, and
|
||||
their PostgreSQL and RabbitMQ. Every tenant gets its own HTTPS host at
|
||||
`https://<tenant>.guess.urpi.be` in production and
|
||||
`https://<tenant>.guess-dev.urpi.be` in the development profile. The chart values are documented in
|
||||
[deploy/helm/go-guess/README.md](deploy/helm/go-guess/README.md).
|
||||
The chart under `deploy/helm/go-guess` deploys the application, PostgreSQL, and
|
||||
RabbitMQ. It works with any Kubernetes cluster managed by Rancher; Rancher's free
|
||||
edition does not restrict Helm or GitHub Actions deployments.
|
||||
|
||||
One web deployment serves every tenant host because the API resolves the tenant
|
||||
from the request `Host` header, so a new tenant is one entry in `tenants`.
|
||||
|
||||
Install the cluster prerequisites once per cluster. Storage for persistent
|
||||
volumes, an ingress-nginx controller for the `nginx` ingress class, and
|
||||
cert-manager for the chart-issued certificate:
|
||||
|
||||
```bash
|
||||
make cluster-prepare
|
||||
```
|
||||
|
||||
Then validate or deploy one of the environment profiles:
|
||||
Validate or deploy one of the environment profiles:
|
||||
|
||||
```bash
|
||||
make helm-lint
|
||||
|
|
@ -258,41 +193,25 @@ make helm-deploy-development
|
|||
make helm-deploy-production
|
||||
```
|
||||
|
||||
Development uses `latest` images, development fixtures, and ephemeral database
|
||||
and broker storage in namespace `go-insane-development`. Production uses the
|
||||
released `0.0.1` tags, production fixtures, and persistent volumes in namespace
|
||||
`go-insane-production`.
|
||||
For the repository's single-node Docker Rancher setup, run
|
||||
`make rancher-storage` once before the first production deployment. This
|
||||
installs the default persistent StorageClass documented in
|
||||
`rancher-install.txt`.
|
||||
|
||||
Certificates are self-signed and issued by the chart, so every browser or client
|
||||
that opens a tenant host needs the CA from the release:
|
||||
|
||||
```bash
|
||||
kubectl -n go-insane-production get secret go-guess-web-ca \
|
||||
-o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt
|
||||
```
|
||||
|
||||
Point each tenant host at the ingress controller address, then open
|
||||
`https://nmbs.guess.urpi.be` and `https://ypto.guess.urpi.be`. Browser sign-in
|
||||
through Go Loose needs the per-tenant client credentials; pass them from the
|
||||
environment to `make helm-deploy-production`:
|
||||
|
||||
```bash
|
||||
export GO_LOOSE_NMBS_CLIENT_ID=... GO_LOOSE_NMBS_CLIENT_SECRET=...
|
||||
export GO_LOOSE_YPTO_CLIENT_ID=... GO_LOOSE_YPTO_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
Without credentials for a tenant, that host keeps password login. The cluster
|
||||
served by `~/.kube/config` is managed by Rancher at `rancher.urpi.be`, which is
|
||||
the management interface and not the deployment target. One.com DNS, Nginx,
|
||||
Certbot, and firewall instructions for the single-node dedicated server are in
|
||||
`deploy/nginx/README.md`.
|
||||
Development uses demo fixtures, ephemeral database and message-broker storage,
|
||||
and NodePort `30080`. Production uses production fixtures, persistent volumes,
|
||||
generated secrets that are preserved across upgrades, and larger resource
|
||||
limits. It exposes the application at NodePort `31374` for the dedicated-server
|
||||
Nginx proxy while PostgreSQL and RabbitMQ remain cluster-internal. The public
|
||||
URL is `https://goguess.urpi.be`; one.com DNS, Nginx, Certbot, firewall, and
|
||||
verification instructions are in `deploy/nginx/README.md`.
|
||||
|
||||
To use external managed services, disable the bundled StatefulSets and provide
|
||||
full connection URLs:
|
||||
|
||||
```bash
|
||||
helm upgrade --install go-guess deploy/helm/go-guess \
|
||||
--namespace go-insane-production --create-namespace \
|
||||
--namespace go-guess-production --create-namespace \
|
||||
-f deploy/helm/go-guess/values-production.yaml \
|
||||
--set postgresql.enabled=false \
|
||||
--set rabbitmq.enabled=false \
|
||||
|
|
@ -310,8 +229,8 @@ workflow succeeds. Create GitHub environments named `development` and
|
|||
- Secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and
|
||||
`GO_GUESS_JWT_SECRET`.
|
||||
- Variable `RANCHER_NAMESPACE` for the target namespace.
|
||||
- Optional secrets `GO_LOOSE_NMBS_CLIENT_ID`, `GO_LOOSE_NMBS_CLIENT_SECRET`,
|
||||
`GO_LOOSE_YPTO_CLIENT_ID`, and `GO_LOOSE_YPTO_CLIENT_SECRET`.
|
||||
- Optional variable `GO_GUESS_FRONTEND_URL` when overriding an environment's
|
||||
configured public URL.
|
||||
|
||||
Because the configured image registry uses HTTP, every Rancher cluster node must
|
||||
trust `batty1039.startdedicated.net:5000` as an insecure registry. Use HTTPS for
|
||||
|
|
@ -320,9 +239,8 @@ the registry in production when possible.
|
|||
## Architecture
|
||||
|
||||
Requests enter the Chi router and web handlers under `backend/internal/web`.
|
||||
Tenant isolation runs first. The protected interviewer group then requires a
|
||||
bearer JWT, issued either by `POST /api/auth/login` or by exchanging a Go Loose
|
||||
browser session.
|
||||
Tenant isolation runs first. The protected interviewer group then applies Go
|
||||
Loose API-key authorization and JWT authentication.
|
||||
Web request/response models are translated to service models before business logic
|
||||
runs. Services own validation, CV trait extraction, and candidate scoring. The
|
||||
repository maps service models to GORM entities and persists them in PostgreSQL.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ module github.com/danyel/go-guess/backend
|
|||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd
|
||||
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK
|
|||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd h1:YCvHmqMNr0cfEqdAzRyVe4MOn+yYyMh8/FLSMydvYQM=
|
||||
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
||||
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14 h1:kX0zybG4E3VLB4QpOsyJEBu4HoXx33CY2x8rtACHRRM=
|
||||
github.com/danyel/go-loose v0.0.0-20261002194542-c2091e746a14/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -31,27 +30,6 @@ type GoLooseTenant struct {
|
|||
ClientSecret string
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultAuthDomain is the Go Loose identity domain used for local
|
||||
// development. Production signs in through DefaultProdAuthDomain.
|
||||
DefaultAuthDomain = "auth-dev.urpi.be"
|
||||
// DefaultProdAuthDomain is the Go Loose identity domain used in production.
|
||||
DefaultProdAuthDomain = "auth.urpi.be"
|
||||
// DefaultAppDomain is the public application domain used for local
|
||||
// development, matching deploy/helm/go-guess/values-dev.yaml.
|
||||
DefaultAppDomain = "guess-dev.urpi.be"
|
||||
// DefaultProdAppDomain is the public application domain used in production,
|
||||
// matching deploy/helm/go-guess/values.yaml.
|
||||
DefaultProdAppDomain = "guess.urpi.be"
|
||||
)
|
||||
|
||||
// GoLooseCredentialEnv returns the environment variable names holding the Go
|
||||
// Loose client credentials of a tenant.
|
||||
func GoLooseCredentialEnv(slug string) (idEnv, secretEnv string) {
|
||||
prefix := "GO_LOOSE_" + strings.ToUpper(slug)
|
||||
return prefix + "_CLIENT_ID", prefix + "_CLIENT_SECRET"
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h"))
|
||||
if err != nil {
|
||||
|
|
@ -77,30 +55,22 @@ func Load() (Config, error) {
|
|||
return cfg, nil
|
||||
}
|
||||
|
||||
// loadGoLoose reads the Go Loose browser login configuration. Both domains are
|
||||
// single host suffixes: a tenant named "nmbs" signs in through
|
||||
// https://nmbs.<AuthDomain> and is served from https://nmbs.<AppDomain>.
|
||||
//
|
||||
// The defaults are the development domains, matching the rest of the local
|
||||
// defaults in Load. Production deployments set GO_LOOSE_AUTH_DOMAIN and
|
||||
// GO_LOOSE_APP_DOMAIN explicitly (see deploy/helm/go-guess/values.yaml).
|
||||
func loadGoLoose() GoLooseConfig {
|
||||
cfg := GoLooseConfig{
|
||||
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", DefaultAuthDomain),
|
||||
AppDomain: env("GO_LOOSE_APP_DOMAIN", DefaultAppDomain),
|
||||
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
|
||||
AppDomain: env("GO_LOOSE_APP_DOMAIN", "guess.dev"),
|
||||
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
|
||||
Tenants: map[string]GoLooseTenant{},
|
||||
}
|
||||
add := func(slug string) {
|
||||
idEnv, secretEnv := GoLooseCredentialEnv(slug)
|
||||
add := func(slug, idEnv, secretEnv string) {
|
||||
id, secret := os.Getenv(idEnv), os.Getenv(secretEnv)
|
||||
if id == "" || secret == "" {
|
||||
return
|
||||
}
|
||||
cfg.Tenants[slug] = GoLooseTenant{ClientID: id, ClientSecret: secret}
|
||||
}
|
||||
add("nmbs")
|
||||
add("ypto")
|
||||
add("nmbs", "GO_LOOSE_NMBS_CLIENT_ID", "GO_LOOSE_NMBS_CLIENT_SECRET")
|
||||
add("ypto", "GO_LOOSE_YPTO_CLIENT_ID", "GO_LOOSE_YPTO_CLIENT_SECRET")
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,16 @@ type Registry struct {
|
|||
|
||||
func New(cfg config.GoLooseConfig) (*Registry, error) {
|
||||
registry := &Registry{
|
||||
appDomain: domain(cfg.AppDomain),
|
||||
appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."),
|
||||
bySlug: map[string]*goloose.BrowserAuth{},
|
||||
}
|
||||
if registry.appDomain == "" {
|
||||
registry.appDomain = "guess.dev"
|
||||
}
|
||||
if len(cfg.Tenants) == 0 {
|
||||
return registry, nil
|
||||
}
|
||||
authDomain := domain(cfg.AuthDomain)
|
||||
authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".")
|
||||
if authDomain == "" {
|
||||
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
|
||||
}
|
||||
|
|
@ -39,7 +42,7 @@ func New(cfg config.GoLooseConfig) (*Registry, error) {
|
|||
return nil, err
|
||||
}
|
||||
for slug, tenant := range cfg.Tenants {
|
||||
slug = domain(slug)
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
if slug == "" || strings.Contains(slug, ".") {
|
||||
return nil, fmt.Errorf("invalid Go Loose tenant slug %q", slug)
|
||||
}
|
||||
|
|
@ -65,13 +68,11 @@ func New(cfg config.GoLooseConfig) (*Registry, error) {
|
|||
|
||||
func (r *Registry) AppDomain() string {
|
||||
if r == nil || r.appDomain == "" {
|
||||
return config.DefaultAppDomain
|
||||
return "guess.dev"
|
||||
}
|
||||
return r.appDomain
|
||||
}
|
||||
|
||||
// ForHost resolves the Go Loose client of the tenant that owns host. Ingress
|
||||
// proxies may replace the request Host, so X-Forwarded-Host wins when present.
|
||||
func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
|
||||
if r == nil {
|
||||
return nil, "", false
|
||||
|
|
@ -81,21 +82,16 @@ func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
|
|||
return auth, slug, ok
|
||||
}
|
||||
|
||||
// Host returns the host that identifies the tenant of a request, preferring the
|
||||
// proxy-supplied X-Forwarded-Host over the request Host.
|
||||
func Host(r *http.Request) string {
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwarded != "" {
|
||||
return forwarded
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func TenantFromHost(host, appDomain string) string {
|
||||
hostname := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
hostname := strings.ToLower(host)
|
||||
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
||||
hostname = strings.ToLower(strings.TrimSuffix(parsed, "."))
|
||||
hostname = strings.ToLower(parsed)
|
||||
}
|
||||
suffix := "." + domain(appDomain)
|
||||
appDomain = strings.TrimPrefix(strings.ToLower(appDomain), ".")
|
||||
if appDomain == "" {
|
||||
appDomain = "guess.dev"
|
||||
}
|
||||
suffix := "." + appDomain
|
||||
if !strings.HasSuffix(hostname, suffix) {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -106,11 +102,6 @@ func TenantFromHost(host, appDomain string) string {
|
|||
return slug
|
||||
}
|
||||
|
||||
// domain normalizes a configured host suffix or tenant slug.
|
||||
func domain(value string) string {
|
||||
return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".")
|
||||
}
|
||||
|
||||
func client(caFile string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if caFile != "" {
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
package golooseauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/config"
|
||||
)
|
||||
|
||||
func tenants() map[string]config.GoLooseTenant {
|
||||
return map[string]config.GoLooseTenant{
|
||||
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
|
||||
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUsesDevelopmentDomainsByDefault(t *testing.T) {
|
||||
registry, err := New(config.GoLooseConfig{AuthDomain: config.DefaultAuthDomain, AppDomain: config.DefaultAppDomain, Tenants: tenants()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registry.AppDomain() != "guess-dev.urpi.be" {
|
||||
t.Fatalf("app domain = %q", registry.AppDomain())
|
||||
}
|
||||
location := loginRedirect(t, registry, "nmbs.guess-dev.urpi.be")
|
||||
if !strings.HasPrefix(location, "https://nmbs.auth-dev.urpi.be/connect/authorize?") {
|
||||
t.Fatalf("development login redirect = %q", location)
|
||||
}
|
||||
if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess-dev.urpi.be%2Fapi%2Fauth%2Fcallback") {
|
||||
t.Fatalf("development callback missing from %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUsesProductionDomains(t *testing.T) {
|
||||
registry, err := New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultProdAuthDomain,
|
||||
AppDomain: config.DefaultProdAppDomain,
|
||||
Tenants: tenants(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
location := loginRedirect(t, registry, "nmbs.guess.urpi.be")
|
||||
if !strings.HasPrefix(location, "https://nmbs.auth.urpi.be/connect/authorize?") {
|
||||
t.Fatalf("production login redirect = %q", location)
|
||||
}
|
||||
if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess.urpi.be%2Fapi%2Fauth%2Fcallback") {
|
||||
t.Fatalf("production callback missing from %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresAnAuthDomainWithCredentials(t *testing.T) {
|
||||
if _, err := New(config.GoLooseConfig{Tenants: tenants()}); err == nil {
|
||||
t.Fatal("expected an error when GO_LOOSE_AUTH_DOMAIN is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNormalizesConfiguredDomains(t *testing.T) {
|
||||
registry, err := New(config.GoLooseConfig{
|
||||
AuthDomain: " .Auth-Dev.Urpi.Be. ",
|
||||
AppDomain: ".Guess-Dev.Urpi.Be.",
|
||||
Tenants: map[string]config.GoLooseTenant{"NMBS": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registry.AppDomain() != "guess-dev.urpi.be" {
|
||||
t.Fatalf("app domain = %q", registry.AppDomain())
|
||||
}
|
||||
if location := loginRedirect(t, registry, "nmbs.guess-dev.urpi.be"); !strings.HasPrefix(location, "https://nmbs.auth-dev.urpi.be/connect/authorize?") {
|
||||
t.Fatalf("normalized login redirect = %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsMultiLabelTenantSlug(t *testing.T) {
|
||||
_, err := New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultAuthDomain,
|
||||
AppDomain: config.DefaultAppDomain,
|
||||
Tenants: map[string]config.GoLooseTenant{"nmbs.eu": {ClientID: "id", ClientSecret: "secret"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a tenant slug containing a dot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDomainFallsBackToTheDevelopmentDomain(t *testing.T) {
|
||||
var missing *Registry
|
||||
if missing.AppDomain() != config.DefaultAppDomain {
|
||||
t.Fatalf("nil registry app domain = %q", missing.AppDomain())
|
||||
}
|
||||
empty, err := New(config.GoLooseConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if empty.AppDomain() != config.DefaultAppDomain {
|
||||
t.Fatalf("empty registry app domain = %q", empty.AppDomain())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForHostResolvesTheTenantOfTheHost(t *testing.T) {
|
||||
registry, err := New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultAuthDomain,
|
||||
AppDomain: config.DefaultAppDomain,
|
||||
Tenants: tenants(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
host string
|
||||
slug string
|
||||
ok bool
|
||||
}{
|
||||
{host: "nmbs.guess-dev.urpi.be", slug: "nmbs", ok: true},
|
||||
{host: "ypto.guess-dev.urpi.be", slug: "ypto", ok: true},
|
||||
{host: "NMBS.Guess-Dev.Urpi.Be:8443", slug: "nmbs", ok: true},
|
||||
// The apex host identifies no tenant.
|
||||
{host: "guess-dev.urpi.be", slug: "", ok: false},
|
||||
// Another environment's domain has no configured Go Loose client here.
|
||||
{host: "nmbs.guess.urpi.be", slug: "", ok: false},
|
||||
{host: "guess.urpi.be", slug: "", ok: false},
|
||||
{host: "localhost:5173", slug: "", ok: false},
|
||||
{host: "", slug: "", ok: false},
|
||||
} {
|
||||
auth, slug, ok := registry.ForHost(tc.host)
|
||||
if slug != tc.slug || ok != tc.ok {
|
||||
t.Fatalf("ForHost(%q) = %q, %v, want %q, %v", tc.host, slug, ok, tc.slug, tc.ok)
|
||||
}
|
||||
if ok && auth == nil {
|
||||
t.Fatalf("ForHost(%q) returned no client", tc.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostPrefersTheForwardedHost(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
request.Host = "api:8080"
|
||||
if Host(request) != "api:8080" {
|
||||
t.Fatalf("host = %q", Host(request))
|
||||
}
|
||||
request.Header.Set("X-Forwarded-Host", "nmbs.guess-dev.urpi.be")
|
||||
if Host(request) != "nmbs.guess-dev.urpi.be" {
|
||||
t.Fatalf("forwarded host = %q", Host(request))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantFromHost(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
host string
|
||||
want string
|
||||
}{
|
||||
{host: "nmbs.guess-dev.urpi.be", want: "nmbs"},
|
||||
{host: "ypto.guess-dev.urpi.be:443", want: "ypto"},
|
||||
{host: "nmbs.guess-dev.urpi.be.", want: "nmbs"},
|
||||
{host: "deep.nmbs.guess-dev.urpi.be", want: ""},
|
||||
{host: "guess-dev.urpi.be", want: ""},
|
||||
{host: "nmbs.evil.test", want: ""},
|
||||
} {
|
||||
if got := TenantFromHost(tc.host, config.DefaultAppDomain); got != tc.want {
|
||||
t.Fatalf("TenantFromHost(%q) = %q, want %q", tc.host, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loginRedirect(t *testing.T, registry *Registry, host string) string {
|
||||
t.Helper()
|
||||
auth, _, ok := registry.ForHost(host)
|
||||
if !ok {
|
||||
t.Fatalf("no Go Loose client for %q", host)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
request.Host = host
|
||||
response := httptest.NewRecorder()
|
||||
auth.LoginHandler(response, request)
|
||||
if response.Code != http.StatusFound {
|
||||
t.Fatalf("login for %q = %d, want 302", host, response.Code)
|
||||
}
|
||||
return response.Header().Get("Location")
|
||||
}
|
||||
|
|
@ -8,10 +8,6 @@ type User struct {
|
|||
PasswordHash string
|
||||
DisplayName string
|
||||
Role string
|
||||
// AvatarURL is the picture Go Loose serves for this person. It is resolved
|
||||
// from the identity provider on every sign-in rather than stored, because a
|
||||
// stored key stops resolving once the picture is replaced or removed.
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
type Trait struct {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const CandidateThreshold = 60.0
|
|||
|
||||
type IAuthService interface {
|
||||
Login(context.Context, string, string) (string, model.User, error)
|
||||
EstablishExternalSession(context.Context, ExternalIdentity) (string, model.User, error)
|
||||
EstablishExternalSession(context.Context, string, string) (string, model.User, error)
|
||||
}
|
||||
|
||||
type IJobService interface {
|
||||
|
|
@ -89,19 +89,9 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
|
|||
return token, user, nil
|
||||
}
|
||||
|
||||
// ExternalIdentity is what Go Loose reports about the person signing in. The
|
||||
// avatar URL is deliberately not persisted: Go Loose retires a picture key when
|
||||
// the picture changes, so a stored copy would eventually point at nothing. It is
|
||||
// read from the live identity on every session instead.
|
||||
type ExternalIdentity struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
func (s *AuthService) EstablishExternalSession(ctx context.Context, identity ExternalIdentity) (string, model.User, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(identity.Email))
|
||||
displayName := strings.TrimSpace(identity.DisplayName)
|
||||
func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
if email == "" {
|
||||
return "", model.User{}, ErrValidation
|
||||
}
|
||||
|
|
@ -126,7 +116,6 @@ func (s *AuthService) EstablishExternalSession(ctx context.Context, identity Ext
|
|||
return "", model.User{}, err
|
||||
}
|
||||
user.PasswordHash = ""
|
||||
user.AvatarURL = strings.TrimSpace(identity.AvatarURL)
|
||||
return token, user, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -653,10 +653,8 @@ func (h *Handler) frontendURLForRequest(r *http.Request) string {
|
|||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return h.frontendURL
|
||||
}
|
||||
// Only trust the Origin when it is the browser origin of the requesting
|
||||
// tenant, so an invitation link cannot be pointed at another host.
|
||||
if strings.EqualFold(parsed.Hostname(), tenant+"."+h.AppDomain()) ||
|
||||
strings.EqualFold(parsed.Hostname(), tenant+".guess.local") {
|
||||
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") ||
|
||||
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") {
|
||||
return origin
|
||||
}
|
||||
return h.frontendURL
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/config"
|
||||
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||
servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
|
||||
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
"github.com/danyel/go-loose/client"
|
||||
)
|
||||
|
||||
func TestFrontendURLForTenantRequest(t *testing.T) {
|
||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "http://ypto.guess.local:5173")
|
||||
request.Header.Set(goloose.XTenantId, "ypto")
|
||||
request.Header.Set(client.XTenantId, "ypto")
|
||||
|
||||
if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
|
||||
t.Fatalf("unexpected frontend URL %q", value)
|
||||
|
|
@ -27,7 +22,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
|||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "http://nmbs.guess.local:5173")
|
||||
request.Header.Set(goloose.XTenantId, "ypto")
|
||||
request.Header.Set(client.XTenantId, "ypto")
|
||||
|
||||
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
|
||||
t.Fatalf("expected configured frontend URL, got %q", value)
|
||||
|
|
@ -37,87 +32,10 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
|||
func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) {
|
||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "https://ypto."+config.DefaultAppDomain)
|
||||
request.Header.Set(goloose.XTenantId, "ypto")
|
||||
request.Header.Set("Origin", "https://ypto.guess.dev")
|
||||
request.Header.Set(client.XTenantId, "ypto")
|
||||
|
||||
if value := handler.frontendURLForRequest(request); value != "https://ypto."+config.DefaultAppDomain {
|
||||
if value := handler.frontendURLForRequest(request); value != "https://ypto.guess.dev" {
|
||||
t.Fatalf("expected tenant dev origin, got %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrontendURLAcceptsConfiguredProductionDomain(t *testing.T) {
|
||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultProdAuthDomain,
|
||||
AppDomain: config.DefaultProdAppDomain,
|
||||
Tenants: map[string]config.GoLooseTenant{
|
||||
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := &Handler{frontendURL: "https://nmbs.guess.urpi.be"}
|
||||
handler.SetBrowserAuth(browser)
|
||||
|
||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "https://nmbs."+config.DefaultProdAppDomain)
|
||||
request.Header.Set(goloose.XTenantId, "nmbs")
|
||||
if value := handler.frontendURLForRequest(request); value != "https://nmbs."+config.DefaultProdAppDomain {
|
||||
t.Fatalf("expected tenant production origin, got %q", value)
|
||||
}
|
||||
|
||||
// The development domain is not the configured app domain in production.
|
||||
request = httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||
request.Header.Set("Origin", "https://nmbs."+config.DefaultAppDomain)
|
||||
request.Header.Set(goloose.XTenantId, "nmbs")
|
||||
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
|
||||
t.Fatalf("expected configured frontend URL, got %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalIdentityCarriesThePicture pins that the picture Go Loose serves is
|
||||
// handed to the service and ends up in the session payload. The URL is resolved on
|
||||
// every sign-in rather than stored, so it must survive the whole way through
|
||||
// rather than being dropped at the first boundary.
|
||||
func TestExternalIdentityCarriesThePicture(t *testing.T) {
|
||||
identity := externalIdentity(goloose.User{
|
||||
Email: "ada@example.test",
|
||||
DisplayName: "Ada Lovelace",
|
||||
AvatarURL: "https://auth.example.test/api/v1/avatars/kZ3abc",
|
||||
Role: "developer",
|
||||
})
|
||||
if identity.Email != "ada@example.test" {
|
||||
t.Errorf("email = %q", identity.Email)
|
||||
}
|
||||
if identity.DisplayName != "Ada Lovelace" {
|
||||
t.Errorf("display name = %q", identity.DisplayName)
|
||||
}
|
||||
if identity.AvatarURL != "https://auth.example.test/api/v1/avatars/kZ3abc" {
|
||||
t.Errorf("avatar URL = %q", identity.AvatarURL)
|
||||
}
|
||||
|
||||
// A person without a picture yields an empty URL rather than a broken one, so
|
||||
// the frontend can fall back to initials.
|
||||
without := externalIdentity(goloose.User{Email: "bob@example.test"})
|
||||
if without.AvatarURL != "" {
|
||||
t.Errorf("avatar URL = %q, want empty", without.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserToWebIncludesTheAvatar pins the JSON field name the frontend reads.
|
||||
func TestUserToWebIncludesTheAvatar(t *testing.T) {
|
||||
encoded, err := json.Marshal(webmapper.UserToWeb(servicemodel.User{
|
||||
ID: 7, Email: "ada@example.test", DisplayName: "Ada", Role: "interviewer",
|
||||
AvatarURL: "https://auth.example.test/api/v1/avatars/kZ3abc",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(encoded, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["avatarUrl"] != "https://auth.example.test/api/v1/avatars/kZ3abc" {
|
||||
t.Errorf("avatarUrl = %v, payload %s", payload["avatarUrl"], encoded)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ package handler
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/config"
|
||||
"github.com/danyel/go-guess/backend/internal/golooseauth"
|
||||
"github.com/danyel/go-guess/backend/internal/service"
|
||||
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
||||
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||
)
|
||||
|
|
@ -17,8 +14,6 @@ import (
|
|||
var (
|
||||
errMissingSession = errors.New("missing Go Loose session")
|
||||
errTenantMismatch = errors.New("Go Loose session does not match this tenant")
|
||||
errLoginDisabled = errors.New("Go Loose browser login is not enabled")
|
||||
errNoTenantHost = errors.New("this host does not identify a configured tenant")
|
||||
)
|
||||
|
||||
func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
|
||||
|
|
@ -27,33 +22,33 @@ func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
|
|||
|
||||
func (h *Handler) AppDomain() string {
|
||||
if h.browser == nil {
|
||||
return config.DefaultAppDomain
|
||||
return "guess.dev"
|
||||
}
|
||||
return h.browser.AppDomain()
|
||||
}
|
||||
|
||||
func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||
auth, ok := h.externalAuth(r)
|
||||
if !ok {
|
||||
writeExternalAuthError(w, h, slug)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth.LoginHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||
auth, ok := h.externalAuth(r)
|
||||
if !ok {
|
||||
writeExternalAuthError(w, h, slug)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth.CallbackHandler(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
|
||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||
auth, ok := h.externalAuth(r)
|
||||
if !ok {
|
||||
writeExternalAuthError(w, h, slug)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth.LogoutHandler(w, r)
|
||||
|
|
@ -62,7 +57,7 @@ func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||
if !ok {
|
||||
writeExternalAuthError(w, h, slug)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := r.Cookie(golooseauth.SessionCookie); err != nil {
|
||||
|
|
@ -75,7 +70,7 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusForbidden, errTenantMismatch)
|
||||
return
|
||||
}
|
||||
token, local, err := h.auth.EstablishExternalSession(r.Context(), externalIdentity(user))
|
||||
token, local, err := h.auth.EstablishExternalSession(r.Context(), user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
|
|
@ -84,17 +79,6 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
|||
})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// externalIdentity is what Go Loose reports about the person signing in. The
|
||||
// picture URL is carried through rather than looked up: Go Loose serves it
|
||||
// publicly, so the browser can render it without another round trip.
|
||||
func externalIdentity(user goloose.User) service.ExternalIdentity {
|
||||
return service.ExternalIdentity{
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
AvatarURL: user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) {
|
||||
auth, _, ok := h.externalAuthWithSlug(r)
|
||||
return auth, ok
|
||||
|
|
@ -104,19 +88,5 @@ func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, s
|
|||
if h.browser == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return h.browser.ForHost(golooseauth.Host(r))
|
||||
}
|
||||
|
||||
func writeExternalAuthError(w http.ResponseWriter, h *Handler, slug string) {
|
||||
if h.browser == nil {
|
||||
writeError(w, http.StatusNotFound, errLoginDisabled)
|
||||
return
|
||||
}
|
||||
if slug == "" {
|
||||
writeError(w, http.StatusNotFound, errNoTenantHost)
|
||||
return
|
||||
}
|
||||
idEnv, secretEnv := config.GoLooseCredentialEnv(slug)
|
||||
writeError(w, http.StatusServiceUnavailable,
|
||||
fmt.Errorf("Go Loose login is not configured for tenant %q: set %s and %s, then restart the API", slug, idEnv, secretEnv))
|
||||
return h.browser.ForHost(r.Host)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
|
|||
func UserToWeb(value servicemodel.User) webmodel.User {
|
||||
return webmodel.User{
|
||||
ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role,
|
||||
AvatarURL: value.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,18 +11,11 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/config"
|
||||
"github.com/danyel/go-loose/client"
|
||||
)
|
||||
|
||||
const guessApplication = "guess"
|
||||
|
||||
// defaultBaseURLPattern is the Go Loose identity domain used when
|
||||
// GO_LOOSE_BASE_URL is unset.
|
||||
func defaultBaseURLPattern() string {
|
||||
return "https://%s." + config.DefaultAuthDomain
|
||||
}
|
||||
|
||||
// A tenant used in the Go Loose host must be one DNS label.
|
||||
var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`)
|
||||
|
||||
|
|
@ -30,12 +23,11 @@ var safeTenantLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62})?$`
|
|||
// from the X-Tenant-Id header, not from process configuration.
|
||||
//
|
||||
// GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The
|
||||
// default is the development identity domain, https://%s.auth-dev.urpi.be.
|
||||
// A URL without %s is used as-is.
|
||||
// default is https://%s.auth.dev. A URL without %s is used as-is.
|
||||
func GoLooseMiddleware() func(http.Handler) http.Handler {
|
||||
pattern := os.Getenv("GO_LOOSE_BASE_URL")
|
||||
if pattern == "" {
|
||||
pattern = defaultBaseURLPattern()
|
||||
pattern = "https://%s.auth.dev"
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 2 * time.Second}
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-guess/backend/internal/config"
|
||||
"github.com/danyel/go-loose/client"
|
||||
)
|
||||
|
||||
|
|
@ -80,22 +79,8 @@ func TestGoLooseMiddlewareRejectsMissingTenantAndKey(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGoLooseMiddlewareDefaultsToTheDevelopmentIdentityDomain(t *testing.T) {
|
||||
t.Setenv("GO_LOOSE_BASE_URL", "")
|
||||
baseURL, err := goLooseBaseURL(defaultBaseURLPattern(), "nmbs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if baseURL != "https://nmbs."+config.DefaultAuthDomain {
|
||||
t.Fatalf("default base URL = %q", baseURL)
|
||||
}
|
||||
if defaultBaseURLPattern() != "https://%s.auth-dev.urpi.be" {
|
||||
t.Fatalf("default pattern = %q", defaultBaseURLPattern())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoLooseMiddlewareRejectsUnsafeTenantInHost(t *testing.T) {
|
||||
t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth-dev.urpi.be")
|
||||
t.Setenv("GO_LOOSE_BASE_URL", "https://%s.auth.dev")
|
||||
handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler reached for unsafe tenant")
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func TenantSchemaMiddleware(db *gorm.DB, appDomain string) func(http.Handler) ht
|
|||
}
|
||||
tenantID := r.Header.Get(client.XTenantId)
|
||||
if tenantID == "" {
|
||||
tenantID = golooseauth.TenantFromHost(golooseauth.Host(r), appDomain)
|
||||
tenantID = golooseauth.TenantFromHost(r.Host, appDomain)
|
||||
}
|
||||
if tenantID == "" || db == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ type User struct {
|
|||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
// AvatarURL is an absolute, publicly fetchable picture URL served by Go Loose,
|
||||
// or an empty string when the person has not uploaded one.
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
|||
router := chi.NewRouter()
|
||||
spec := openapi.New()
|
||||
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
|
||||
router.Use(cors(frontendURL, h.AppDomain()))
|
||||
router.Use(cors(frontendURL))
|
||||
router.Use(mw.TenantSchemaMiddleware(h.Db(), h.AppDomain()))
|
||||
router.Use(middleware.Timeout(2 * time.Minute))
|
||||
register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP,
|
||||
|
|
@ -164,11 +164,11 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
|
|||
}
|
||||
}
|
||||
|
||||
func cors(origin, appDomain string) func(http.Handler) http.Handler {
|
||||
func cors(origin string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestOrigin := r.Header.Get("Origin")
|
||||
if allowedCORSOrigin(requestOrigin, origin, appDomain) {
|
||||
if allowedCORSOrigin(requestOrigin, origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", requestOrigin)
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id")
|
||||
|
|
@ -183,11 +183,7 @@ func cors(origin, appDomain string) func(http.Handler) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
// allowedCORSOrigin accepts the configured frontend origin plus any tenant host
|
||||
// of the configured application domain, which is what the Vite dev server on
|
||||
// http://<tenant>.<appDomain>:5173 sends. *.guess.local stays allowed for the
|
||||
// offline BDD and local password-only setups.
|
||||
func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool {
|
||||
func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool {
|
||||
if requestOrigin == "" {
|
||||
return false
|
||||
}
|
||||
|
|
@ -198,11 +194,5 @@ func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool {
|
|||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return false
|
||||
}
|
||||
hostname := strings.ToLower(parsed.Hostname())
|
||||
for _, suffix := range []string{"." + appDomain, ".guess.local"} {
|
||||
if suffix != "." && strings.HasSuffix(hostname, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".guess.local")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ func (authStub) Login(context.Context, string, string) (string, model.User, erro
|
|||
return "", model.User{}, service.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
func (authStub) EstablishExternalSession(context.Context, service.ExternalIdentity) (string, model.User, error) {
|
||||
func (authStub) EstablishExternalSession(context.Context, string, string) (string, model.User, error) {
|
||||
return "", model.User{}, service.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
|
|
@ -313,8 +313,8 @@ func TestOpenAPIDoesNotRequireTenant(t *testing.T) {
|
|||
func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
|
||||
h := newTestHandler(t, nil)
|
||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultAuthDomain,
|
||||
AppDomain: config.DefaultAppDomain,
|
||||
AuthDomain: "auth.dev",
|
||||
AppDomain: "guess.dev",
|
||||
Tenants: map[string]config.GoLooseTenant{
|
||||
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
|
||||
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
|
||||
|
|
@ -328,184 +328,19 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
|
|||
|
||||
for _, slug := range []string{"nmbs", "ypto"} {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
request.Host = slug + ".guess-dev.urpi.be"
|
||||
request.Host = slug + ".guess.dev"
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
location := response.Header().Get("Location")
|
||||
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth-dev.urpi.be/connect/authorize?") {
|
||||
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://"+slug+".auth.dev/connect/authorize?") {
|
||||
t.Fatalf("%s login redirect = %d %s", slug, response.Code, location)
|
||||
}
|
||||
if !strings.Contains(location, "redirect_uri=https%3A%2F%2F"+slug+".guess-dev.urpi.be%2Fapi%2Fauth%2Fcallback") {
|
||||
if !strings.Contains(location, "redirect_uri=https%3A%2F%2F"+slug+".guess.dev%2Fapi%2Fauth%2Fcallback") {
|
||||
t.Fatalf("%s callback missing from %s", slug, location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHostLoginExplainsUnconfiguredGoLooseTenant(t *testing.T) {
|
||||
h := newTestHandler(t, nil)
|
||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultAuthDomain,
|
||||
AppDomain: config.DefaultAppDomain,
|
||||
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.SetBrowserAuth(browser)
|
||||
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
request.Host = "ypto.guess-dev.urpi.be"
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable ||
|
||||
!strings.Contains(response.Body.String(), "GO_LOOSE_YPTO_CLIENT_ID") {
|
||||
t.Fatalf("ypto login = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
request.Host = "guess-dev.urpi.be"
|
||||
response = httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "tenant") {
|
||||
t.Fatalf("apex login = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithoutGoLooseRegistryIsNotFound(t *testing.T) {
|
||||
api := New(
|
||||
newTestHandler(t, nil),
|
||||
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
|
||||
"http://localhost:5173",
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
request.Host = "nmbs.guess-dev.urpi.be"
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") {
|
||||
t.Fatalf("login without registry = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The frontend falls back to password sign-in only when it can tell that Go
|
||||
// Loose login is unavailable, so a host without a registry must answer 404
|
||||
// rather than 401 even when the request carries no session cookie.
|
||||
func TestSessionWithoutGoLooseRegistryIsNotFound(t *testing.T) {
|
||||
api := New(
|
||||
newTestHandler(t, nil),
|
||||
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
|
||||
"http://localhost:5173",
|
||||
)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
request.Host = "nmbs.guess-dev.urpi.be"
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "not enabled") {
|
||||
t.Fatalf("session without registry = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionOnAnUnconfiguredTenantHostIsNotFound(t *testing.T) {
|
||||
h := newTestHandler(t, nil)
|
||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultAuthDomain,
|
||||
AppDomain: config.DefaultAppDomain,
|
||||
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.SetBrowserAuth(browser)
|
||||
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
request.Host = "ypto.guess-dev.urpi.be"
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable ||
|
||||
!strings.Contains(response.Body.String(), "GO_LOOSE_YPTO_CLIENT_ID") {
|
||||
t.Fatalf("unconfigured tenant session = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionWithoutCookieIsUnauthorizedOnATenantHost(t *testing.T) {
|
||||
h := newTestHandler(t, nil)
|
||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultAuthDomain,
|
||||
AppDomain: config.DefaultAppDomain,
|
||||
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.SetBrowserAuth(browser)
|
||||
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
|
||||
request.Host = "nmbs.guess-dev.urpi.be"
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized || !strings.Contains(response.Body.String(), "missing Go Loose session") {
|
||||
t.Fatalf("session without cookie = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An ingress proxy may replace the request Host, so the tenant has to be read
|
||||
// from X-Forwarded-Host when it is present.
|
||||
func TestTenantHostLoginHonorsTheForwardedHost(t *testing.T) {
|
||||
h := newTestHandler(t, nil)
|
||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||
AuthDomain: config.DefaultProdAuthDomain,
|
||||
AppDomain: config.DefaultProdAppDomain,
|
||||
Tenants: map[string]config.GoLooseTenant{"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.SetBrowserAuth(browser)
|
||||
api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "https://nmbs.guess.urpi.be")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||
request.Host = "go-guess-api.go-insane-production.svc.cluster.local:8080"
|
||||
request.Header.Set("X-Forwarded-Host", "nmbs.guess.urpi.be")
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
|
||||
location := response.Header().Get("Location")
|
||||
if response.Code != http.StatusFound || !strings.HasPrefix(location, "https://nmbs.auth.urpi.be/connect/authorize?") {
|
||||
t.Fatalf("forwarded host login redirect = %d %s", response.Code, location)
|
||||
}
|
||||
if !strings.Contains(location, "redirect_uri=https%3A%2F%2Fnmbs.guess.urpi.be%2Fapi%2Fauth%2Fcallback") {
|
||||
t.Fatalf("forwarded host callback missing from %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSAllowsTheConfiguredTenantOrigin(t *testing.T) {
|
||||
api := New(
|
||||
newTestHandler(t, nil),
|
||||
security.NewTokenManager("01234567890123456789012345678901", time.Hour),
|
||||
"https://nmbs.guess.urpi.be",
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
origin string
|
||||
allow string
|
||||
}{
|
||||
{origin: "https://nmbs.guess.urpi.be", allow: "https://nmbs.guess.urpi.be"},
|
||||
{origin: "http://nmbs.guess-dev.urpi.be:5173", allow: "http://nmbs.guess-dev.urpi.be:5173"},
|
||||
{origin: "http://ypto.guess.local:5173", allow: "http://ypto.guess.local:5173"},
|
||||
{origin: "https://evil.test", allow: ""},
|
||||
{origin: "https://nmbs.guess.urpi.be.evil.test", allow: ""},
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodOptions, "/api/jobs", nil)
|
||||
request.Header.Set("Origin", tc.origin)
|
||||
response := httptest.NewRecorder()
|
||||
api.ServeHTTP(response, request)
|
||||
if got := response.Header().Get("Access-Control-Allow-Origin"); got != tc.allow {
|
||||
t.Fatalf("CORS allow origin for %q = %q, want %q", tc.origin, got, tc.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type stubDriver struct{}
|
||||
|
||||
func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: go-guess
|
||||
description: Go Guess recruitment workspace with per-tenant web hosts
|
||||
description: Go Guess recruitment workspace
|
||||
type: application
|
||||
version: 0.3.0
|
||||
appVersion: "0.3.0"
|
||||
version: 0.2.0
|
||||
appVersion: "0.2.0"
|
||||
|
|
|
|||
|
|
@ -1,272 +0,0 @@
|
|||
# Go Guess Helm chart
|
||||
|
||||
This chart deploys Go Guess as two images, an API and a web front end, plus its
|
||||
own PostgreSQL and RabbitMQ, and publishes one HTTPS host per tenant under
|
||||
`https://<tenant>.guess.urpi.be`.
|
||||
|
||||
## 1. What the chart creates
|
||||
|
||||
| Resource | Name | Purpose |
|
||||
|---|---|---|
|
||||
| Deployment | `go-guess-api` | Go API, runs migrations and seeds before serving |
|
||||
| Deployment | `go-guess-web` | nginx serving the built React application |
|
||||
| Service | `api` | Cluster-internal API, proxied by the web image |
|
||||
| Service | `go-guess-web` | Cluster-internal web front end, ingress backend |
|
||||
| Ingress | `go-guess` | One rule and one TLS SAN per tenant host |
|
||||
| Issuer | `go-guess-selfsigned` | cert-manager self-signed issuer |
|
||||
| Certificate | `go-guess-web-ca` | Chart CA that signs the leaf certificate |
|
||||
| Certificate | `go-guess-web` | Leaf certificate covering every tenant host |
|
||||
| StatefulSet | `go-guess-postgresql` | PostgreSQL, `emptyDir` or persistent volume |
|
||||
| StatefulSet | `go-guess-rabbitmq` | RabbitMQ, `emptyDir` or persistent volume |
|
||||
| Secret | `go-guess-secrets` | JWT secret, connection URLs, broker credentials |
|
||||
|
||||
A single web deployment serves every tenant host. The API resolves the tenant
|
||||
from the request `Host` header, so adding a tenant means adding one entry to
|
||||
`tenants`, not deploying another stack.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
- A Kubernetes cluster reachable through `KUBECONFIG`.
|
||||
- A `StorageClass` when `postgresql.persistence.enabled` or
|
||||
`rabbitmq.persistence.enabled` is `true`.
|
||||
- ingress-nginx with the `nginx` ingress class: `make ingress-nginx-install`.
|
||||
- cert-manager when `ingress.tls.enabled` is `true`: `make cert-manager-install`.
|
||||
- Cluster nodes trusting `batty1039.startdedicated.net:5000` as an insecure
|
||||
registry, or an HTTPS registry.
|
||||
|
||||
Run everything the chart depends on in one step:
|
||||
|
||||
```bash
|
||||
make cluster-prepare
|
||||
```
|
||||
|
||||
## 3. Deploy
|
||||
|
||||
```bash
|
||||
make helm-lint
|
||||
make helm-deploy-development
|
||||
```
|
||||
|
||||
The development profile uses `latest` images, development fixtures, ephemeral
|
||||
storage, and namespace `go-insane-development`. Production uses released tags,
|
||||
production fixtures, persistent volumes, and namespace `go-insane-production`:
|
||||
|
||||
```bash
|
||||
make helm-deploy-production
|
||||
```
|
||||
|
||||
Both targets accept per-tenant Go Loose credentials from the environment:
|
||||
|
||||
```bash
|
||||
export GO_LOOSE_NMBS_CLIENT_ID=...
|
||||
export GO_LOOSE_NMBS_CLIENT_SECRET=...
|
||||
export GO_LOOSE_YPTO_CLIENT_ID=...
|
||||
export GO_LOOSE_YPTO_CLIENT_SECRET=...
|
||||
make helm-deploy-production
|
||||
```
|
||||
|
||||
After a successful install the release notes list every tenant URL and the
|
||||
command that exports the CA certificate. With the local platform, prefer
|
||||
`docs/KUBERNETES-DEPLOYMENTS.md` in the infrastructure repository, which covers
|
||||
`/etc/hosts` entries and CA trust for the whole platform.
|
||||
|
||||
## 4. Tenants and hosts
|
||||
|
||||
`tenants` is the list of slugs and `domain` is the shared parent domain:
|
||||
|
||||
```yaml
|
||||
domain: guess.urpi.be
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
```
|
||||
|
||||
The chart derives, without any extra configuration:
|
||||
|
||||
- `GO_LOOSE_APP_DOMAIN=<domain>`, so the API accepts `nmbs.<domain>` and
|
||||
`ypto.<domain>` as tenant hosts.
|
||||
- One ingress rule per tenant, all pointing at `go-guess-web`.
|
||||
- One certificate SAN per tenant, so a single secret covers every host.
|
||||
- `FRONTEND_URL`, when `api.frontendURL` is empty, as
|
||||
`https://<first tenant>.<domain>`. This is the fallback used to build
|
||||
invitation and meeting links, so the first entry is the tenant that receives
|
||||
shareable links.
|
||||
|
||||
A slug must be a single DNS label. The API rejects slugs containing a dot, and
|
||||
the certificate would not match a two-level host.
|
||||
|
||||
Adding a tenant therefore requires three things: the slug in `tenants`, a DNS
|
||||
record pointing at the ingress controller, and a Go Loose client when browser
|
||||
sign-in is used.
|
||||
|
||||
## 5. Values
|
||||
|
||||
### Top level
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `nameOverride` | `""` | Overrides the chart name in resource names and labels |
|
||||
| `fullnameOverride` | `""` | Replaces the generated resource name prefix |
|
||||
| `domain` | `guess.urpi.be` | Parent domain of every tenant host. `guess-dev.urpi.be` in the development profile |
|
||||
| `tenants` | `[nmbs, ypto]` | Tenant slugs served by the release |
|
||||
| `imagePullSecrets` | `[]` | Pull secrets added to both pods |
|
||||
|
||||
### api
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `api.replicaCount` | `1` | API replicas, keep at `1` while migrations run on startup |
|
||||
| `api.image.repository` | `batty1039.startdedicated.net:5000/go-guess-api` | API image |
|
||||
| `api.image.tag` | `0.0.1` | API image tag |
|
||||
| `api.image.pullPolicy` | `IfNotPresent` | API pull policy |
|
||||
| `api.environment` | `development` | `APP_ENV`, `production` seeds production fixtures only |
|
||||
| `api.frontendURL` | `""` | Fallback public URL for invitation and meeting links |
|
||||
| `api.tokenTTL` | `8h` | `TOKEN_TTL` for issued API tokens |
|
||||
| `api.maxUploadMB` | `10` | `MAX_UPLOAD_MB` upload limit, match `proxy-body-size` |
|
||||
| `api.service.name` | `api` | Service DNS name, fixed by the web image |
|
||||
| `api.service.type` | `ClusterIP` | API service type |
|
||||
| `api.service.port` | `8080` | API service port |
|
||||
| `api.goLoose.enabled` | `true` | Enables Go Loose browser sign-in |
|
||||
| `api.goLoose.authDomain` | `auth.urpi.be` | Tenant login host `https://<tenant>.<authDomain>`. `auth-dev.urpi.be` in the development profile |
|
||||
| `api.goLoose.ca.existingSecret` | `""` | Secret holding the Go Loose CA bundle |
|
||||
| `api.goLoose.ca.key` | `ca.crt` | Key projected from that secret |
|
||||
| `api.goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target |
|
||||
| `api.goLoose.tenants.<slug>.clientID` | `""` | Go Loose client ID for that tenant |
|
||||
| `api.goLoose.tenants.<slug>.clientSecret` | `""` | Go Loose client secret for that tenant |
|
||||
| `api.resources` | `50m`/`128Mi` to `500m`/`512Mi` | API requests and limits |
|
||||
| `api.podAnnotations`, `api.podLabels` | `{}` | Extra pod metadata |
|
||||
| `api.podSecurityContext` | `{}` | Pod security context |
|
||||
| `api.securityContext` | non-root, read-only root filesystem | Container security context |
|
||||
| `api.strategy.type` | `Recreate` | Old pod exits before migrations run again |
|
||||
|
||||
### web
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `web.replicaCount` | `2` | nginx replicas, safe to scale because nginx is stateless |
|
||||
| `web.image.repository` | `batty1039.startdedicated.net:5000/go-guess-web` | Web image |
|
||||
| `web.image.tag` | `0.0.1` | Web image tag |
|
||||
| `web.image.pullPolicy` | `IfNotPresent` | Web pull policy |
|
||||
| `web.service.type` | `ClusterIP` | Web service type |
|
||||
| `web.service.port` | `80` | Web service port |
|
||||
| `web.service.nodePort` | `null` | Node port when the type is `NodePort` |
|
||||
| `web.resources` | `25m`/`32Mi` to `300m`/`128Mi` | Web requests and limits |
|
||||
| `web.podAnnotations`, `web.podLabels` | `{}` | Extra pod metadata |
|
||||
| `web.podSecurityContext` | `{}` | Pod security context |
|
||||
| `web.securityContext` | root, `NET_BIND_SERVICE` only | Container security context |
|
||||
| `web.strategy` | rolling, `maxUnavailable: 0` | Keeps the ingress backed up during rollout |
|
||||
|
||||
Neither container has a forced uid. Both images declare their own user, the API
|
||||
as `app` and nginx as `nginx`, and `runAsNonRoot` validates it.
|
||||
|
||||
The web security context differs from the API one on purpose. nginx binds the
|
||||
privileged port `80` and forks workers as the `nginx` user, so the pod runs as
|
||||
root with `CHOWN`, `SETGID`, `SETUID`, and `NET_BIND_SERVICE`, and mounts
|
||||
writable `emptyDir` volumes at `/var/cache/nginx`, `/var/run`, and `/tmp`.
|
||||
|
||||
### ingress
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `ingress.enabled` | `true` | Creates the multi-host ingress |
|
||||
| `ingress.className` | `nginx` | Ingress class of the controller |
|
||||
| `ingress.annotations` | body size `10m`, timeouts `3600`, buffering off, TLS redirect | Controller annotations |
|
||||
| `ingress.path` | `/` | Path routed to the web service |
|
||||
| `ingress.pathType` | `Prefix` | Path type of that route |
|
||||
| `ingress.tls.enabled` | `true` | Terminates TLS on the controller |
|
||||
| `ingress.tls.secretName` | `""` | Defaults to `<fullname>-web-tls` |
|
||||
| `ingress.tls.existingSecret` | `""` | Uses a certificate managed outside the chart |
|
||||
|
||||
The proxy annotations matter for the interview feature: the API streams events
|
||||
over server-sent events and accepts video uploads, so buffering off, a one-hour
|
||||
read timeout, and a body size at least as large as `api.maxUploadMB` are
|
||||
required, otherwise streams stall and uploads fail with `413`.
|
||||
|
||||
### certManager
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `certManager.enabled` | `true` | Chart creates the certificates |
|
||||
| `certManager.issuerRef` | `{}` | Uses an existing issuer instead of the chart CA |
|
||||
| `certManager.duration` | `8760h` | Certificate lifetime |
|
||||
| `certManager.renewBefore` | `720h` | Renewal window |
|
||||
| `certManager.caSecretName` | `""` | Defaults to `<fullname>-web-ca` |
|
||||
|
||||
Three certificate objects are created in order: a self-signed `Issuer`, a CA
|
||||
`Certificate` stored in `caSecretName`, and the leaf `Certificate` stored in the
|
||||
ingress TLS secret with one `dnsNames` entry per tenant. Set `issuerRef` to sign
|
||||
with a cluster issuer instead, for example an internal ACME issuer:
|
||||
|
||||
```yaml
|
||||
certManager:
|
||||
issuerRef:
|
||||
name: internal-acme
|
||||
kind: ClusterIssuer
|
||||
group: cert-manager.io
|
||||
```
|
||||
|
||||
Set `ingress.tls.existingSecret` to use a certificate the chart does not own,
|
||||
and `certManager.enabled: false` to stop emitting cert-manager objects.
|
||||
|
||||
### Secrets, storage, and scheduling
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `secrets.jwtSecret` | `""` | Generated and preserved across upgrades when empty |
|
||||
| `secrets.databaseURL` | derived | Full URL, required when PostgreSQL is disabled |
|
||||
| `secrets.rabbitmqURL` | derived | Full URL, required when RabbitMQ is disabled |
|
||||
| `secrets.postgresqlPassword` | generated | PostgreSQL user password |
|
||||
| `secrets.rabbitmqPassword` | generated | RabbitMQ user password |
|
||||
| `postgresql.enabled` | `true` | Bundled PostgreSQL |
|
||||
| `postgresql.persistence.enabled` | `true` | Persistent volume instead of `emptyDir` |
|
||||
| `postgresql.persistence.size` | `8Gi` | Volume size |
|
||||
| `postgresql.auth.username` | `go_guess` | Database user |
|
||||
| `rabbitmq.enabled` | `true` | Bundled RabbitMQ |
|
||||
| `rabbitmq.persistence.enabled` | `true` | Persistent volume instead of `emptyDir` |
|
||||
| `rabbitmq.persistence.size` | `4Gi` | Volume size |
|
||||
| `nodeSelector`, `tolerations`, `affinity` | empty | Applied to both deployments |
|
||||
|
||||
Generated secrets are read back with `lookup` on upgrade, so leaving them empty
|
||||
rotates nothing and keeps sessions valid across releases.
|
||||
|
||||
## 6. External services
|
||||
|
||||
Disable the bundled StatefulSets and pass full connection URLs to use managed
|
||||
PostgreSQL and RabbitMQ:
|
||||
|
||||
```bash
|
||||
helm upgrade --install go-guess deploy/helm/go-guess \
|
||||
--namespace go-insane-production --create-namespace \
|
||||
-f deploy/helm/go-guess/values-production.yaml \
|
||||
--set postgresql.enabled=false \
|
||||
--set rabbitmq.enabled=false \
|
||||
--set-string secrets.databaseURL="$DATABASE_URL" \
|
||||
--set-string secrets.rabbitmqURL="$RABBITMQ_URL"
|
||||
```
|
||||
|
||||
## 7. Upgrade and remove
|
||||
|
||||
```bash
|
||||
make helm-deploy-production
|
||||
helm -n go-insane-production history go-guess
|
||||
helm -n go-insane-production rollback go-guess 1
|
||||
make helm-down-production
|
||||
```
|
||||
|
||||
Uninstalling removes the release secrets, so store `secrets.jwtSecret` and the
|
||||
two connection URLs outside the cluster before deleting a release whose database
|
||||
must survive.
|
||||
|
||||
## 8. Verify
|
||||
|
||||
```bash
|
||||
kubectl -n go-insane-production get ingress go-guess
|
||||
kubectl -n go-insane-production get certificate
|
||||
kubectl -n go-insane-production rollout status deployment/go-guess-api
|
||||
kubectl -n go-insane-production rollout status deployment/go-guess-web
|
||||
curl --resolve nmbs.guess.urpi.be:443:127.0.0.1 --cacert go-guess-ca.crt \
|
||||
https://nmbs.guess.urpi.be/api/health
|
||||
```
|
||||
|
||||
The test pod installed by `helm test` checks the API health endpoint and the web
|
||||
root from inside the cluster.
|
||||
|
|
@ -1,48 +1,17 @@
|
|||
Go Guess is installed in namespace {{ .Release.Namespace }}.
|
||||
|
||||
Components:
|
||||
API service/{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }} (cluster-internal)
|
||||
Web service/{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }}
|
||||
Ingress {{ if .Values.ingress.enabled }}class {{ if .Values.ingress.className }}{{ .Values.ingress.className }}{{ else }}default{{ end }}{{ else }}disabled{{ end }}
|
||||
{{ if .Values.ingress.enabled }}
|
||||
Tenant URLs:
|
||||
{{- range .Values.tenants }}
|
||||
{{ if $.Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ printf "%s.%s" . $.Values.domain }}
|
||||
{{- end }}
|
||||
|
||||
Point every tenant host at the ingress controller address, for example in
|
||||
/etc/hosts:
|
||||
{{- range .Values.tenants }}
|
||||
<ingress-controller-ip> {{ printf "%s.%s" . $.Values.domain }}
|
||||
{{- end }}
|
||||
{{- if and .Values.ingress.tls.enabled .Values.certManager.enabled (not .Values.ingress.tls.existingSecret) }}
|
||||
|
||||
The certificate is self-signed, so trust the chart CA in every browser or client
|
||||
that opens these hosts:
|
||||
kubectl -n {{ .Release.Namespace }} get secret {{ include "go-guess.certManagerCASecretName" . }} \
|
||||
-o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt
|
||||
{{- end }}
|
||||
{{- if .Values.api.goLoose.enabled }}
|
||||
|
||||
Browser sign-in redirects to https://<tenant>.{{ .Values.api.goLoose.authDomain }}:
|
||||
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
|
||||
{{- if and $credentials.clientID $credentials.clientSecret }}
|
||||
{{ $tenant }}: Go Loose browser sign-in
|
||||
Go Guess has been installed in namespace {{ .Release.Namespace }}.
|
||||
{{- if .Values.goLoose.enabled }}
|
||||
Browser sign-in uses Go Loose at https://<tenant>.{{ .Values.goLoose.authDomain }}.
|
||||
{{- else }}
|
||||
{{ $tenant }}: no client credentials configured, password login only
|
||||
{{- end }}
|
||||
Browser sign-in uses the application password login. Enable goLoose to redirect tenant hosts.
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Open {{ .Values.app.frontendURL }}
|
||||
{{- else if eq .Values.service.type "NodePort" }}
|
||||
The application is exposed on cluster node port {{ .Values.service.nodePort }}.
|
||||
Open {{ .Values.app.frontendURL }} through the external reverse proxy.
|
||||
{{- else }}
|
||||
|
||||
Browser sign-in uses the application password login.
|
||||
{{- end }}
|
||||
{{- else if and (eq .Values.web.service.type "NodePort") .Values.web.service.nodePort }}
|
||||
|
||||
The web Service is exposed on cluster node port {{ .Values.web.service.nodePort }}.
|
||||
{{- else }}
|
||||
|
||||
Run:
|
||||
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-guess.webDeploymentName" . }} 8080:{{ .Values.web.service.port }}
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-guess.fullname" . }} 8080:{{ .Values.service.port }}
|
||||
Then open http://127.0.0.1:8080.
|
||||
{{- end }}
|
||||
|
|
@ -39,72 +39,3 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
|||
{{- define "go-guess.rabbitmqName" -}}
|
||||
{{- printf "%s-rabbitmq" (include "go-guess.fullname" .) }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "go-guess.apiDeploymentName" -}}
|
||||
{{- printf "%s-api" (include "go-guess.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "go-guess.webDeploymentName" -}}
|
||||
{{- printf "%s-web" (include "go-guess.fullname" .) | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* DNS name the API is reachable under. The web image hardcodes "api:8080". */}}
|
||||
{{- define "go-guess.apiServiceName" -}}
|
||||
{{- default "api" .Values.api.service.name }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Host serving one tenant, for example nmbs.guess.urpi.be. */}}
|
||||
{{- define "go-guess.tenantHost" -}}
|
||||
{{- printf "%s.%s" (index . 0) (index . 1) }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Fallback public URL for invitation and meeting links. */}}
|
||||
{{- define "go-guess.frontendURL" -}}
|
||||
{{- if .Values.api.frontendURL }}
|
||||
{{- .Values.api.frontendURL }}
|
||||
{{- else if .Values.tenants }}
|
||||
{{- printf "https://%s.%s" (first .Values.tenants) .Values.domain }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret holding the wildcard-per-tenant web certificate. */}}
|
||||
{{- define "go-guess.tlsSecretName" -}}
|
||||
{{- if .Values.ingress.tls.existingSecret }}
|
||||
{{- .Values.ingress.tls.existingSecret }}
|
||||
{{- else if .Values.ingress.tls.secretName }}
|
||||
{{- .Values.ingress.tls.secretName }}
|
||||
{{- else }}
|
||||
{{- printf "%s-web-tls" (include "go-guess.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Cluster-scoped by default so one issuer signs every tenant release. */}}
|
||||
{{- define "go-guess.certManagerIssuerRef" -}}
|
||||
{{- if .Values.certManager.issuerRef }}
|
||||
{{- toYaml .Values.certManager.issuerRef | nindent 4 }}
|
||||
{{- else }}
|
||||
name: selfsigned
|
||||
kind: Issuer
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "go-guess.validate" -}}
|
||||
{{- if not .Values.tenants }}
|
||||
{{- fail "tenants must list at least one tenant slug" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.domain }}
|
||||
{{- fail "domain is required" }}
|
||||
{{- end }}
|
||||
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled (not .Values.certManager.enabled) (not .Values.ingress.tls.existingSecret) }}
|
||||
{{- fail "ingress.tls.enabled requires certManager.enabled or ingress.tls.existingSecret" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret holding the CA that signs the leaf web certificate. */}}
|
||||
{{- define "go-guess.certManagerCASecretName" -}}
|
||||
{{- if .Values.certManager.caSecretName }}
|
||||
{{- .Values.certManager.caSecretName }}
|
||||
{{- else }}
|
||||
{{- printf "%s-web-ca" (include "go-guess.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
{{- include "go-guess.validate" . -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "go-guess.apiDeploymentName" . }}
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
replicas: {{ .Values.api.replicaCount }}
|
||||
strategy:
|
||||
type: {{ .Values.api.strategy.type }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: api
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.api.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: api
|
||||
{{- with .Values.api.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.api.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: api
|
||||
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.api.securityContext | nindent 12 }}
|
||||
env:
|
||||
- name: APP_ENV
|
||||
value: {{ .Values.api.environment | quote }}
|
||||
- name: FRONTEND_URL
|
||||
value: {{ include "go-guess.frontendURL" . | quote }}
|
||||
{{- with .Values.api.themeBaseUrl }}
|
||||
# The theme service is read by the browser, not the API, so it is passed
|
||||
# through to the entrypoint, which writes the runtime config the page
|
||||
# reads. Empty means this deployment keeps its own styling.
|
||||
- name: THEME_BASE_URL
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
- name: TOKEN_TTL
|
||||
value: {{ .Values.api.tokenTTL | quote }}
|
||||
- name: MAX_UPLOAD_MB
|
||||
value: {{ .Values.api.maxUploadMB | quote }}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" . }}
|
||||
key: database-url
|
||||
- name: RABBITMQ_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" . }}
|
||||
key: rabbitmq-url
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" . }}
|
||||
key: jwt-secret
|
||||
{{- if .Values.api.goLoose.enabled }}
|
||||
- name: GO_LOOSE_AUTH_DOMAIN
|
||||
value: {{ .Values.api.goLoose.authDomain | quote }}
|
||||
- name: GO_LOOSE_APP_DOMAIN
|
||||
value: {{ .Values.domain | quote }}
|
||||
{{- if .Values.api.goLoose.ca.existingSecret }}
|
||||
- name: GO_LOOSE_CA_FILE
|
||||
value: {{ .Values.api.goLoose.ca.mountPath | quote }}
|
||||
{{- end }}
|
||||
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
|
||||
{{- $envPrefix := printf "GO_LOOSE_%s" (upper $tenant) }}
|
||||
{{- $keyPrefix := printf "go-loose-%s" (lower $tenant) }}
|
||||
{{- if $credentials.clientID }}
|
||||
- name: {{ $envPrefix }}_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" $ }}
|
||||
key: {{ $keyPrefix }}-client-id
|
||||
{{- end }}
|
||||
{{- if $credentials.clientSecret }}
|
||||
- name: {{ $envPrefix }}_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" $ }}
|
||||
key: {{ $keyPrefix }}-client-secret
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 12
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
{{- toYaml .Values.api.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: temporary-files
|
||||
mountPath: /tmp
|
||||
{{- if and .Values.api.goLoose.enabled .Values.api.goLoose.ca.existingSecret }}
|
||||
- name: go-loose-ca
|
||||
mountPath: {{ dir .Values.api.goLoose.ca.mountPath }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: temporary-files
|
||||
emptyDir: {}
|
||||
{{- if and .Values.api.goLoose.enabled .Values.api.goLoose.ca.existingSecret }}
|
||||
- name: go-loose-ca
|
||||
secret:
|
||||
secretName: {{ .Values.api.goLoose.ca.existingSecret }}
|
||||
items:
|
||||
- key: {{ .Values.api.goLoose.ca.key }}
|
||||
path: {{ base .Values.api.goLoose.ca.mountPath }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
# Kept as the literal name "api": the web image proxies /api/ to api:8080.
|
||||
name: {{ include "go-guess.apiServiceName" . }}
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
type: {{ .Values.api.service.type }}
|
||||
selector:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.api.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
105
deploy/helm/go-guess/templates/app-deployment.yaml
Normal file
105
deploy/helm/go-guess/templates/app-deployment.yaml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "go-guess.fullname" . }}
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
strategy:
|
||||
type: {{ .Values.strategy.type }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: application
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: application
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: go-guess
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
env:
|
||||
- name: APP_ENV
|
||||
value: {{ .Values.app.environment | quote }}
|
||||
- name: FRONTEND_URL
|
||||
value: {{ .Values.app.frontendURL | quote }}
|
||||
- name: TOKEN_TTL
|
||||
value: {{ .Values.app.tokenTTL | quote }}
|
||||
- name: MAX_UPLOAD_MB
|
||||
value: {{ .Values.app.maxUploadMB | quote }}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" . }}
|
||||
key: database-url
|
||||
- name: RABBITMQ_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" . }}
|
||||
key: rabbitmq-url
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "go-guess.secretName" . }}
|
||||
key: jwt-secret
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 12
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: temporary-files
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: temporary-files
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
19
deploy/helm/go-guess/templates/app-service.yaml
Normal file
19
deploy/helm/go-guess/templates/app-service.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "go-guess.fullname" . }}
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: application
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
|
||||
nodePort: {{ .Values.service.nodePort }}
|
||||
{{- end }}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled }}
|
||||
{{- $fullname := include "go-guess.fullname" . }}
|
||||
{{- $caSecret := include "go-guess.certManagerCASecretName" . }}
|
||||
{{- $tlsSecret := include "go-guess.tlsSecretName" . }}
|
||||
{{- if not .Values.certManager.issuerRef }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Issuer
|
||||
metadata:
|
||||
name: {{ $fullname }}-selfsigned
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: certificate
|
||||
spec:
|
||||
selfSigned: {}
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ $fullname }}-web-ca
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: certificate
|
||||
spec:
|
||||
secretName: {{ $caSecret }}
|
||||
commonName: {{ printf "%s-ca" $fullname }}
|
||||
duration: {{ .Values.certManager.duration }}
|
||||
renewBefore: {{ .Values.certManager.renewBefore }}
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
issuerRef:
|
||||
name: {{ $fullname }}-selfsigned
|
||||
kind: Issuer
|
||||
group: cert-manager.io
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Issuer
|
||||
metadata:
|
||||
name: {{ $fullname }}-web-ca
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: certificate
|
||||
spec:
|
||||
ca:
|
||||
secretName: {{ $caSecret }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ $fullname }}-web
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: certificate
|
||||
spec:
|
||||
secretName: {{ $tlsSecret }}
|
||||
duration: {{ .Values.certManager.duration }}
|
||||
renewBefore: {{ .Values.certManager.renewBefore }}
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
usages:
|
||||
- server auth
|
||||
dnsNames:
|
||||
{{- range .Values.tenants }}
|
||||
- {{ printf "%s.%s" . $.Values.domain | quote }}
|
||||
{{- end }}
|
||||
issuerRef:
|
||||
{{- if .Values.certManager.issuerRef }}
|
||||
{{- include "go-guess.certManagerIssuerRef" . | nindent 4 }}
|
||||
{{- else }}
|
||||
name: {{ $fullname }}-web-ca
|
||||
kind: Issuer
|
||||
group: cert-manager.io
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
{{- if .Values.ingress.enabled }}
|
||||
{{- $webService := include "go-guess.webDeploymentName" . }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
|
@ -14,25 +13,23 @@ spec:
|
|||
{{- with .Values.ingress.className }}
|
||||
ingressClassName: {{ . }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
{{- with .Values.ingress.tls }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- range .Values.tenants }}
|
||||
- {{ printf "%s.%s" . $.Values.domain | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ include "go-guess.tlsSecretName" . }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.tenants }}
|
||||
- host: {{ printf "%s.%s" . $.Values.domain | quote }}
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ $.Values.ingress.path }}
|
||||
pathType: {{ $.Values.ingress.pathType }}
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ $webService }}
|
||||
name: {{ include "go-guess.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.web.service.port }}
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
@ -32,14 +32,3 @@ data:
|
|||
rabbitmq-url: {{ $rabbitmqURL | b64enc | quote }}
|
||||
postgresql-password: {{ $postgresPassword | b64enc | quote }}
|
||||
rabbitmq-password: {{ $rabbitmqPassword | b64enc | quote }}
|
||||
{{- if .Values.api.goLoose.enabled }}
|
||||
{{- range $tenant, $credentials := .Values.api.goLoose.tenants }}
|
||||
{{- $prefix := printf "go-loose-%s" (lower $tenant) }}
|
||||
{{- if $credentials.clientID }}
|
||||
{{ printf "%s-client-id" $prefix }}: {{ $credentials.clientID | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if $credentials.clientSecret }}
|
||||
{{ printf "%s-client-secret" $prefix }}: {{ $credentials.clientSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
|
@ -17,11 +17,4 @@ spec:
|
|||
- wget
|
||||
args:
|
||||
- -qO-
|
||||
- "http://{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }}/api/health"
|
||||
- name: wget-web
|
||||
image: busybox:1.37
|
||||
command:
|
||||
- wget
|
||||
args:
|
||||
- -qO-
|
||||
- "http://{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }}/"
|
||||
- "http://{{ include "go-guess.fullname" . }}:{{ .Values.service.port }}/api/health"
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "go-guess.webDeploymentName" . }}
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
replicas: {{ .Values.web.replicaCount }}
|
||||
strategy:
|
||||
{{- toYaml .Values.web.strategy | nindent 4 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: web
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.web.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: web
|
||||
{{- with .Values.web.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.web.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: web
|
||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.web.securityContext | nindent 12 }}
|
||||
env:
|
||||
{{- with .Values.web.themeBaseUrl }}
|
||||
# Read by the browser, not the API, so it goes to the web container:
|
||||
# the entrypoint writes it into runtime-config.js on start. Empty keeps
|
||||
# the application on its own styling.
|
||||
- name: THEME_BASE_URL
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
{{- toYaml .Values.web.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: nginx-cache
|
||||
mountPath: /var/cache/nginx
|
||||
- name: nginx-run
|
||||
mountPath: /var/run
|
||||
- name: temporary-files
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: nginx-cache
|
||||
emptyDir: {}
|
||||
- name: nginx-run
|
||||
emptyDir: {}
|
||||
- name: temporary-files
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "go-guess.webDeploymentName" . }}
|
||||
labels:
|
||||
{{- include "go-guess.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
spec:
|
||||
type: {{ .Values.web.service.type }}
|
||||
selector:
|
||||
{{- include "go-guess.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: web
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.web.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
# Development values for go-guess
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
|
||||
domain: guess-dev.urpi.be
|
||||
|
||||
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
|
||||
imagePullSecrets:
|
||||
- name: nexus-registry
|
||||
|
||||
api:
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
fsGroup: 100
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: Always
|
||||
environment: development
|
||||
frontendURL: ""
|
||||
tokenTTL: 8h
|
||||
maxUploadMB: 10
|
||||
service:
|
||||
name: api
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
goLoose:
|
||||
enabled: true
|
||||
authDomain: auth-dev.urpi.be
|
||||
ca:
|
||||
existingSecret: ""
|
||||
key: ca.crt
|
||||
mountPath: /certs/local-ca.crt
|
||||
tenants:
|
||||
nmbs:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
ypto:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: Always
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
nodePort: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 96Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- NET_BIND_SERVICE
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
enabled: true
|
||||
existingSecret: ""
|
||||
secretName: ""
|
||||
|
||||
certManager:
|
||||
enabled: true
|
||||
issuerRef:
|
||||
name: letsencrypt-staging
|
||||
kind: ClusterIssuer
|
||||
duration: 8760h
|
||||
renewBefore: 720h
|
||||
caSecretName: ""
|
||||
|
||||
secrets:
|
||||
jwtSecret: ""
|
||||
databaseURL: ""
|
||||
rabbitmqURL: ""
|
||||
postgresqlPassword: ""
|
||||
rabbitmqPassword: ""
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: 18-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
database: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 2Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
rabbitmq:
|
||||
enabled: true
|
||||
image:
|
||||
repository: rabbitmq
|
||||
tag: 4-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 2Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
|
@ -1,43 +1,25 @@
|
|||
# Development profile: demo fixtures, ephemeral database and broker storage, and
|
||||
# `latest` image tags. Browser sign-in goes through the development identity
|
||||
# domain auth-dev.urpi.be, never the production auth.urpi.be.
|
||||
domain: guess-dev.urpi.be
|
||||
image:
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
|
||||
api:
|
||||
app:
|
||||
environment: development
|
||||
image:
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
goLoose:
|
||||
enabled: true
|
||||
authDomain: auth-dev.urpi.be
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
frontendURL: http://localhost:30080
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
image:
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 96Mi
|
||||
service:
|
||||
type: NodePort
|
||||
nodePort: 30080
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
postgresql:
|
||||
persistence:
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
# Local (LCL) values for go-guess.
|
||||
# Tenants are served at https://<tenant>.guess-lcl.intern-urpi.be
|
||||
# (nmbs, ypto), routed by the docker TLS edge into k3s Traefik.
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
|
||||
domain: guess-lcl.intern-urpi.be
|
||||
|
||||
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
|
||||
# Images live in the local registry (localhost:5000), no pull secret needed.
|
||||
imagePullSecrets: []
|
||||
|
||||
api:
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
fsGroup: 100
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: localhost:5000/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: IfNotPresent
|
||||
environment: development
|
||||
frontendURL: ""
|
||||
tokenTTL: 8h
|
||||
maxUploadMB: 10
|
||||
service:
|
||||
name: api
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
goLoose:
|
||||
enabled: true
|
||||
authDomain: auth-lcl.intern-urpi.be
|
||||
# local-ca is the certificate authority used by the docker TLS edge
|
||||
# (dev-infrastructure/certs/local-ca.crt).
|
||||
ca:
|
||||
existingSecret: "local-ca"
|
||||
key: ca.crt
|
||||
mountPath: /certs/local-ca.crt
|
||||
tenants:
|
||||
nmbs:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
ypto:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: localhost:5000/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: IfNotPresent
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
nodePort: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 96Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- NET_BIND_SERVICE
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
annotations: {}
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
enabled: true
|
||||
# local-dev-cert is signed by the local CA (dev-infrastructure/certs).
|
||||
existingSecret: "local-dev-cert"
|
||||
secretName: ""
|
||||
|
||||
# No cert-manager in the local cluster.
|
||||
certManager:
|
||||
enabled: false
|
||||
issuerRef: null
|
||||
duration: 8760h
|
||||
renewBefore: 720h
|
||||
caSecretName: ""
|
||||
|
||||
# Empty values are generated once and preserved in the release secret.
|
||||
secrets:
|
||||
jwtSecret: ""
|
||||
databaseURL: ""
|
||||
rabbitmqURL: ""
|
||||
postgresqlPassword: ""
|
||||
rabbitmqPassword: ""
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: 18-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
database: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 2Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
rabbitmq:
|
||||
enabled: true
|
||||
image:
|
||||
repository: rabbitmq
|
||||
tag: 4-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 2Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
# Production values for go-guess
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
|
||||
domain: guess.urpi.be
|
||||
|
||||
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
|
||||
imagePullSecrets:
|
||||
- name: nexus-registry
|
||||
|
||||
api:
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
fsGroup: 100
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: Always
|
||||
environment: production
|
||||
frontendURL: ""
|
||||
tokenTTL: 8h
|
||||
maxUploadMB: 10
|
||||
service:
|
||||
name: api
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
goLoose:
|
||||
enabled: true
|
||||
authDomain: auth.urpi.be
|
||||
ca:
|
||||
existingSecret: ""
|
||||
key: ca.crt
|
||||
mountPath: /certs/local-ca.crt
|
||||
tenants:
|
||||
nmbs:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
ypto:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
|
||||
web:
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: Always
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
nodePort: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- NET_BIND_SERVICE
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
enabled: true
|
||||
existingSecret: ""
|
||||
secretName: ""
|
||||
|
||||
certManager:
|
||||
enabled: true
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
duration: 8760h
|
||||
renewBefore: 720h
|
||||
caSecretName: ""
|
||||
|
||||
secrets:
|
||||
jwtSecret: ""
|
||||
databaseURL: ""
|
||||
rabbitmqURL: ""
|
||||
postgresqlPassword: ""
|
||||
rabbitmqPassword: ""
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: 18-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
database: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
|
||||
rabbitmq:
|
||||
enabled: true
|
||||
image:
|
||||
repository: rabbitmq
|
||||
tag: 4-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
|
@ -1,45 +1,25 @@
|
|||
# Production profile: production fixtures, persistent volumes, released image
|
||||
# tags, and self-signed certificates served for https://<tenant>.guess.urpi.be.
|
||||
domain: guess.urpi.be
|
||||
image:
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
|
||||
api:
|
||||
app:
|
||||
environment: production
|
||||
image:
|
||||
tag: "0.0.1"
|
||||
pullPolicy: IfNotPresent
|
||||
goLoose:
|
||||
enabled: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 768Mi
|
||||
frontendURL: https://goguess.urpi.be
|
||||
|
||||
web:
|
||||
replicaCount: 2
|
||||
image:
|
||||
tag: "0.0.1"
|
||||
pullPolicy: IfNotPresent
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 128Mi
|
||||
service:
|
||||
type: NodePort
|
||||
nodePort: 31374
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
enabled: false
|
||||
|
||||
certManager:
|
||||
enabled: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 768Mi
|
||||
|
||||
postgresql:
|
||||
persistence:
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
# TST values for go-guess
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
|
||||
domain: guess-tst.intern-urpi.be
|
||||
|
||||
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
|
||||
imagePullSecrets:
|
||||
- name: nexus-registry
|
||||
|
||||
api:
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
fsGroup: 100
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: Always
|
||||
environment: development
|
||||
frontendURL: ""
|
||||
tokenTTL: 8h
|
||||
maxUploadMB: 10
|
||||
service:
|
||||
name: api
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
goLoose:
|
||||
enabled: true
|
||||
authDomain: auth-tst.intern-urpi.be
|
||||
ca:
|
||||
existingSecret: ""
|
||||
key: ca.crt
|
||||
mountPath: /certs/local-ca.crt
|
||||
tenants:
|
||||
nmbs:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
ypto:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: Always
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
nodePort: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 96Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-staging
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
enabled: true
|
||||
existingSecret: ""
|
||||
secretName: ""
|
||||
|
||||
certManager:
|
||||
enabled: true
|
||||
issuerRef:
|
||||
name: letsencrypt-staging
|
||||
kind: ClusterIssuer
|
||||
duration: 8760h
|
||||
renewBefore: 720h
|
||||
caSecretName: ""
|
||||
|
||||
secrets:
|
||||
jwtSecret: ""
|
||||
databaseURL: ""
|
||||
rabbitmqURL: ""
|
||||
postgresqlPassword: ""
|
||||
rabbitmqPassword: ""
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: 18-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
database: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
rabbitmq:
|
||||
enabled: true
|
||||
image:
|
||||
repository: rabbitmq
|
||||
tag: 4-alpine
|
||||
pullPolicy: IfNotPresent
|
||||
auth:
|
||||
username: go_guess
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 96Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 384Mi
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
|
@ -1,153 +1,30 @@
|
|||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
|
||||
domain: guess.urpi.be
|
||||
replicaCount: 1
|
||||
|
||||
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
|
||||
tenants:
|
||||
- nmbs
|
||||
- ypto
|
||||
image:
|
||||
repository: batty1039.startdedicated.net:5000/go-guess
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets:
|
||||
- name: nexus-registry
|
||||
imagePullSecrets: []
|
||||
|
||||
api:
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
fsGroup: 100
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 100
|
||||
runAsGroup: 100
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: IfNotPresent
|
||||
environment: development
|
||||
# Public base URL used when building invitation and meeting links. Defaults to
|
||||
# https://<first tenant>.<domain> when empty.
|
||||
frontendURL: ""
|
||||
# Origin of the go-bananas runtime supplying the shared styling contract, for
|
||||
# example https://themes.urpi.be. Empty keeps this application's own styles, so
|
||||
# a deployment without a theme service is unaffected. The service must also be
|
||||
# configured to allow this application's origin, or the browser will refuse to
|
||||
# load the controls from it.
|
||||
themeBaseUrl: ""
|
||||
app:
|
||||
environment: production
|
||||
frontendURL: http://localhost:8080
|
||||
tokenTTL: 8h
|
||||
maxUploadMB: 10
|
||||
service:
|
||||
# The web image proxies /api/ to the literal upstream name "api:8080", so this
|
||||
# Service name has to stay "api" unless the web image is rebuilt.
|
||||
name: api
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
goLoose:
|
||||
enabled: true
|
||||
authDomain: auth.urpi.be
|
||||
ca:
|
||||
# Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it.
|
||||
existingSecret: ""
|
||||
key: ca.crt
|
||||
mountPath: /certs/local-ca.crt
|
||||
# Per-tenant browser authentication credentials, written to the release secret.
|
||||
tenants:
|
||||
nmbs:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
ypto:
|
||||
clientID: ""
|
||||
clientSecret: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
web:
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
||||
tag: "0.0.1"
|
||||
pullPolicy: IfNotPresent
|
||||
# Origin of the go-bananas runtime supplying the shared styling contract, for
|
||||
# example https://bananas-dev.urpi.be. Empty keeps this application's own
|
||||
# styles. The theme service must also allow this application's origin, or the
|
||||
# browser refuses to load the controls from it.
|
||||
themeBaseUrl: ""
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
nodePort: null
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 128Mi
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
# nginx workers run as uid 101, which the image already declares.
|
||||
podSecurityContext: {}
|
||||
# nginx binds the privileged port 80 and forks workers as the nginx user, so it
|
||||
# needs the root user plus a small capability set and writable scratch paths.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- NET_BIND_SERVICE
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: false
|
||||
runAsUser: 0
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
enabled: true
|
||||
# Use a certificate you already manage instead of the chart-issued one.
|
||||
goLoose:
|
||||
enabled: false
|
||||
authDomain: auth.dev
|
||||
appDomain: guess.dev
|
||||
ca:
|
||||
existingSecret: ""
|
||||
# Defaults to <fullname>-web-tls when empty and certManager.enabled is true.
|
||||
secretName: ""
|
||||
|
||||
certManager:
|
||||
# Requires cert-manager in the cluster: make cert-manager-install.
|
||||
enabled: true
|
||||
# Reuse an existing cert-manager issuer instead of creating a self-signed one.
|
||||
issuerRef: {}
|
||||
duration: 8760h
|
||||
renewBefore: 720h
|
||||
caSecretName: ""
|
||||
key: ca.crt
|
||||
mountPath: /certs/local-ca.crt
|
||||
tenants: []
|
||||
|
||||
secrets:
|
||||
jwtSecret: ""
|
||||
|
|
@ -156,6 +33,49 @@ secrets:
|
|||
postgresqlPassword: ""
|
||||
rabbitmqPassword: ""
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
nodePort: null
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: nginx
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: go-guess.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
fsGroup: 10001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
image:
|
||||
|
|
|
|||
|
|
@ -55,9 +55,8 @@ curl --fail https://goguess.urpi.be/api/health
|
|||
The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ
|
||||
services should remain `ClusterIP` services without NodePorts.
|
||||
|
||||
Browser sign-in also calls Go Loose from the application pod, using
|
||||
`GO_LOOSE_AUTH_DOMAIN` (`auth-dev.urpi.be` outside production, `auth.urpi.be` in
|
||||
production) as `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>`. That address must be
|
||||
reachable from the pod. The browser forwards browser authentication and API
|
||||
requests without adding application credentials. Nginx does not add or strip
|
||||
authorization headers.
|
||||
Protected interviewer routes also call Go Loose from the application pod, using
|
||||
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
|
||||
request tenant). That address must be reachable from the cluster. The browser
|
||||
forwards browser authentication and API requests without adding application
|
||||
credentials. Nginx does not add or strip authorization headers.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_GO_LOOSE_API_KEY: gl_bdd_key_that_is_long_enough_for_client
|
||||
environment:
|
||||
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable&search_path=ypto
|
||||
JWT_SECRET: bdd-test-secret-that-is-at-least-32-characters
|
||||
|
|
|
|||
|
|
@ -36,12 +36,10 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
|
||||
JWT_SECRET: local-development-secret-change-me-12345
|
||||
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess-dev.urpi.be}
|
||||
# Where the go-bananas styling contract is served from. Empty disables it.
|
||||
THEME_BASE_URL: ${THEME_BASE_URL:-}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess.local}
|
||||
APP_ENV: ${APP_ENV:-development}
|
||||
GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth-dev.urpi.be}
|
||||
GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess-dev.urpi.be}
|
||||
GO_LOOSE_AUTH_DOMAIN: auth.dev
|
||||
GO_LOOSE_APP_DOMAIN: guess.dev
|
||||
GO_LOOSE_CA_FILE: /certs/local-ca.crt
|
||||
GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-}
|
||||
GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-}
|
||||
|
|
@ -54,7 +52,7 @@ services:
|
|||
- go_guess_network
|
||||
- local_dev_edge
|
||||
volumes:
|
||||
- ${LOCAL_CA_FILE:-/home/dnoulet/go/infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro
|
||||
- ${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}:/certs/local-ca.crt:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -13,13 +13,6 @@ seed
|
|||
|
||||
mkdir -p /tmp/nginx
|
||||
|
||||
# The theme service location is environment, not build time, so one image serves
|
||||
# every environment. The file is regenerated on every start and is read by the
|
||||
# page before the bundle runs.
|
||||
cat > /app/frontend/runtime-config.js <<CONFIG
|
||||
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: "${THEME_BASE_URL:-}" }
|
||||
CONFIG
|
||||
|
||||
api &
|
||||
api_pid=$!
|
||||
nginx -e /dev/stderr -g 'daemon off;' &
|
||||
|
|
|
|||
|
|
@ -8,11 +8,5 @@ RUN npm run build
|
|||
FROM nginx:1.29-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# Writes runtime-config.js from the environment on start, so one image serves
|
||||
# every environment instead of baking a hostname into the bundle at build time.
|
||||
# It lives beside this Dockerfile because the build context is ./frontend.
|
||||
COPY entrypoint.sh /usr/local/bin/go-guess-frontend-entrypoint
|
||||
RUN chmod +x /usr/local/bin/go-guess-frontend-entrypoint
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/usr/local/bin/go-guess-frontend-entrypoint"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
|
|||
|
|
@ -36,64 +36,15 @@ application shell.
|
|||
|
||||
## API integration
|
||||
|
||||
For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and
|
||||
`nmbs.guess.urpi.be` in production, `src/api/client.ts` derives tenant `nmbs`,
|
||||
calls same-origin `/api` through the shared HTTPS proxy, and sends
|
||||
For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives
|
||||
tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends
|
||||
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
|
||||
request. `ypto.guess-dev.urpi.be` behaves identically for tenant `ypto`. Other
|
||||
request. `ypto.guess.dev` behaves identically for tenant `ypto`. Other
|
||||
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
|
||||
|
||||
`src/components/actions/Account.tsx` treats any host whose first DNS label is a
|
||||
tenant as a possible Go Loose host and asks `/api/auth/session` whether browser
|
||||
login is available there. A `404` means the API has no Go Loose login for the
|
||||
host, so the password form is shown. Otherwise the sign-in screen offers a single
|
||||
button that hands the browser to Go Loose, which returns to the welcome page.
|
||||
Nothing redirects to the identity provider on its own: that fired on every
|
||||
anonymous visit and read as a redirect loop rather than as a choice to sign in.
|
||||
A person who already signed in at another application passes straight through,
|
||||
because Go Loose recognises the session it holds and issues a code without
|
||||
asking anything. The callback exchanges that session for the bearer token used by
|
||||
the typed API client. Participant routes are exempt: a participant opens the
|
||||
application through a token link and is never signing in.
|
||||
|
||||
## Theming
|
||||
|
||||
The palette and the light/dark switch come from
|
||||
[go-bananas](https://github.com/urpi/go-bananas), the design-token service, as
|
||||
runtime CSS custom properties. Three pieces, and the application installs all
|
||||
three itself:
|
||||
|
||||
- `src/theme/installThemeRuntime.ts` injects
|
||||
`<link rel="stylesheet" href="$THEME_BASE_URL/rt/v1/contract.css" data-theme-contract>`
|
||||
and imports `$THEME_BASE_URL/rt/v1/components.js`. Both are best effort: a
|
||||
theme service that is absent, slow, or unreachable must not stop the
|
||||
application working.
|
||||
- `src/theme/ThemeControls.tsx` renders the service's own custom elements,
|
||||
`<bananas-theme-selector>` and `<bananas-appearance-toggle>`. They are custom
|
||||
elements rather than components written here because the applications this
|
||||
service feeds are a mix of React, vanilla JavaScript, and server-rendered HTML,
|
||||
and a custom element is the one shape all three can use with no build step, no
|
||||
import map, and no second copy of React. React treats an unknown element as an
|
||||
inert host node, so rendering them unconditionally is safe.
|
||||
- Every token in `src/styles/global.css` is written as
|
||||
`var(--bn-*, fallback)`. That ordering is the whole safety property: with no
|
||||
theme service configured the page renders exactly as it did before, and with
|
||||
one configured the same rules follow the palette.
|
||||
|
||||
`THEME_BASE_URL` is read from `runtime-config.js`, which the container entrypoint
|
||||
rewrites from the environment on start, so one image serves every environment.
|
||||
`VITE_THEME_BASE_URL` is the equivalent for `npm run dev`.
|
||||
|
||||
Two deployment requirements are easy to miss. The service must be configured to
|
||||
allow this application's origin (`CORS_ALLOWED_ORIGINS` on go-bananas), or the
|
||||
browser refuses to load the controls from it, because a module script is always
|
||||
fetched with CORS. And `api.themeBaseUrl` in the Helm values must be set; it is
|
||||
empty by default, which disables theming.
|
||||
|
||||
`src/theme/contract.test.ts` fetches the live contract and asserts it defines
|
||||
every `--bn-*` variable this application consumes. A token renamed on the service
|
||||
would otherwise leave every `var()` resolving to its fallback, and the page would
|
||||
look correct while never actually following the palette.
|
||||
On the configured tenant hosts, the app checks `/api/auth/session` and redirects
|
||||
to `/api/auth/login` when no Go Loose browser session exists. The callback
|
||||
exchanges that session for the bearer token used by the typed API client.
|
||||
Invitation and participant-meeting URLs use the validated request origin so
|
||||
links stay on the tenant hostname. Copy actions fall back to a temporary selected
|
||||
textarea when the Clipboard API is unavailable on local HTTP domains.
|
||||
|
|
@ -102,7 +53,7 @@ Login stores the returned JWT in
|
|||
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
|
||||
shown in the UI and are never replaced with demo data. A `401` from a protected
|
||||
JSON, file, or event-stream request clears authentication and returns the user to
|
||||
`/login`. On a tenant host with Go Loose configured, the app restores
|
||||
`/login`. On `nmbs.guess.dev` and `ypto.guess.dev`, the app restores
|
||||
`/api/auth/session` or redirects to `/api/auth/login`; participant routes stay
|
||||
token-based. The app also schedules logout from the JWT expiration claim so an idle
|
||||
expired session cannot remain on a protected page.
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Writes the runtime configuration the page reads before the bundle runs, then
|
||||
# hands over to nginx.
|
||||
#
|
||||
# The front end is built once and served by many environments, so anything that
|
||||
# differs between them has to be resolved here rather than baked into the bundle.
|
||||
# Right now that is the origin of the theme service; an empty value leaves the
|
||||
# application on its own styling.
|
||||
set -eu
|
||||
|
||||
CONFIG_DIR=/usr/share/nginx/html
|
||||
CONFIG_FILE="$CONFIG_DIR/runtime-config.js"
|
||||
|
||||
# nginx serves its own error pages from this directory, so a read-only filesystem
|
||||
# would otherwise turn a missing file into a 500. The default is already in the
|
||||
# image, so this only replaces it.
|
||||
cat > "$CONFIG_FILE" <<CONFIG
|
||||
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: "${THEME_BASE_URL:-}" }
|
||||
CONFIG
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -6,9 +6,6 @@
|
|||
<meta name="theme-color" content="#081c2c" />
|
||||
<meta name="description" content="Go Guess recruitment assessment workspace" />
|
||||
<title>Go Guess · Recruitment workspace</title>
|
||||
<!-- Read before the bundle runs: it says where the theme service is, if there
|
||||
is one. Overwritten by the entrypoint in a container. -->
|
||||
<script src="/runtime-config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
/* Rewritten by the container entrypoint from THEME_BASE_URL on start, so one
|
||||
* image serves every environment. The committed value disables theming, which is
|
||||
* what a checkout with no theme service should do. */
|
||||
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: '' }
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
|
||||
import { api, apiBaseURL, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
|
||||
import { subscribeToDomainEvent } from './domainEvents'
|
||||
|
||||
describe('API client', () => {
|
||||
|
|
@ -8,11 +8,12 @@ describe('API client', () => {
|
|||
})
|
||||
|
||||
it('uses the same-origin API behind the local HTTPS proxy', () => {
|
||||
expect(tenantFromHostname('nmbs.guess-dev.urpi.be')).toBe('nmbs')
|
||||
expect(tenantFromHostname('ypto.guess-dev.urpi.be')).toBe('ypto')
|
||||
expect(tenantFromHostname('nmbs.guess.urpi.be')).toBe('nmbs')
|
||||
expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs')
|
||||
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto')
|
||||
expect(tenantFromHostname('localhost')).toBeNull()
|
||||
expect(tenantFromHostname('127.0.0.1')).toBeNull()
|
||||
expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'https:' })).toBe('/api')
|
||||
expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api')
|
||||
})
|
||||
|
||||
it('sends the persisted bearer token on protected requests', async () => {
|
||||
|
|
|
|||
|
|
@ -79,10 +79,16 @@ export function tenantFromHostname(hostname: string): string | null {
|
|||
return labels.length >= 3 ? labels[0] : null
|
||||
}
|
||||
|
||||
// Every tenant host serves the API from its own origin: nginx and Traefik proxy
|
||||
// /api to the API container, so the base is always the same-origin path.
|
||||
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
|
||||
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
|
||||
if (hostname.endsWith('.guess.local')) {
|
||||
return '/api'
|
||||
}
|
||||
return '/api'
|
||||
}
|
||||
|
||||
function apiURL(path: string): string {
|
||||
return `/api${path}`
|
||||
return `${apiBaseURL(window.location)}${path}`
|
||||
}
|
||||
|
||||
function tenantHeader(): Record<string, string> {
|
||||
|
|
|
|||
|
|
@ -258,17 +258,10 @@ describe('Go Guess frontend', () => {
|
|||
'href',
|
||||
'/invitations',
|
||||
)
|
||||
// The person is reached through the account menu rather than a bare profile
|
||||
// link, so the menu trigger names them and holds the actions.
|
||||
const menu = within(accountNavigation).getByRole('button', { name: /Admin/ })
|
||||
expect(menu).toHaveAttribute('aria-expanded', 'false')
|
||||
await userEvent.click(menu)
|
||||
expect(within(accountNavigation).getByRole('link', { name: 'Profile' })).toHaveAttribute(
|
||||
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
|
||||
'href',
|
||||
'/profile',
|
||||
)
|
||||
expect(within(accountNavigation).getByRole('button', { name: 'Sign out' })).toBeInTheDocument()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
|
||||
expect(
|
||||
|
|
@ -336,10 +329,7 @@ describe('Go Guess frontend', () => {
|
|||
renderApp('/participants/3')
|
||||
|
||||
const input = await screen.findByLabelText(/replace cv/i)
|
||||
await user.upload(
|
||||
input,
|
||||
new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }),
|
||||
)
|
||||
await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }))
|
||||
|
||||
expect(await screen.findByText('React')).toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -1,40 +1,33 @@
|
|||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { useAuthSession } from '../components/actions'
|
||||
import { NotFound } from '../components/ui/NotFound'
|
||||
import { ParticipantInterviewPage } from '../features/assessments'
|
||||
import { ParticipantMeetingPage } from '../features/interviews'
|
||||
import { LoginPage } from '../features/login/LoginPage'
|
||||
import { AuthenticatedApp } from './AuthenticatedApp'
|
||||
import { DataProvider } from './DataContext'
|
||||
import {Route, Routes, useLocation} from 'react-router-dom'
|
||||
import {useAuthSession} from '../components/actions'
|
||||
import {NotFound} from '../components/ui/NotFound'
|
||||
import {ParticipantInterviewPage} from '../features/assessments'
|
||||
import {ParticipantMeetingPage} from '../features/interviews'
|
||||
import {LoginPage} from '../features/login/LoginPage'
|
||||
import {AuthenticatedApp} from './AuthenticatedApp'
|
||||
import {DataProvider} from './DataContext'
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation()
|
||||
const { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } = useAuthSession()
|
||||
if (location.pathname.startsWith('/participant/')) {
|
||||
const location = useLocation();
|
||||
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
|
||||
if (location.pathname.startsWith('/participant/')) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage/>}/>
|
||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
if (!authenticated) {
|
||||
if (ssoStatus !== 'idle') {
|
||||
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
|
||||
}
|
||||
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
|
||||
}
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
|
||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<DataProvider>
|
||||
<AuthenticatedApp onLogout={logout}/>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
// The sign-in screen is the entry point, so nothing redirects to the identity
|
||||
// provider on its own. While the API is still answering there is nothing
|
||||
// useful to show, but not a spinner either: the button is correct either way,
|
||||
// because it is what the screen offers whether or not Go Loose is configured.
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<LoginPage
|
||||
onLogin={completeLogin}
|
||||
sessionExpired={sessionExpired}
|
||||
ssoAvailable={ssoAvailable}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DataProvider>
|
||||
<AuthenticatedApp onLogout={logout} />
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
|
|||
<div className="app-shell">
|
||||
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
|
||||
<main className="main-content">
|
||||
<WorkspaceUtilities user={user} onSignOut={onLogout}/>
|
||||
<WorkspaceUtilities user={user}/>
|
||||
<ApplicationRoutes/>
|
||||
<AppFooter/>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isSSOHost, useAuthSession } from './Account'
|
||||
|
||||
interface FakeLocation {
|
||||
hostname: string
|
||||
pathname: string
|
||||
assign: ReturnType<typeof vi.fn>
|
||||
replace: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function setHost(hostname: string, pathname = '/'): FakeLocation {
|
||||
const location: FakeLocation = {
|
||||
hostname,
|
||||
pathname,
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: location,
|
||||
})
|
||||
return location
|
||||
}
|
||||
|
||||
function json(body: unknown, status: number) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
const { authenticated, ssoAvailable } = useAuthSession()
|
||||
return <p>{`${String(ssoAvailable)}|${String(authenticated)}`}</p>
|
||||
}
|
||||
|
||||
function renderProbe(path = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Probe />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
const authResponse = {
|
||||
token: 'sso-token',
|
||||
user: { id: 1, email: 'ssuser@example.com', displayName: 'SSO', role: 'interviewer' },
|
||||
}
|
||||
|
||||
describe('isSSOHost', () => {
|
||||
it('recognises tenant hosts on the development and production domains', () => {
|
||||
for (const hostname of [
|
||||
'nmbs.guess-dev.urpi.be',
|
||||
'ypto.guess-dev.urpi.be',
|
||||
'nmbs.guess.urpi.be',
|
||||
'ypto.guess.urpi.be',
|
||||
]) {
|
||||
setHost(hostname)
|
||||
expect(isSSOHost(), hostname).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores localhost and raw addresses', () => {
|
||||
for (const hostname of ['localhost', '127.0.0.1', '']) {
|
||||
setHost(hostname)
|
||||
expect(isSSOHost(), hostname).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthSession Go Loose bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('signs in from an existing Go Loose session without leaving the page', async () => {
|
||||
const location = setHost('nmbs.guess-dev.urpi.be')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => json(authResponse, 200)),
|
||||
)
|
||||
|
||||
renderProbe()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('true|true')).toBeInTheDocument())
|
||||
expect(sessionStorage.getItem('go-guess-token')).toBe('sso-token')
|
||||
expect(location.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the sign-in screen in place when there is no session', async () => {
|
||||
// Signing in starts from the screen and one button. Redirecting on sight would
|
||||
// skip past the screen and fire on every anonymous visit, which reads as a
|
||||
// redirect loop rather than as a choice to sign in.
|
||||
const location = setHost('nmbs.guess.urpi.be')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => json({ error: 'missing Go Loose session' }, 401)),
|
||||
)
|
||||
|
||||
renderProbe()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('true|false')).toBeInTheDocument())
|
||||
expect(location.replace).not.toHaveBeenCalled()
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the single sign-on button when the API cannot be reached', async () => {
|
||||
// Nothing answers, so there is no way to know whether Go Loose is configured.
|
||||
// The button is the only way in on such a host, so it stays.
|
||||
const location = setHost('nmbs.guess.urpi.be')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.reject(new Error('offline'))),
|
||||
)
|
||||
|
||||
renderProbe()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument())
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to password sign-in when the tenant has no client credentials', async () => {
|
||||
// A 503 is what a configured tenant without Go Loose credentials returns. It is
|
||||
// just as unusable as a 404, and treating it as "available" left the sign-in
|
||||
// button leading to an error page with no way in.
|
||||
const location = setHost('nmbs.guess-dev.urpi.be')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => json({ error: 'Go Loose login is not configured for tenant "nmbs"' }, 503)),
|
||||
)
|
||||
|
||||
renderProbe()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to password sign-in when the API reports no Go Loose login', async () => {
|
||||
const location = setHost('nmbs.guess-dev.urpi.be')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => json({ error: 'Go Loose browser login is not enabled' }, 404)),
|
||||
)
|
||||
|
||||
renderProbe()
|
||||
|
||||
// false is what switches the screen from the button to the password form.
|
||||
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
|
||||
expect(location.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never probes the session outside a tenant host', async () => {
|
||||
setHost('localhost')
|
||||
const fetchMock = vi.fn(() => json(authResponse, 200))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
renderProbe()
|
||||
|
||||
// A host with no tenant label cannot have Go Loose login, so it is false
|
||||
// without asking the API anything.
|
||||
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the bootstrap on participant routes', async () => {
|
||||
const location = setHost('nmbs.guess-dev.urpi.be', '/participant/meeting/token')
|
||||
const fetchMock = vi.fn(() => json(authResponse, 200))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
renderProbe('/participant/meeting/token')
|
||||
|
||||
await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument())
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(location.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,101 +1,67 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
authStorage,
|
||||
invalidateSession,
|
||||
tenantFromHostname,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
} from '../../api/client'
|
||||
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
|
||||
|
||||
const SESSION_PATH = '/api/auth/session'
|
||||
const LOGOUT_PATH = '/api/auth/logout'
|
||||
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
|
||||
|
||||
/**
|
||||
* Every tenant is served from https://<tenant>.<app domain>, on guess-dev.urpi.be
|
||||
* locally and guess.urpi.be in production, so the first DNS label of a host
|
||||
* with an app domain below it names the tenant. Those hosts may authenticate
|
||||
* through Go Loose. Whether they actually do is answered by the API, which
|
||||
* reports 404 for /api/auth/session on any host it has no Go Loose login for,
|
||||
* and that host then falls back to password sign-in.
|
||||
*/
|
||||
export function isSSOHost() {
|
||||
return tenantFromHostname(window.location.hostname) !== null
|
||||
return ssoHosts.has(window.location.hostname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks who is signed in.
|
||||
*
|
||||
* There is deliberately no automatic redirect to the identity provider: signing in
|
||||
* starts from the sign-in screen and one button. Redirecting on sight would skip
|
||||
* past the screen, and would also fire on every anonymous visit to the
|
||||
* application, which reads as a redirect loop rather than as a choice to sign in.
|
||||
*/
|
||||
export function useAuthSession() {
|
||||
const location = useLocation()
|
||||
// A participant opens the application through a token link and is never a
|
||||
// member of the tenant, so nothing here may probe or redirect them.
|
||||
const participant = location.pathname.startsWith('/participant/')
|
||||
const navigate = useNavigate()
|
||||
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
|
||||
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
||||
const [sessionExpired, setSessionExpired] = useState(false)
|
||||
// Whether Go Loose login is configured for this host. A host with no tenant
|
||||
// label cannot have it, so that is known without asking: false there, and null
|
||||
// on a tenant host until /api/auth/session has answered.
|
||||
const [ssoAvailable, setSsoAvailable] = useState<boolean | null>(() =>
|
||||
isSSOHost() ? null : false,
|
||||
)
|
||||
const storedToken = authStorage.token()
|
||||
const [redirecting, setRedirecting] = useState(false)
|
||||
const needsSSO = sso && !authenticated && !authStorage.token()
|
||||
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking'
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function handleSessionExpired() {
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(true)
|
||||
if (isSSOHost() && !window.location.pathname.startsWith('/participant/')) {
|
||||
window.location.replace('/api/auth/login')
|
||||
return
|
||||
}
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||
}, [])
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
// A host without a tenant label cannot have Go Loose login configured, and a
|
||||
// participant is not signing in at all.
|
||||
if (participant || !isSSOHost() || storedToken) return
|
||||
if (!needsSSO) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(SESSION_PATH, {
|
||||
const response = await fetch('/api/auth/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
redirect: 'manual',
|
||||
})
|
||||
if (cancelled) return
|
||||
if (response.ok) {
|
||||
// An existing Go Loose session. This is also the single sign-on case:
|
||||
// having signed in anywhere in the family, the person lands here
|
||||
// already authenticated.
|
||||
setSsoAvailable(true)
|
||||
authStorage.save(await response.json())
|
||||
setAuthenticated(true)
|
||||
setSessionExpired(false)
|
||||
return
|
||||
}
|
||||
if (response.status === 404 || response.status === 503) {
|
||||
// Either Go Loose login is not set up for this host at all (404), or the
|
||||
// host is a known tenant whose client credentials have not been supplied
|
||||
// (503). Neither can produce a sign-in, so offering the button would
|
||||
// dead-end on an error page instead of letting the person in.
|
||||
setSsoAvailable(false)
|
||||
return
|
||||
}
|
||||
setSsoAvailable(true)
|
||||
} catch {
|
||||
// An unreachable API leaves ssoAvailable null, which keeps the single
|
||||
// sign-on button on screen: it is the only way in on such a host anyway.
|
||||
// The browser redirect below starts a fresh Go Loose login.
|
||||
}
|
||||
if (!cancelled) {
|
||||
setRedirecting(true)
|
||||
window.location.replace('/api/auth/login')
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [storedToken, participant])
|
||||
}, [needsSSO])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) return
|
||||
|
|
@ -113,8 +79,6 @@ export function useAuthSession() {
|
|||
function completeLogin() {
|
||||
setAuthenticated(true)
|
||||
setSessionExpired(false)
|
||||
// The password path can be entered from any URL, so the welcome page is the
|
||||
// destination. The Go Loose path is already sent here by its redirect.
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
|
|
@ -122,10 +86,8 @@ export function useAuthSession() {
|
|||
authStorage.clear()
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(false)
|
||||
// Signing out also ends the Go Loose session, so the next application in the
|
||||
// family asks for credentials again rather than letting this one back in.
|
||||
if (isSSOHost()) window.location.assign(LOGOUT_PATH)
|
||||
if (isSSOHost()) window.location.assign('/api/auth/logout')
|
||||
}
|
||||
|
||||
return { authenticated, sessionExpired, ssoAvailable, completeLogin, logout }
|
||||
return { authenticated, sessionExpired, ssoStatus, completeLogin, logout }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { User } from '../../types'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
const user: User = {
|
||||
id: 1,
|
||||
email: 'ada@example.test',
|
||||
displayName: 'Ada Lovelace',
|
||||
role: 'interviewer',
|
||||
avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc',
|
||||
}
|
||||
|
||||
function renderMenu(onSignOut = vi.fn()) {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UserMenu user={user} onSignOut={onSignOut} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return onSignOut
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return screen.getByRole('button', { name: /Ada Lovelace/ })
|
||||
}
|
||||
|
||||
describe('UserMenu', () => {
|
||||
it('starts closed and says so', () => {
|
||||
renderMenu()
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('link', { name: 'Profile' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens and closes on the trigger', async () => {
|
||||
const user_ = userEvent.setup()
|
||||
renderMenu()
|
||||
await user_.click(trigger())
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('link', { name: 'Profile' })).toHaveAttribute('href', '/profile')
|
||||
|
||||
await user_.click(trigger())
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('closes on Escape and returns focus to the trigger', async () => {
|
||||
const user_ = userEvent.setup()
|
||||
renderMenu()
|
||||
await user_.click(trigger())
|
||||
await user_.keyboard('{Escape}')
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(trigger()).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes when the click lands outside it', async () => {
|
||||
const user_ = userEvent.setup()
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<div>
|
||||
<UserMenu user={user} onSignOut={vi.fn()} />
|
||||
<button type="button">elsewhere</button>
|
||||
</div>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
await user_.click(trigger())
|
||||
await user_.click(screen.getByRole('button', { name: 'elsewhere' }))
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('signs out and closes', async () => {
|
||||
const user_ = userEvent.setup()
|
||||
const onSignOut = renderMenu()
|
||||
await user_.click(trigger())
|
||||
await user_.click(screen.getByRole('button', { name: 'Sign out' }))
|
||||
expect(onSignOut).toHaveBeenCalledTimes(1)
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('shows the picture and the address once open', async () => {
|
||||
const user_ = userEvent.setup()
|
||||
renderMenu()
|
||||
await user_.click(trigger())
|
||||
const panel = screen.getByRole('link', { name: 'Profile' }).closest('.user-menu-panel')!
|
||||
expect(within(panel as HTMLElement).getByText('ada@example.test')).toBeInTheDocument()
|
||||
expect(within(panel as HTMLElement).getByTestId('avatar-image')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the address when there is no name', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<UserMenu user={{ ...user, displayName: '' }} onSignOut={vi.fn()} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /ada@example.test/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { LogOut, UserRound } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { User } from '../../types'
|
||||
import { Avatar } from '../ui/Avatar'
|
||||
|
||||
/**
|
||||
* The account menu in the workspace utilities bar.
|
||||
*
|
||||
* It is a disclosure rather than a hover menu: the trigger is a real button with
|
||||
* aria-expanded, the panel is labelled, Escape closes it, a click anywhere else
|
||||
* closes it, and focus returns to the trigger. A menu that only opens on hover
|
||||
* cannot be used from the keyboard or on a touch screen.
|
||||
*
|
||||
* The panel is not an ARIA menu. Its two actions are a link and a button, and
|
||||
* giving them menuitem roles would strip the link semantics from one of them for
|
||||
* no gain: everything here is reachable by tabbing, which is the behaviour that
|
||||
* matters.
|
||||
*/
|
||||
export function UserMenu({ user, onSignOut }: { user: User | null; onSignOut: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const panelId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
trigger.current?.focus()
|
||||
}
|
||||
}
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!container.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const name = (user?.displayName || user?.email || 'Profile').trim()
|
||||
const email = user?.email ?? ''
|
||||
|
||||
return (
|
||||
<div className="user-menu" ref={container}>
|
||||
<button
|
||||
type="button"
|
||||
ref={trigger}
|
||||
className="user-menu-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<Avatar user={user} size="tiny" />
|
||||
{/* The trigger stays to the name alone: the address is one click away in
|
||||
the panel, and repeating it on every screen is noise. */}
|
||||
<span className="user-menu-identity">
|
||||
<span className="user-menu-name">{name}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="user-menu-panel" id={panelId} aria-label="Account">
|
||||
<div className="user-menu-header">
|
||||
<Avatar user={user} size="large" />
|
||||
<div>
|
||||
<p className="user-menu-panel-name">{name}</p>
|
||||
{email && <p className="user-menu-panel-email">{email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Link className="user-menu-item" to="/profile" onClick={() => setOpen(false)}>
|
||||
<UserRound size={18} aria-hidden="true" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="user-menu-item"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onSignOut()
|
||||
}}
|
||||
>
|
||||
<LogOut size={18} aria-hidden="true" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,9 @@
|
|||
import { CalendarDays, Mail } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import type { User } from '../../types'
|
||||
import { ThemeControls } from '../../theme'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
export function WorkspaceUtilities({
|
||||
user,
|
||||
onSignOut,
|
||||
}: {
|
||||
user: User | null
|
||||
onSignOut: () => void
|
||||
}) {
|
||||
export function WorkspaceUtilities({ user }: { user: User | null }) {
|
||||
const profileLabel = user?.displayName || user?.email || 'Profile'
|
||||
return (
|
||||
<nav className="workspace-utilities" aria-label="Account navigation">
|
||||
<NavLink className="utility-link" to="/invitations">
|
||||
|
|
@ -21,8 +14,12 @@ export function WorkspaceUtilities({
|
|||
<CalendarDays size={18} aria-hidden="true" />
|
||||
<span>Schedule</span>
|
||||
</NavLink>
|
||||
<ThemeControls />
|
||||
<UserMenu user={user} onSignOut={onSignOut} />
|
||||
<NavLink className="utility-link profile-utility" to="/profile">
|
||||
<span className="avatar tiny" aria-hidden="true">
|
||||
{profileLabel.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span>{profileLabel}</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { User } from '../../types'
|
||||
import { Avatar } from './Avatar'
|
||||
import { initialsFor } from './initials'
|
||||
|
||||
const withPicture = {
|
||||
displayName: 'Ada Lovelace',
|
||||
email: 'ada@example.test',
|
||||
avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc',
|
||||
}
|
||||
const withoutPicture = { displayName: 'Ada Lovelace', email: 'ada@example.test' }
|
||||
|
||||
function renderAvatar(user: Partial<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null) {
|
||||
return render(<Avatar user={user} />)
|
||||
}
|
||||
|
||||
describe('initialsFor', () => {
|
||||
it('uses the first letter of the first two words', () => {
|
||||
expect(initialsFor('Ada Lovelace')).toBe('AL')
|
||||
expect(initialsFor('Ada Byron King Lovelace')).toBe('AB')
|
||||
})
|
||||
|
||||
it('falls back to the address, and to a single name', () => {
|
||||
expect(initialsFor('', 'ada@example.test')).toBe('AD')
|
||||
expect(initialsFor('Prince')).toBe('PR')
|
||||
})
|
||||
|
||||
it('has something to show for nothing at all', () => {
|
||||
expect(initialsFor('', '')).toBe('?')
|
||||
expect(initialsFor(null, null)).toBe('?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders the picture Go Loose serves', () => {
|
||||
renderAvatar(withPicture)
|
||||
const image = screen.getByTestId('avatar-image').querySelector('img')
|
||||
expect(image).toHaveAttribute('src', withPicture.avatarUrl)
|
||||
// The surrounding element already names the person, so the image itself is
|
||||
// decorative and must not be announced twice.
|
||||
expect(image).toHaveAttribute('alt', '')
|
||||
})
|
||||
|
||||
it('falls back to initials when there is no picture', () => {
|
||||
renderAvatar(withoutPicture)
|
||||
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL')
|
||||
})
|
||||
|
||||
it('falls back to initials when the stored link no longer resolves', () => {
|
||||
// Go Loose retires a picture key when the picture changes, so a cached URL in
|
||||
// this browser can be dead. A broken image icon would be worse than initials.
|
||||
renderAvatar(withPicture)
|
||||
const image = screen.getByTestId('avatar-image').querySelector('img')!
|
||||
// An error event on an image does not bubble, so React only sees it when the
|
||||
// event is dispatched through the testing library.
|
||||
fireEvent.error(image)
|
||||
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL')
|
||||
})
|
||||
|
||||
it('has something to show for no user at all', () => {
|
||||
// With no person there is no name either, so the neutral placeholder the rest
|
||||
// of the interface uses stands in.
|
||||
renderAvatar(null)
|
||||
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('PR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Avatar sizing', () => {
|
||||
it('carries the requested size', () => {
|
||||
const { container } = render(<Avatar user={withoutPicture} size="tiny" className="extra" />)
|
||||
const avatar = container.querySelector('.avatar')!
|
||||
expect(avatar.className).toContain('tiny')
|
||||
expect(avatar.className).toContain('extra')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import type { User } from '../../types'
|
||||
import { initialsFor } from './initials'
|
||||
|
||||
/**
|
||||
* A person's picture, falling back to their initials.
|
||||
*
|
||||
* The URL comes from Go Loose and stops resolving the moment the picture is
|
||||
* replaced or removed there, because retiring the key is what stops a stale link
|
||||
* from being a privacy problem. A cached copy in this browser can therefore be
|
||||
* dead, so a failed load falls back rather than leaving a broken image. An empty
|
||||
* or absent URL is the common case for anyone who has not uploaded one.
|
||||
*/
|
||||
export function Avatar({
|
||||
user,
|
||||
size = 'default',
|
||||
className = '',
|
||||
}: {
|
||||
user: Partial<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null
|
||||
size?: 'tiny' | 'default' | 'large'
|
||||
className?: string
|
||||
}) {
|
||||
const url = user?.avatarUrl ?? ''
|
||||
// Remembering which URL failed, rather than a bare flag, means a new picture
|
||||
// for the same person is retried without an effect to reset anything.
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const failed = failedUrl === url && url !== ''
|
||||
|
||||
const classes = ['avatar', size === 'default' ? '' : size, className].filter(Boolean).join(' ')
|
||||
const label = (user?.displayName || user?.email || 'Profile').trim()
|
||||
|
||||
if (!url || failed) {
|
||||
return (
|
||||
<span className={classes} aria-hidden="true" data-testid="avatar-fallback">
|
||||
{initialsFor(label)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={classes} data-testid="avatar-image">
|
||||
<img src={url} alt="" onError={() => setFailedUrl(url)} />
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/**
|
||||
* The person's initials, used until a picture loads and whenever there is none.
|
||||
* The first letter of the first two words reads better than one letter, so
|
||||
* "Ada Lovelace" gives AL rather than A.
|
||||
*/
|
||||
export function initialsFor(name: string | null | undefined, email?: string | null): string {
|
||||
const source = (name ?? '').trim() || (email ?? '').trim()
|
||||
if (!source) return '?'
|
||||
// An address is not a name: "ada@example.test" must not become AE, so only the
|
||||
// part before the @ counts.
|
||||
const local = source.includes('@') ? source.slice(0, source.indexOf('@')) : source
|
||||
const words = local.split(/[\s._-]+/).filter(Boolean)
|
||||
if (words.length === 0) return '?'
|
||||
if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase()
|
||||
return (words[0]!.slice(0, 1) + words[1]!.slice(0, 1)).toUpperCase()
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LoginPage } from './LoginPage'
|
||||
|
||||
function stubLocation() {
|
||||
const location = { assign: vi.fn(), replace: vi.fn(), hostname: 'nmbs.guess.urpi.be' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: location,
|
||||
})
|
||||
return location
|
||||
}
|
||||
|
||||
function renderPage(ssoAvailable: boolean | null) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<LoginPage onLogin={vi.fn()} sessionExpired={false} ssoAvailable={ssoAvailable} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('offers only the Go Loose button while the answer is unknown', () => {
|
||||
renderPage(null)
|
||||
expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/^password$/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers only the Go Loose button where Go Loose is configured', () => {
|
||||
renderPage(true)
|
||||
expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends the browser to Go Loose rather than routing there', async () => {
|
||||
// A router push cannot follow a redirect chain across two origins, so this has
|
||||
// to be a real navigation.
|
||||
const location = stubLocation()
|
||||
const user = userEvent.setup()
|
||||
renderPage(true)
|
||||
await user.click(screen.getByRole('button', { name: /sign in with go loose/i }))
|
||||
expect(location.assign).toHaveBeenCalledWith('/api/auth/login')
|
||||
})
|
||||
|
||||
it('falls back to the password form on a host without Go Loose login', () => {
|
||||
renderPage(false)
|
||||
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /sign in with go loose/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says why the password form is being shown', () => {
|
||||
renderPage(false)
|
||||
expect(screen.getByText(/single sign-on is not configured/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,47 +1,20 @@
|
|||
import { ArrowRight, CircleHelp, KeyRound, Sparkles } from 'lucide-react'
|
||||
import { ArrowRight, CircleHelp, Sparkles } from 'lucide-react'
|
||||
import { SyntheticEvent, useState } from 'react'
|
||||
import Logo from '../../components/layout/Logo'
|
||||
import { FormField } from '../../components/form'
|
||||
import { ErrorAlert, errorMessage } from '../../components/actions'
|
||||
import { ThemeControls } from '../../theme'
|
||||
import { api, authStorage } from '../../api/client'
|
||||
|
||||
const LOGIN_PATH = '/api/auth/login'
|
||||
|
||||
/**
|
||||
* The sign-in screen.
|
||||
*
|
||||
* Signing in happens at Go Loose, so the usual case is one button that hands the
|
||||
* browser over to it and comes back to the welcome page. That also means a person
|
||||
* who already signed in at another application passes straight through, because
|
||||
* Go Loose recognises the session it already holds and issues a code without
|
||||
* asking anything.
|
||||
*
|
||||
* The password form is only for a host where Go Loose login is not configured:
|
||||
* the API answers /api/auth/session with 404 there, and without a fallback the
|
||||
* application would be unreachable on a bare localhost. It is not offered where
|
||||
* the identity provider is in charge.
|
||||
*/
|
||||
export function LoginPage({
|
||||
onLogin,
|
||||
sessionExpired,
|
||||
ssoAvailable,
|
||||
}: {
|
||||
onLogin: () => void
|
||||
sessionExpired: boolean
|
||||
/** null until the API has said whether Go Loose login is configured here. */
|
||||
ssoAvailable: boolean | null
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
function beginExternalLogin() {
|
||||
setLoading(true)
|
||||
// A full navigation, not a router push: Go Loose answers with a redirect
|
||||
// chain across two origins that the router has no part in.
|
||||
window.location.assign(LOGIN_PATH)
|
||||
}
|
||||
|
||||
async function submit(event: SyntheticEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setLoading(true)
|
||||
|
|
@ -74,10 +47,7 @@ export function LoginPage({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-foot">
|
||||
<ThemeControls />
|
||||
<p>Designed for fair, collaborative recruitment.</p>
|
||||
</div>
|
||||
<p className="login-foot">Designed for fair, collaborative recruitment.</p>
|
||||
</section>
|
||||
<section className="login-panel" aria-labelledby="login-title">
|
||||
<div className="login-card">
|
||||
|
|
@ -88,46 +58,25 @@ export function LoginPage({
|
|||
Your session is no longer active. Sign in again to continue.
|
||||
</div>
|
||||
)}
|
||||
<p className="muted">
|
||||
Use your work account to continue. If you are already signed in elsewhere, this takes
|
||||
you straight through.
|
||||
</p>
|
||||
|
||||
{ssoAvailable === false ? (
|
||||
<form onSubmit={submit} className="stack-form">
|
||||
<p className="muted">
|
||||
Single sign-on is not configured for this host. Sign in with a local account.
|
||||
</p>
|
||||
<FormField label="Email address" htmlFor="email">
|
||||
<input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="password">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<button className="button primary wide" type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="stack-form">
|
||||
<button
|
||||
className="button primary wide"
|
||||
type="button"
|
||||
onClick={beginExternalLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Redirecting…' : 'Sign in with Go Loose'} <KeyRound size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="muted">Use your work account to continue.</p>
|
||||
<form onSubmit={submit} className="stack-form">
|
||||
<FormField label="Email address" htmlFor="email">
|
||||
<input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="password">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<button className="button primary wide" type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="demo-note">
|
||||
<CircleHelp size={16} /> Contact your administrator for access.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Mail, ShieldCheck, UserRound } from 'lucide-react'
|
||||
import { authStorage } from '../../api/client'
|
||||
import { Avatar } from '../../components/ui/Avatar'
|
||||
|
||||
export function ProfilePage() {
|
||||
const user = authStorage.user()
|
||||
|
|
@ -15,8 +14,9 @@ export function ProfilePage() {
|
|||
</header>
|
||||
<section className="card profile-card">
|
||||
<div className="profile-identity">
|
||||
{/* The picture Go Loose serves, with the initials as the fallback. */}
|
||||
<Avatar user={user} size="large" className="profile-avatar" />
|
||||
<span className="avatar profile-avatar" aria-hidden="true">
|
||||
{user.displayName.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<h1>{user.displayName}</h1>
|
||||
<span className={`status status-${user.role}`}>{user.role}</span>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import {StrictMode} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import {BrowserRouter} from 'react-router-dom'
|
||||
import App from './app/App'
|
||||
import { installThemeRuntime } from './theme/installThemeRuntime'
|
||||
import './styles/global.css'
|
||||
|
||||
// Started before the first render so the contract stylesheet is in the document
|
||||
// while the tree mounts. Nothing awaits it: a slow or absent theme service must
|
||||
// not delay the application, and the page is correct either way.
|
||||
void installThemeRuntime()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App/>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,71 +1,24 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap');
|
||||
|
||||
/* Design tokens.
|
||||
*
|
||||
* Every value here is `var(--bn-*, fallback)`. The contract stylesheet published
|
||||
* by go-bananas defines the --bn-* half at run time from whichever palette and
|
||||
* appearance are selected; the second argument is this application's own value,
|
||||
* used whenever that stylesheet is absent.
|
||||
*
|
||||
* That ordering is what makes the feature safe to adopt: with no theme service
|
||||
* configured the page renders exactly as it did before, and with one configured
|
||||
* the same rules follow the palette. Nothing below this block needs to know which
|
||||
* of the two it is getting. */
|
||||
:root {
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--page);
|
||||
color: #142b3a;
|
||||
background: #f3f7f8;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
/* Surfaces, from the page up to the most raised. */
|
||||
--page: var(--bn-bg, #f3f7f8);
|
||||
--surface: var(--bn-surface, #ffffff);
|
||||
--raised: var(--bn-surface-raised, #ffffff);
|
||||
--sunken: var(--bn-surface-sunken, #eef3f4);
|
||||
|
||||
/* The navigation rail is a distinct plane rather than another surface, so it
|
||||
* takes the sunken step: readable against the page in either appearance. */
|
||||
--navy: var(--bn-surface-sunken, #081c2c);
|
||||
--navy-soft: var(--bn-surface, #123449);
|
||||
/* Text on the rail. It comes from the contract's own foreground pairing rather
|
||||
* than being fixed to white, because a light appearance wants dark text there. */
|
||||
--on-rail: var(--bn-on-secondary, #ffffff);
|
||||
|
||||
/* Text. */
|
||||
--ink: var(--bn-text, #142b3a);
|
||||
--muted: var(--bn-muted, #667986);
|
||||
--subtle: var(--bn-subtle, #8a9aa4);
|
||||
|
||||
/* Lines. */
|
||||
--line: var(--bn-border, #dce5e8);
|
||||
--line-strong: var(--bn-border-strong, #c2d2d6);
|
||||
|
||||
/* Accents. */
|
||||
--cyan: var(--bn-accent, #00a9c8);
|
||||
--cyan-dark: var(--bn-accent-hover, #007f98);
|
||||
--lime: var(--bn-secondary, #d9ef4d);
|
||||
--orange: var(--bn-warning, #ff7c52);
|
||||
--good: var(--bn-good, #13704d);
|
||||
--danger: var(--bn-danger, #b3261e);
|
||||
--focus: var(--bn-focus, #007f98);
|
||||
|
||||
/* Shape and depth. */
|
||||
--radius: var(--bn-radius-lg, 16px);
|
||||
--radius-sm: var(--bn-radius-md, 10px);
|
||||
--shadow: var(--bn-shadow-md, 0 12px 35px rgba(14, 42, 56, 0.08));
|
||||
--navy: #081c2c;
|
||||
--navy-soft: #123449;
|
||||
--ink: #142b3a;
|
||||
--muted: #667986;
|
||||
--line: #dce5e8;
|
||||
--surface: #ffffff;
|
||||
--cyan: #00a9c8;
|
||||
--cyan-dark: #007f98;
|
||||
--lime: #d9ef4d;
|
||||
--orange: #ff7c52;
|
||||
--radius: 16px;
|
||||
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
|
||||
}
|
||||
|
||||
/* Native widgets follow the selected appearance, so a scrollbar, a text
|
||||
* selection, or a date picker in a form is not light-on-light in dark mode. The
|
||||
* light default keeps a browser without a theme service exactly as it was. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
:root[data-mode='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.eligible-row {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
|
@ -78,7 +31,7 @@
|
|||
}
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--page);
|
||||
background: #f3f7f8;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
|
|
@ -294,7 +247,7 @@ button {
|
|||
}
|
||||
.login-story {
|
||||
background: var(--navy);
|
||||
color: var(--on-rail);
|
||||
color: white;
|
||||
padding: 2.2rem clamp(2rem, 6vw, 6rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -372,12 +325,12 @@ button {
|
|||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
background: var(--page);
|
||||
background: #f3f7f8;
|
||||
}
|
||||
.login-card {
|
||||
width: min(430px, 100%);
|
||||
padding: clamp(1.8rem, 5vw, 3rem);
|
||||
background: var(--raised);
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
|
@ -411,7 +364,7 @@ button {
|
|||
justify-content: center;
|
||||
}
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
color: #a92e2e;
|
||||
}
|
||||
.error-alert {
|
||||
display: flex;
|
||||
|
|
@ -458,7 +411,7 @@ button {
|
|||
inset: 0 auto 0 0;
|
||||
width: 255px;
|
||||
background: var(--navy);
|
||||
color: var(--on-rail);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.6rem 1.1rem;
|
||||
|
|
@ -721,8 +674,8 @@ button {
|
|||
color: var(--cyan-dark);
|
||||
}
|
||||
.card {
|
||||
background: var(--raised);
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
border: 1px solid #e1e9eb;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
|
||||
}
|
||||
|
|
@ -2849,135 +2802,3 @@ fieldset {
|
|||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Account menu.
|
||||
The trigger sits in the utilities bar and the panel is anchored to it, so the
|
||||
panel is positioned against a relatively positioned wrapper rather than the
|
||||
viewport: it must stay with its trigger when the utilities bar wraps. */
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
.user-menu-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 40px;
|
||||
padding: 0.3rem 0.7rem 0.3rem 0.35rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.user-menu-trigger:hover,
|
||||
.user-menu-trigger:focus-visible {
|
||||
border-color: var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
.user-menu-trigger:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.user-menu-identity {
|
||||
display: grid;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.user-menu-name {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.user-menu-email {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.user-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 16rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.user-menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.6rem 0.7rem 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.user-menu-header p {
|
||||
margin: 0;
|
||||
}
|
||||
.user-menu-panel-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.user-menu-panel-email {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-menu-item:hover,
|
||||
.user-menu-item:focus-visible {
|
||||
background: #eef6f7;
|
||||
}
|
||||
.user-menu-item:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.user-menu-panel + .user-menu-item {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
/* The sidebar carries the same actions on a narrow screen, so the menu keeps
|
||||
only the picture and its accessible name. */
|
||||
.user-menu-identity {
|
||||
display: none;
|
||||
}
|
||||
.user-menu-trigger {
|
||||
padding: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme controls.
|
||||
The controls render into their own shadow root, so nothing here can reach
|
||||
inside them and nothing inside can leak out. They inherit the custom
|
||||
properties that cross the boundary, which is why they follow the palette
|
||||
without a single rule of their own. */
|
||||
.theme-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.login-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { themeBaseUrl } from './installThemeRuntime'
|
||||
|
||||
/**
|
||||
* The palette picker and the appearance toggle.
|
||||
*
|
||||
* Both are the custom elements go-bananas ships rather than components written
|
||||
* here. That is the whole point of the service: the applications it feeds are a
|
||||
* mix of React, vanilla JavaScript and server-rendered HTML, and a custom element
|
||||
* is the one shape all three can use without a build step, an import map, or a
|
||||
* second copy of React. React treats an unknown element as an inert host node, so
|
||||
* rendering them unconditionally is safe: with no theme service configured they
|
||||
* simply stay empty.
|
||||
*
|
||||
* Neither takes a `base` prop. They read the service origin from the contract
|
||||
* stylesheet already on the page, which is the one place it is configured, so
|
||||
* there is no second place to get wrong.
|
||||
*
|
||||
* Whether to render at all is a plain read rather than state: the runtime config
|
||||
* is written before the first render and does not change afterwards, so there is
|
||||
* nothing to track and no effect to wait on.
|
||||
*/
|
||||
export function ThemeControls() {
|
||||
if (!themeBaseUrl()) return null
|
||||
|
||||
return (
|
||||
<div className="theme-controls">
|
||||
<bananas-appearance-toggle label="Appearance" />
|
||||
<bananas-theme-selector label="Palette" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontend/src/theme/bananas-elements.d.ts
vendored
27
frontend/src/theme/bananas-elements.d.ts
vendored
|
|
@ -1,27 +0,0 @@
|
|||
import type { DetailedHTMLProps, HTMLAttributes } from 'react'
|
||||
|
||||
/**
|
||||
* The custom elements go-bananas ships.
|
||||
*
|
||||
* React needs to be told about an unknown element before it will render one, and
|
||||
* declaring them here is the whole integration: the tags, the attributes they
|
||||
* accept, and nothing else. The behaviour lives in the service, so there is
|
||||
* nothing to keep in step.
|
||||
*
|
||||
* `label` is read by both controls for their accessible name.
|
||||
*/
|
||||
type BananasControl = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
|
||||
/** Accessible name for the control. */
|
||||
label?: string
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'bananas-theme-selector': BananasControl
|
||||
'bananas-appearance-toggle': BananasControl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
// The stylesheet is imported as text rather than read from disk: this keeps Node
|
||||
// APIs out of a project whose tsconfig deliberately does not include them, so
|
||||
// application code cannot reach for one by accident.
|
||||
import stylesheet from '../styles/global.css?raw'
|
||||
|
||||
/**
|
||||
* The contract is a remote document, so the way this application consumes it can
|
||||
* rot quietly: a token renamed on the service leaves every `var(--bn-*, fallback)`
|
||||
* resolving to its fallback, and the page looks fine while never actually
|
||||
* following the palette. Nothing else would catch that.
|
||||
*
|
||||
* This reads the stylesheet, takes every contract variable the application asks
|
||||
* for, and checks the real contract defines each one. It is skipped when no theme
|
||||
* service is configured, so an ordinary checkout does not need one.
|
||||
*/
|
||||
const baseUrl = (
|
||||
import.meta.env.THEME_BASE_URL ??
|
||||
import.meta.env.VITE_THEME_BASE_URL ??
|
||||
''
|
||||
).replace(/\/+$/, '')
|
||||
|
||||
/** Every `--bn-*` name the stylesheet consumes. */
|
||||
function contractVariablesUsed(): string[] {
|
||||
const found = new Set<string>()
|
||||
const pattern = /var\(\s*(--bn-[a-z0-9-]+)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(stylesheet)) !== null) {
|
||||
found.add(match[1]!)
|
||||
}
|
||||
return [...found].sort()
|
||||
}
|
||||
|
||||
describe('the styling contract', () => {
|
||||
const used = contractVariablesUsed()
|
||||
|
||||
it('consumes the contract rather than hardcoding a palette', () => {
|
||||
expect(used.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('gives every contract variable a fallback, so a missing service changes nothing', () => {
|
||||
// Each use must carry a second argument. A bare var(--bn-x) with no fallback
|
||||
// resolves to nothing at all when the service is absent, which is how a
|
||||
// half-configured deployment ends up with invisible text.
|
||||
const bare = [...stylesheet.matchAll(/var\(\s*(--bn-[a-z0-9-]+)\s*\)/g)].map((m) => m[1]!)
|
||||
expect(bare).toEqual([])
|
||||
})
|
||||
|
||||
it.runIf(baseUrl)('defines every variable this application asks for', async () => {
|
||||
const response = await fetch(`${baseUrl}/rt/v1/contract.css`)
|
||||
expect(response.status, `fetching the contract from ${baseUrl}`).toBe(200)
|
||||
const contract = await response.text()
|
||||
|
||||
const defined = new Set<string>()
|
||||
for (const match of contract.matchAll(/(--bn-[a-z0-9-]+)\s*:/g)) {
|
||||
defined.add(match[1]!)
|
||||
}
|
||||
|
||||
const missing = used.filter((name) => !defined.has(name))
|
||||
expect(missing, `the contract at ${baseUrl} does not define these`).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { ThemeControls } from './ThemeControls'
|
||||
export { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'
|
||||
|
||||
const CONTRACT_LINK_MARKER = 'data-theme-contract'
|
||||
|
||||
function config(themeBaseUrl: string) {
|
||||
window.__GO_GUESS_CONFIG__ = { themeBaseUrl }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__GO_GUESS_CONFIG__
|
||||
document.head.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('themeBaseUrl', () => {
|
||||
it('is empty when nothing is configured', () => {
|
||||
expect(themeBaseUrl()).toBe('')
|
||||
})
|
||||
|
||||
it('reads the runtime config the entrypoint writes', () => {
|
||||
config('https://themes.example.com')
|
||||
expect(themeBaseUrl()).toBe('https://themes.example.com')
|
||||
})
|
||||
|
||||
it('tolerates a trailing slash, which would otherwise double up in the path', () => {
|
||||
config('https://themes.example.com/')
|
||||
expect(themeBaseUrl()).toBe('https://themes.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installThemeRuntime', () => {
|
||||
it('does nothing when no theme service is configured', async () => {
|
||||
config('')
|
||||
await installThemeRuntime()
|
||||
expect(document.head.querySelector(`link[${CONTRACT_LINK_MARKER}]`)).toBeNull()
|
||||
})
|
||||
|
||||
it('adds the contract stylesheet, marked so the controls can find the origin', async () => {
|
||||
config('https://themes.example.com')
|
||||
await installThemeRuntime()
|
||||
const link = document.head.querySelector<HTMLLinkElement>(`link[${CONTRACT_LINK_MARKER}]`)
|
||||
expect(link).not.toBeNull()
|
||||
expect(link!.rel).toBe('stylesheet')
|
||||
expect(link!.href).toBe('https://themes.example.com/rt/v1/contract.css')
|
||||
})
|
||||
|
||||
it('adds the stylesheet only once, however often it is called', async () => {
|
||||
config('https://themes.example.com')
|
||||
await installThemeRuntime()
|
||||
await installThemeRuntime()
|
||||
await installThemeRuntime()
|
||||
expect(document.head.querySelectorAll(`link[${CONTRACT_LINK_MARKER}]`)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves rather than throwing when the module cannot be fetched', async () => {
|
||||
// A theme service that is down must not stop the application. The controls are
|
||||
// custom elements, so failing to load leaves inert elements and the page keeps
|
||||
// the values baked into its own stylesheet.
|
||||
config('https://themes.example.com')
|
||||
await expect(installThemeRuntime()).resolves.toBeUndefined()
|
||||
// The stylesheet is still requested, so the palette follows whatever
|
||||
// selection is already stored even with no picker to change it.
|
||||
expect(document.head.querySelector(`link[${CONTRACT_LINK_MARKER}]`)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
/**
|
||||
* Where the theme service lives, and whether it answered.
|
||||
*
|
||||
* The URL is read from the runtime config rather than baked into the bundle so
|
||||
* one image serves every environment: the entrypoint rewrites runtime-config.js
|
||||
* from the environment on start. A build-time variable is only the convenience
|
||||
* for `npm run dev`, where there is no entrypoint to do it.
|
||||
*/
|
||||
export interface RuntimeConfig {
|
||||
/** Origin of the go-bananas runtime, or empty when theming is not configured. */
|
||||
themeBaseUrl?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__GO_GUESS_CONFIG__?: RuntimeConfig
|
||||
}
|
||||
}
|
||||
|
||||
/** The marker the shipped controls read the base URL from. */
|
||||
const CONTRACT_LINK_MARKER = 'data-theme-contract'
|
||||
|
||||
export function themeBaseUrl(): string {
|
||||
const configured = window.__GO_GUESS_CONFIG__?.themeBaseUrl
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
const built = import.meta.env.VITE_THEME_BASE_URL
|
||||
return built ? built.replace(/\/+$/, '') : ''
|
||||
}
|
||||
|
||||
/** Whether the contract stylesheet is already on the page. */
|
||||
function contractLinked(): boolean {
|
||||
return document.querySelector(`link[${CONTRACT_LINK_MARKER}]`) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the shared styling contract and the two controls, once.
|
||||
*
|
||||
* The stylesheet goes in first and synchronously-ish, before the controls, so the
|
||||
* palette is applied before anything paints rather than flashing the application's
|
||||
* own colours and then swapping them.
|
||||
*
|
||||
* Everything here is best effort. A theme service that is unreachable, slow, or
|
||||
* not configured must not stop the application from working, so every failure
|
||||
* resolves quietly and the page keeps the values baked into the stylesheet.
|
||||
*/
|
||||
export async function installThemeRuntime(): Promise<void> {
|
||||
const base = themeBaseUrl()
|
||||
if (!base || contractLinked()) return
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `${base}/rt/v1/contract.css`
|
||||
link.setAttribute(CONTRACT_LINK_MARKER, '')
|
||||
document.head.append(link)
|
||||
|
||||
try {
|
||||
await import(/* @vite-ignore */ `${base}/rt/v1/components.js`)
|
||||
} catch {
|
||||
// The controls are custom elements, so a failure here leaves inert elements
|
||||
// in the page rather than an error. The contract stylesheet still applies, so
|
||||
// the palette follows the stored selection even without the picker.
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,6 @@ export interface User {
|
|||
email: string
|
||||
displayName: string
|
||||
role: string
|
||||
/**
|
||||
* Absolute URL of the picture Go Loose serves for this person, or empty when
|
||||
* they have not uploaded one. Resolved on every sign-in rather than stored, so
|
||||
* it always reflects the current picture.
|
||||
*/
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
|
|
|||
|
|
@ -4,14 +4,7 @@ import { defineConfig } from 'vitest/config'
|
|||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: [
|
||||
'nmbs.guess-dev.urpi.be',
|
||||
'ypto.guess-dev.urpi.be',
|
||||
'nmbs.guess.urpi.be',
|
||||
'ypto.guess.urpi.be',
|
||||
'nmbs.guess.local',
|
||||
'ypto.guess.local',
|
||||
],
|
||||
allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
|
|
|
|||
179
rancher-install.md
Normal file
179
rancher-install.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# Rancher single-server installation
|
||||
|
||||
This runbook reproduces the local Rancher management instance installed on the
|
||||
dedicated server. It binds Rancher only to loopback; Nginx and Certbot provide
|
||||
public access separately.
|
||||
|
||||
This Docker-based Rancher installation is appropriate for a single-server,
|
||||
non-HA environment. A production environment that requires control-plane high
|
||||
availability should run Rancher with its Helm chart on a dedicated multi-node
|
||||
Kubernetes cluster instead of copying this single-container topology.
|
||||
|
||||
## Installed versions and paths
|
||||
|
||||
- Rancher: `v2.15.1`
|
||||
- Image:
|
||||
`rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5`
|
||||
- Persistent data: `/opt/rancher`
|
||||
- Registry configuration: `~/.config/kubectl/registries.yaml`
|
||||
- Local HTTP: `http://127.0.0.1:8000`
|
||||
- Local HTTPS: `https://127.0.0.1:8443`
|
||||
- Development application NodePort: `http://127.0.0.1:30080`
|
||||
- Production application NodePort: `http://127.0.0.1:31374`
|
||||
|
||||
Pinning the digest makes another installation reproducible. Review and update
|
||||
the Rancher version deliberately rather than running an unpinned `latest` image
|
||||
in production.
|
||||
|
||||
## 1. Configure the HTTP image registry
|
||||
|
||||
The source file is committed at `deploy/rancher/registries.yaml`. Install it:
|
||||
|
||||
```bash
|
||||
install -Dm0644 deploy/rancher/registries.yaml \
|
||||
"$HOME/.config/kubectl/registries.yaml"
|
||||
```
|
||||
|
||||
Its content is:
|
||||
|
||||
```yaml
|
||||
mirrors:
|
||||
"batty1039.startdedicated.net:5000":
|
||||
endpoint:
|
||||
- "http://batty1039.startdedicated.net:5000"
|
||||
|
||||
configs:
|
||||
"batty1039.startdedicated.net:5000":
|
||||
tls:
|
||||
insecure_skip_verify: true
|
||||
```
|
||||
|
||||
The original draft contained duplicate YAML keys. Duplicate keys are invalid
|
||||
for this purpose because parsers may silently discard one occurrence.
|
||||
|
||||
## 2. Prepare persistent storage
|
||||
|
||||
```bash
|
||||
sudo install -d -m 0755 /opt/rancher
|
||||
```
|
||||
|
||||
Back up this directory before Rancher upgrades. It contains the embedded K3s
|
||||
management cluster and all Rancher state.
|
||||
|
||||
## 3. Pull and run Rancher
|
||||
|
||||
```bash
|
||||
docker pull \
|
||||
rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5
|
||||
|
||||
docker run -d \
|
||||
--restart=unless-stopped \
|
||||
-p 127.0.0.1:8000:80 \
|
||||
-p 127.0.0.1:8443:443 \
|
||||
-p 127.0.0.1:10000:10000 \
|
||||
-p 127.0.0.1:10100:10100 \
|
||||
-v /opt/rancher:/var/lib/rancher \
|
||||
-v "$HOME/.config/kubectl/registries.yaml:/etc/rancher/k3s/registries.yaml:ro" \
|
||||
--privileged \
|
||||
--name rancher \
|
||||
rancher/rancher@sha256:5f6c4dc52a05e0c400b53c08bf778ca55790f277e443b5881269158499b7ebe5
|
||||
```
|
||||
|
||||
The explicit `127.0.0.1` bindings prevent Rancher and application NodePorts
|
||||
from being published directly to the internet. Nginx on the same server can
|
||||
still reach them. Docker cannot add port mappings to an existing container, so
|
||||
new NodePorts must be included when recreating the Rancher container.
|
||||
|
||||
## 4. Verify the installation
|
||||
|
||||
Wait until the readiness endpoint returns `pong`:
|
||||
|
||||
```bash
|
||||
until curl -ksSf https://127.0.0.1:8443/ping | grep -q pong; do
|
||||
sleep 5
|
||||
done
|
||||
|
||||
docker ps --filter name='^/rancher$'
|
||||
docker exec rancher kubectl get nodes -o wide
|
||||
```
|
||||
|
||||
Expected results:
|
||||
|
||||
- The container is running with restart policy `unless-stopped`.
|
||||
- Only loopback ports `8000`, `8443`, `30080`, and `31374` are published.
|
||||
- `https://127.0.0.1:8443/ping` returns `pong`.
|
||||
- The embedded `local-node` reports `Ready`.
|
||||
|
||||
After deploying Go Guess, verify the application NodePort:
|
||||
|
||||
```bash
|
||||
curl --fail http://127.0.0.1:30080/api/health
|
||||
```
|
||||
|
||||
Production PostgreSQL and RabbitMQ require persistent volumes. Install the
|
||||
single-node local-path provisioner before the production Helm release:
|
||||
|
||||
```bash
|
||||
make rancher-storage
|
||||
make helm-deploy-production
|
||||
```
|
||||
|
||||
The provisioner stores data under `/var/lib/rancher/k3s/storage`, which is
|
||||
inside the persistent `/opt/rancher` host mount. Its StorageClass uses a
|
||||
`Retain` reclaim policy so deleting a claim does not automatically delete the
|
||||
underlying production data.
|
||||
|
||||
Retrieve the one-time bootstrap password locally, open
|
||||
`https://127.0.0.1:8443`, and immediately choose a permanent administrator
|
||||
password:
|
||||
|
||||
```bash
|
||||
docker logs rancher 2>&1 |
|
||||
sed -n 's/.*Bootstrap Password: //p' |
|
||||
tail -n 1
|
||||
```
|
||||
|
||||
Do not save that password in this repository.
|
||||
|
||||
## 5. Add Nginx and Certbot separately
|
||||
|
||||
For a public Rancher hostname, configure one.com DNS to point at the dedicated
|
||||
server. Proxy Nginx to `https://127.0.0.1:8443`, enable WebSocket upgrade
|
||||
headers, and disable upstream certificate verification because Rancher's local
|
||||
certificate is self-signed. Then request the public certificate:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d rancher.urpi.be
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
Expose only public TCP ports `80` and `443`. Keep `8000` and `8443` bound to
|
||||
loopback as configured above.
|
||||
|
||||
## Operations
|
||||
|
||||
Inspect status and logs:
|
||||
|
||||
```bash
|
||||
docker ps --filter name='^/rancher$'
|
||||
docker logs --tail 200 rancher
|
||||
```
|
||||
|
||||
Restart Rancher:
|
||||
|
||||
```bash
|
||||
docker restart rancher
|
||||
```
|
||||
|
||||
Create a consistent backup:
|
||||
|
||||
```bash
|
||||
docker stop rancher
|
||||
sudo tar -C /opt -czf "rancher-backup-$(date +%Y%m%d-%H%M%S).tar.gz" rancher
|
||||
docker start rancher
|
||||
```
|
||||
|
||||
Before an upgrade, read the Rancher release notes and compatibility matrix,
|
||||
back up `/opt/rancher`, pull a specifically pinned replacement image, remove
|
||||
only the stopped `rancher` container, and recreate it with the same mounts and
|
||||
port bindings.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
set -eu
|
||||
|
||||
github_url='https://github.com/danyel/go-guess.git'
|
||||
forgejo_url='https://forgejo.urpi.be/exr462/go-guess.git'
|
||||
forgejo_url='https://forgejo.dev/exr462/go-guess.git'
|
||||
remote='origin'
|
||||
|
||||
current_url=$(git remote get-url "$remote")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue