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.
|
domain types, and `src/styles/global.css` contains application-wide styles.
|
||||||
Keep feature-specific components inside their feature directory. Vite proxies
|
Keep feature-specific components inside their feature directory. Vite proxies
|
||||||
`/api` to the Go service in development.
|
`/api` to the Go service in development.
|
||||||
- Multi-tenant URLs use `<tenant>.<app domain>`, where the app domain is
|
- Local multi-tenant URLs use `<tenant>.guess.local`. The frontend derives the
|
||||||
`guess-dev.urpi.be` in local development and the Kubernetes development
|
tenant from the hostname, calls same-origin `/api` through the HTTPS proxy, and
|
||||||
profile, and `guess.urpi.be` in production. The frontend derives the tenant from
|
sends `X-Tenant-Id` on every request type. Other deployments use same-origin
|
||||||
the hostname, calls same-origin `/api` through the HTTPS proxy, and sends
|
`/api`. Generated participant links must use the validated matching tenant
|
||||||
`X-Tenant-Id` on every request type. Generated participant links must use the
|
origin. Keep this contract documented in `README.md` and `frontend/README.md`.
|
||||||
validated matching tenant origin. Keep this contract documented in `README.md`
|
- Protected interviewer routes also require Go Loose `X-API-Key` authorization
|
||||||
and `frontend/README.md`.
|
for application `guess`. The tenant passed to Go Loose comes from
|
||||||
- Protected interviewer routes require a bearer JWT only. Go Loose is used for
|
`X-Tenant-Id`. `GO_LOOSE_BASE_URL` defaults to `https://%s.auth.dev`;
|
||||||
browser login, not for API-key authorization.
|
`%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.
|
Goose files in `backend/migrations` are schema-only and run in every environment.
|
||||||
`internal/database/seed/common.sql` contains shared reference data;
|
`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
|
Authentication uses bcrypt passwords and HMAC-signed bearer JWTs. Domain routes are
|
||||||
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
|
protected except `/api/health`, `/api/openapi.json`, `/api/auth/login`,
|
||||||
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
|
`/api/auth/callback`, `/api/auth/logout`, `/api/auth/session`, and opaque-token
|
||||||
participant interview routes. Any tenant host starts browser login with
|
participant interview routes. Tenant hosts `nmbs.guess.dev` and `ypto.guess.dev`
|
||||||
`github.com/danyel/go-loose/client`: `/api/auth/login` redirects to
|
start login with `github.com/danyel/go-loose/client`: `/api/auth/login` redirects
|
||||||
`https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize` and Go Loose returns to
|
to `https://<tenant>.auth.dev/connect/authorize` and Go Loose returns to
|
||||||
`/api/auth/callback`. `GO_LOOSE_AUTH_DOMAIN` is `auth-dev.urpi.be` locally and in
|
`/api/auth/callback`. Go Loose client credentials come from uncommitted
|
||||||
the development profile, and `auth.urpi.be` in production; `GO_LOOSE_APP_DOMAIN`
|
environment variables. Missing tenant context uses the public schema; a
|
||||||
is the matching application domain. Both default to the development domains.
|
`*.guess.dev` host supplies the schema when `X-Tenant-Id` is absent. Never expose
|
||||||
`GET /api/auth/session` answers `404` when no Go Loose login is configured for the
|
password hashes, CV/photo bytes, or interviewer reference answers in public JSON.
|
||||||
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.
|
|
||||||
|
|
||||||
Questions are permanent reusable records linked to jobs through
|
Questions are permanent reusable records linked to jobs through
|
||||||
`job_posting_questions`; detach links rather than deleting questions. Deprecated
|
`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
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_NAME }}:latest
|
${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.IMAGE_NAME }}:${{ forgejo.sha }}
|
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
|
cache-from: type=gha
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max
|
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/nginx.conf /etc/nginx/nginx.conf
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint
|
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
|
USER app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENV ADDRESS=:8081 \
|
ENV ADDRESS=:8081 \
|
||||||
|
|
|
||||||
71
Makefile
71
Makefile
|
|
@ -1,5 +1,5 @@
|
||||||
SHELL := /bin/sh
|
SHELL := /bin/sh
|
||||||
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
|
|
||||||
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
|
DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable
|
||||||
JWT_SECRET ?= local-development-secret-change-me-12345
|
JWT_SECRET ?= local-development-secret-change-me-12345
|
||||||
GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0
|
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 ?= helm
|
||||||
HELM_CHART := deploy/helm/go-guess
|
HELM_CHART := deploy/helm/go-guess
|
||||||
HELM_RELEASE ?= go-guess
|
HELM_RELEASE ?= go-guess
|
||||||
HELM_NAMESPACE ?= go-insane
|
HELM_NAMESPACE ?= go-guess
|
||||||
KUBECONFIG ?= $(HOME)/.kube/config
|
KUBECONFIG ?= $(HOME)/.config/kubectl/rancher.urpi.local.yaml
|
||||||
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:-}"
|
|
||||||
|
|
||||||
.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:
|
help:
|
||||||
@echo "make dev builds the demo docker image"
|
@echo "make dev builds the demo docker image"
|
||||||
|
|
@ -37,13 +32,7 @@ help:
|
||||||
@echo "make test-env-down stops the test image"
|
@echo "make test-env-down stops the test image"
|
||||||
@echo "make lint runs lint on the frontend"
|
@echo "make lint runs lint on the frontend"
|
||||||
@echo "make build builds the entire project"
|
@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 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-lint lints development and production Helm configurations"
|
||||||
@echo "make helm-deploy-development deploys the development Helm release"
|
@echo "make helm-deploy-development deploys the development Helm release"
|
||||||
@echo "make helm-deploy-production deploys the production Helm release"
|
@echo "make helm-deploy-production deploys the production Helm release"
|
||||||
|
|
@ -112,41 +101,9 @@ build:
|
||||||
cd backend && go build ./...
|
cd backend && go build ./...
|
||||||
cd frontend && npm run 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:
|
rancher-storage:
|
||||||
KUBECONFIG="$(KUBECONFIG)" kubectl apply -f deploy/rancher/local-path.yaml
|
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:
|
helm-lint:
|
||||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
|
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
|
||||||
KUBECONFIG="$(KUBECONFIG)" $(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-production.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
|
-f $(HELM_CHART)/values-production.yaml >/dev/null
|
||||||
|
|
||||||
helm-deploy-development:
|
helm-deploy-development:
|
||||||
$(HELM_KUBECTL) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
|
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
|
||||||
--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
|
|
||||||
|
|
||||||
helm-deploy-production:
|
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 \
|
--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.postgresqlPassword="go_guess" \
|
||||||
--set secrets.rabbitmqPassword="guest" \
|
--set secrets.rabbitmqPassword="guest"
|
||||||
$(GO_LOOSE_SET) \
|
|
||||||
--wait --timeout 10m
|
|
||||||
|
|
||||||
helm-down-production:
|
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-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
|
`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
|
<https://nmbs.guess.dev> and <https://ypto.guess.dev>. Add the hostnames once:
|
||||||
sign-in goes through the development Go Loose installation at
|
|
||||||
<https://nmbs.auth-dev.urpi.be>. Both domains must resolve to the local edge:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
127.0.0.1 nmbs.guess-dev.urpi.be ypto.guess-dev.urpi.be
|
127.0.0.1 nmbs.guess.dev ypto.guess.dev
|
||||||
127.0.0.1 nmbs.auth-dev.urpi.be ypto.auth-dev.urpi.be
|
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
|
Start the shared TLS proxy described in
|
||||||
`/home/dnoulet/go/infrastructure/docs/APPLICATIONS.md`, then open
|
`/home/dnoulet/go/infrasctruture/docs/APPLICATIONS.md`, then open
|
||||||
<https://nmbs.guess-dev.urpi.be> or <https://ypto.guess-dev.urpi.be>. The
|
<https://nmbs.guess.dev> or <https://ypto.guess.dev>. The hostname's first
|
||||||
hostname's first label is the tenant ID: the frontend calls same-origin `/api` and
|
label is the tenant ID: the frontend calls same-origin `/api` and sends the same value in the
|
||||||
sends the same value in the `X-Tenant-Id` header on JSON, multipart,
|
`X-Tenant-Id` header on JSON, multipart, file-download, and event-stream
|
||||||
file-download, and event-stream requests. The backend uses that tenant ID as the
|
requests. The backend uses that tenant ID as the isolated PostgreSQL schema and
|
||||||
isolated PostgreSQL schema and builds participant invitation and meeting links
|
builds participant invitation and meeting links from the matching tenant browser
|
||||||
from the matching tenant browser origin instead of the static localhost fallback.
|
origin instead of the static localhost fallback.
|
||||||
|
|
||||||
Compose mounts the infrastructure CA from
|
Compose mounts the infrastructure CA from
|
||||||
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API at
|
`${LOCAL_CA_FILE:-../infrastructure/certs/local-ca.crt}` into the API. The
|
||||||
`/certs/local-ca.crt`, which `GO_LOOSE_CA_FILE` points at so the API trusts the
|
browser is redirected through the matching Go Loose tenant and returns to
|
||||||
local Traefik certificate. The browser is redirected through the matching Go Loose
|
`/api/auth/callback`. Set the per-tenant `GO_LOOSE_*_CLIENT_ID` and
|
||||||
tenant and returns to `/api/auth/callback`. Set the per-tenant
|
`GO_LOOSE_*_CLIENT_SECRET` variables in the environment; never commit them.
|
||||||
`GO_LOOSE_*_CLIENT_ID` and `GO_LOOSE_*_CLIENT_SECRET` variables in the
|
|
||||||
environment; never commit them.
|
|
||||||
|
|
||||||
Sign in with:
|
Sign in with:
|
||||||
|
|
||||||
- Email: `interviewer@ypto.local`
|
- Email: `interviewer@ypto.local`
|
||||||
- Password: `admin123`
|
- 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:
|
To run the applications outside containers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -73,8 +60,7 @@ make frontend
|
||||||
|
|
||||||
The API listens on port `8080` inside Docker and is exposed to browsers only
|
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
|
through Traefik and the frontend's same-origin `/api` proxy. Vite permits the
|
||||||
documented local tenant hostnames; the API CORS policy permits the configured
|
documented local tenant hostnames; the API CORS policy permits `*.guess.local` development origins and
|
||||||
tenant domain and `*.guess.local` development origins and
|
|
||||||
the `Authorization` and `X-Tenant-Id` request headers. Non-local
|
the `Authorization` and `X-Tenant-Id` request headers. Non-local
|
||||||
deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET`
|
deployments keep using same-origin `/api`. Set a different secure `JWT_SECRET`
|
||||||
outside local development.
|
outside local development.
|
||||||
|
|
@ -82,7 +68,7 @@ outside local development.
|
||||||
## API contract
|
## API contract
|
||||||
|
|
||||||
The backend exposes its current OpenAPI 3.0 contract at
|
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
|
from the same typed registrations that add routes to Chi, including request and
|
||||||
response schemas, status codes, authentication, tenant headers, path/query
|
response schemas, status codes, authentication, tenant headers, path/query
|
||||||
parameters, multipart uploads, downloads, and event streams. It therefore
|
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
|
tenant header. Every other route declares required `X-Tenant-Id`. Protected
|
||||||
operations also declare bearer authentication.
|
operations also declare bearer authentication.
|
||||||
|
|
||||||
## Go Loose browser login
|
## Go Loose authorization
|
||||||
|
|
||||||
Tenant hosts start browser authentication at `GET /api/auth/login`. The API
|
Tenant hosts start browser authentication at `GET /api/auth/login`. Go Loose
|
||||||
redirects to `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`; Go Loose
|
|
||||||
returns to `/api/auth/callback`, and `GET /api/auth/session` exchanges the
|
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
|
resulting secure browser session for the application's bearer JWT. The tenant
|
||||||
derived from the request host, or from `X-Forwarded-Host` when an ingress proxy
|
is derived from the request host, or from `X-Tenant-Id` for API calls.
|
||||||
replaces it, and from `X-Tenant-Id` for API calls.
|
|
||||||
|
|
||||||
| Environment | `GO_LOOSE_AUTH_DOMAIN` | `GO_LOOSE_APP_DOMAIN` |
|
Compose configures `GO_LOOSE_AUTH_DOMAIN`, `GO_LOOSE_APP_DOMAIN`,
|
||||||
| --- | --- | --- |
|
`GO_LOOSE_CA_FILE`, and per-tenant client ID and secret variables. These routes
|
||||||
| Local development | `auth-dev.urpi.be` | `guess-dev.urpi.be` |
|
do not require a JWT:
|
||||||
| 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:
|
|
||||||
|
|
||||||
- `GET /api/health` and `GET /api/openapi.json`
|
- `GET /api/health` and `GET /api/openapi.json`
|
||||||
- browser login, callback, logout, and session routes under `/api/auth`
|
- 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
|
Login and participant routes still require `X-Tenant-Id`. Health and OpenAPI do
|
||||||
not. Browser authentication routes derive the tenant from the configured host.
|
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
|
## Switch the Git remote
|
||||||
|
|
||||||
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between
|
Run `scripts/toggle-origin.sh` to inspect `origin` and switch it between
|
||||||
|
|
@ -185,72 +156,36 @@ make test-env-down
|
||||||
|
|
||||||
## Production image
|
## Production image
|
||||||
|
|
||||||
Two images are published to the private registry: `go-guess-api:0.0.1` for the Go
|
Build the complete application as a single image:
|
||||||
service and `go-guess-web:0.0.1` for the React front end behind nginx.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t batty1039.startdedicated.net:5000/go-guess-api:0.0.1 ./backend
|
docker build -t go-guess .
|
||||||
docker build -t batty1039.startdedicated.net:5000/go-guess-web:0.0.1 ./frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The API image listens on `8080` and applies database migrations and shared
|
The image serves the frontend and API on port `8080`. It applies database
|
||||||
fixtures before serving. The web image listens on `80`, serves the built assets,
|
migrations and shared production fixtures before startup. PostgreSQL, RabbitMQ,
|
||||||
and proxies `/api/` to `http://api:8080`, so both containers must share a
|
and these environment variables must be provided:
|
||||||
network with the API reachable under the DNS name `api`. PostgreSQL, RabbitMQ,
|
|
||||||
and these environment variables must be provided to the API:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 \
|
docker run --rm -p 8080:8080 \
|
||||||
-e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \
|
-e DATABASE_URL='postgres://user:password@postgres:5432/go_guess?sslmode=disable' \
|
||||||
-e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \
|
-e RABBITMQ_URL='amqp://user:password@rabbitmq:5672/' \
|
||||||
-e JWT_SECRET='replace-with-at-least-32-characters' \
|
-e JWT_SECRET='replace-with-at-least-32-characters' \
|
||||||
-e FRONTEND_URL='https://nmbs.guess-dev.urpi.be' \
|
-e FRONTEND_URL='https://go-guess.example.com' \
|
||||||
batty1039.startdedicated.net:5000/go-guess-api:0.0.1
|
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
|
Pushes to `master` publish `go-guess:latest` and `go-guess:<commit-sha>` to the
|
||||||
private registry configured in `.github/workflows/publish-image.yml`. The
|
private registry configured in `.github/workflows/publish-image.yml`. The
|
||||||
repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets.
|
repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets.
|
||||||
|
|
||||||
## Rancher and Helm
|
## Rancher and Helm
|
||||||
|
|
||||||
The chart under `deploy/helm/go-guess` deploys the API image, the web image, and
|
The chart under `deploy/helm/go-guess` deploys the application, PostgreSQL, and
|
||||||
their PostgreSQL and RabbitMQ. Every tenant gets its own HTTPS host at
|
RabbitMQ. It works with any Kubernetes cluster managed by Rancher; Rancher's free
|
||||||
`https://<tenant>.guess.urpi.be` in production and
|
edition does not restrict Helm or GitHub Actions deployments.
|
||||||
`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).
|
|
||||||
|
|
||||||
One web deployment serves every tenant host because the API resolves the tenant
|
Validate or deploy one of the environment profiles:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make helm-lint
|
make helm-lint
|
||||||
|
|
@ -258,41 +193,25 @@ make helm-deploy-development
|
||||||
make helm-deploy-production
|
make helm-deploy-production
|
||||||
```
|
```
|
||||||
|
|
||||||
Development uses `latest` images, development fixtures, and ephemeral database
|
For the repository's single-node Docker Rancher setup, run
|
||||||
and broker storage in namespace `go-insane-development`. Production uses the
|
`make rancher-storage` once before the first production deployment. This
|
||||||
released `0.0.1` tags, production fixtures, and persistent volumes in namespace
|
installs the default persistent StorageClass documented in
|
||||||
`go-insane-production`.
|
`rancher-install.txt`.
|
||||||
|
|
||||||
Certificates are self-signed and issued by the chart, so every browser or client
|
Development uses demo fixtures, ephemeral database and message-broker storage,
|
||||||
that opens a tenant host needs the CA from the release:
|
and NodePort `30080`. Production uses production fixtures, persistent volumes,
|
||||||
|
generated secrets that are preserved across upgrades, and larger resource
|
||||||
```bash
|
limits. It exposes the application at NodePort `31374` for the dedicated-server
|
||||||
kubectl -n go-insane-production get secret go-guess-web-ca \
|
Nginx proxy while PostgreSQL and RabbitMQ remain cluster-internal. The public
|
||||||
-o jsonpath='{.data.ca\.crt}' | base64 -d > go-guess-ca.crt
|
URL is `https://goguess.urpi.be`; one.com DNS, Nginx, Certbot, firewall, and
|
||||||
```
|
verification instructions are in `deploy/nginx/README.md`.
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
To use external managed services, disable the bundled StatefulSets and provide
|
To use external managed services, disable the bundled StatefulSets and provide
|
||||||
full connection URLs:
|
full connection URLs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm upgrade --install go-guess deploy/helm/go-guess \
|
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 \
|
-f deploy/helm/go-guess/values-production.yaml \
|
||||||
--set postgresql.enabled=false \
|
--set postgresql.enabled=false \
|
||||||
--set rabbitmq.enabled=false \
|
--set rabbitmq.enabled=false \
|
||||||
|
|
@ -310,8 +229,8 @@ workflow succeeds. Create GitHub environments named `development` and
|
||||||
- Secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and
|
- Secrets `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and
|
||||||
`GO_GUESS_JWT_SECRET`.
|
`GO_GUESS_JWT_SECRET`.
|
||||||
- Variable `RANCHER_NAMESPACE` for the target namespace.
|
- Variable `RANCHER_NAMESPACE` for the target namespace.
|
||||||
- Optional secrets `GO_LOOSE_NMBS_CLIENT_ID`, `GO_LOOSE_NMBS_CLIENT_SECRET`,
|
- Optional variable `GO_GUESS_FRONTEND_URL` when overriding an environment's
|
||||||
`GO_LOOSE_YPTO_CLIENT_ID`, and `GO_LOOSE_YPTO_CLIENT_SECRET`.
|
configured public URL.
|
||||||
|
|
||||||
Because the configured image registry uses HTTP, every Rancher cluster node must
|
Because the configured image registry uses HTTP, every Rancher cluster node must
|
||||||
trust `batty1039.startdedicated.net:5000` as an insecure registry. Use HTTPS for
|
trust `batty1039.startdedicated.net:5000` as an insecure registry. Use HTTPS for
|
||||||
|
|
@ -320,9 +239,8 @@ the registry in production when possible.
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Requests enter the Chi router and web handlers under `backend/internal/web`.
|
Requests enter the Chi router and web handlers under `backend/internal/web`.
|
||||||
Tenant isolation runs first. The protected interviewer group then requires a
|
Tenant isolation runs first. The protected interviewer group then applies Go
|
||||||
bearer JWT, issued either by `POST /api/auth/login` or by exchanging a Go Loose
|
Loose API-key authorization and JWT authentication.
|
||||||
browser session.
|
|
||||||
Web request/response models are translated to service models before business logic
|
Web request/response models are translated to service models before business logic
|
||||||
runs. Services own validation, CV trait extraction, and candidate scoring. The
|
runs. Services own validation, CV trait extraction, and candidate scoring. The
|
||||||
repository maps service models to GORM entities and persists them in PostgreSQL.
|
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
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
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/go-chi/chi/v5 v5.2.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.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/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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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-20261002194542-c2091e746a14 h1:kX0zybG4E3VLB4QpOsyJEBu4HoXx33CY2x8rtACHRRM=
|
||||||
github.com/danyel/go-loose v0.0.0-20261005194805-49ab89ae46fd/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,27 +30,6 @@ type GoLooseTenant struct {
|
||||||
ClientSecret string
|
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) {
|
func Load() (Config, error) {
|
||||||
ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h"))
|
ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -77,30 +55,22 @@ func Load() (Config, error) {
|
||||||
return cfg, nil
|
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 {
|
func loadGoLoose() GoLooseConfig {
|
||||||
cfg := GoLooseConfig{
|
cfg := GoLooseConfig{
|
||||||
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", DefaultAuthDomain),
|
AuthDomain: env("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
|
||||||
AppDomain: env("GO_LOOSE_APP_DOMAIN", DefaultAppDomain),
|
AppDomain: env("GO_LOOSE_APP_DOMAIN", "guess.dev"),
|
||||||
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
|
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
|
||||||
Tenants: map[string]GoLooseTenant{},
|
Tenants: map[string]GoLooseTenant{},
|
||||||
}
|
}
|
||||||
add := func(slug string) {
|
add := func(slug, idEnv, secretEnv string) {
|
||||||
idEnv, secretEnv := GoLooseCredentialEnv(slug)
|
|
||||||
id, secret := os.Getenv(idEnv), os.Getenv(secretEnv)
|
id, secret := os.Getenv(idEnv), os.Getenv(secretEnv)
|
||||||
if id == "" || secret == "" {
|
if id == "" || secret == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg.Tenants[slug] = GoLooseTenant{ClientID: id, ClientSecret: secret}
|
cfg.Tenants[slug] = GoLooseTenant{ClientID: id, ClientSecret: secret}
|
||||||
}
|
}
|
||||||
add("nmbs")
|
add("nmbs", "GO_LOOSE_NMBS_CLIENT_ID", "GO_LOOSE_NMBS_CLIENT_SECRET")
|
||||||
add("ypto")
|
add("ypto", "GO_LOOSE_YPTO_CLIENT_ID", "GO_LOOSE_YPTO_CLIENT_SECRET")
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,16 @@ type Registry struct {
|
||||||
|
|
||||||
func New(cfg config.GoLooseConfig) (*Registry, error) {
|
func New(cfg config.GoLooseConfig) (*Registry, error) {
|
||||||
registry := &Registry{
|
registry := &Registry{
|
||||||
appDomain: domain(cfg.AppDomain),
|
appDomain: strings.TrimPrefix(strings.ToLower(cfg.AppDomain), "."),
|
||||||
bySlug: map[string]*goloose.BrowserAuth{},
|
bySlug: map[string]*goloose.BrowserAuth{},
|
||||||
}
|
}
|
||||||
|
if registry.appDomain == "" {
|
||||||
|
registry.appDomain = "guess.dev"
|
||||||
|
}
|
||||||
if len(cfg.Tenants) == 0 {
|
if len(cfg.Tenants) == 0 {
|
||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
authDomain := domain(cfg.AuthDomain)
|
authDomain := strings.TrimPrefix(strings.ToLower(cfg.AuthDomain), ".")
|
||||||
if authDomain == "" {
|
if authDomain == "" {
|
||||||
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for slug, tenant := range cfg.Tenants {
|
for slug, tenant := range cfg.Tenants {
|
||||||
slug = domain(slug)
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||||
if slug == "" || strings.Contains(slug, ".") {
|
if slug == "" || strings.Contains(slug, ".") {
|
||||||
return nil, fmt.Errorf("invalid Go Loose tenant slug %q", 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 {
|
func (r *Registry) AppDomain() string {
|
||||||
if r == nil || r.appDomain == "" {
|
if r == nil || r.appDomain == "" {
|
||||||
return config.DefaultAppDomain
|
return "guess.dev"
|
||||||
}
|
}
|
||||||
return r.appDomain
|
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) {
|
func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
|
|
@ -81,21 +82,16 @@ func (r *Registry) ForHost(host string) (*goloose.BrowserAuth, string, bool) {
|
||||||
return auth, slug, ok
|
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 {
|
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 {
|
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) {
|
if !strings.HasSuffix(hostname, suffix) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -106,11 +102,6 @@ func TenantFromHost(host, appDomain string) string {
|
||||||
return slug
|
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) {
|
func client(caFile string) (*http.Client, error) {
|
||||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
if caFile != "" {
|
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
|
PasswordHash string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Role 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 {
|
type Trait struct {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const CandidateThreshold = 60.0
|
||||||
|
|
||||||
type IAuthService interface {
|
type IAuthService interface {
|
||||||
Login(context.Context, string, string) (string, model.User, error)
|
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 {
|
type IJobService interface {
|
||||||
|
|
@ -89,19 +89,9 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
|
||||||
return token, user, nil
|
return token, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExternalIdentity is what Go Loose reports about the person signing in. The
|
func (s *AuthService) EstablishExternalSession(ctx context.Context, email, displayName string) (string, model.User, error) {
|
||||||
// avatar URL is deliberately not persisted: Go Loose retires a picture key when
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
// the picture changes, so a stored copy would eventually point at nothing. It is
|
displayName = strings.TrimSpace(displayName)
|
||||||
// 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)
|
|
||||||
if email == "" {
|
if email == "" {
|
||||||
return "", model.User{}, ErrValidation
|
return "", model.User{}, ErrValidation
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +116,6 @@ func (s *AuthService) EstablishExternalSession(ctx context.Context, identity Ext
|
||||||
return "", model.User{}, err
|
return "", model.User{}, err
|
||||||
}
|
}
|
||||||
user.PasswordHash = ""
|
user.PasswordHash = ""
|
||||||
user.AvatarURL = strings.TrimSpace(identity.AvatarURL)
|
|
||||||
return token, user, nil
|
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") {
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
return h.frontendURL
|
return h.frontendURL
|
||||||
}
|
}
|
||||||
// Only trust the Origin when it is the browser origin of the requesting
|
if strings.EqualFold(parsed.Hostname(), tenant+".guess.local") ||
|
||||||
// tenant, so an invitation link cannot be pointed at another host.
|
strings.EqualFold(parsed.Hostname(), tenant+".guess.dev") {
|
||||||
if strings.EqualFold(parsed.Hostname(), tenant+"."+h.AppDomain()) ||
|
|
||||||
strings.EqualFold(parsed.Hostname(), tenant+".guess.local") {
|
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
return h.frontendURL
|
return h.frontendURL
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/danyel/go-guess/backend/internal/config"
|
"github.com/danyel/go-loose/client"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFrontendURLForTenantRequest(t *testing.T) {
|
func TestFrontendURLForTenantRequest(t *testing.T) {
|
||||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||||
request.Header.Set("Origin", "http://ypto.guess.local:5173")
|
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" {
|
if value := handler.frontendURLForRequest(request); value != "http://ypto.guess.local:5173" {
|
||||||
t.Fatalf("unexpected frontend URL %q", value)
|
t.Fatalf("unexpected frontend URL %q", value)
|
||||||
|
|
@ -27,7 +22,7 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
||||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||||
request.Header.Set("Origin", "http://nmbs.guess.local:5173")
|
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 {
|
if value := handler.frontendURLForRequest(request); value != handler.frontendURL {
|
||||||
t.Fatalf("expected configured frontend URL, got %q", value)
|
t.Fatalf("expected configured frontend URL, got %q", value)
|
||||||
|
|
@ -37,87 +32,10 @@ func TestFrontendURLRejectsDifferentTenantOrigin(t *testing.T) {
|
||||||
func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) {
|
func TestFrontendURLAcceptsDevTenantOrigin(t *testing.T) {
|
||||||
handler := &Handler{frontendURL: "http://localhost:5173"}
|
handler := &Handler{frontendURL: "http://localhost:5173"}
|
||||||
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
request := httptest.NewRequest("GET", "/api/jobs/2/invitations", nil)
|
||||||
request.Header.Set("Origin", "https://ypto."+config.DefaultAppDomain)
|
request.Header.Set("Origin", "https://ypto.guess.dev")
|
||||||
request.Header.Set(goloose.XTenantId, "ypto")
|
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)
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
goloose "github.com/danyel/go-loose/client"
|
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/golooseauth"
|
||||||
"github.com/danyel/go-guess/backend/internal/service"
|
|
||||||
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
|
||||||
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
|
||||||
)
|
)
|
||||||
|
|
@ -17,8 +14,6 @@ import (
|
||||||
var (
|
var (
|
||||||
errMissingSession = errors.New("missing Go Loose session")
|
errMissingSession = errors.New("missing Go Loose session")
|
||||||
errTenantMismatch = errors.New("Go Loose session does not match this tenant")
|
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) {
|
func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
|
||||||
|
|
@ -27,33 +22,33 @@ func (h *Handler) SetBrowserAuth(auth *golooseauth.Registry) {
|
||||||
|
|
||||||
func (h *Handler) AppDomain() string {
|
func (h *Handler) AppDomain() string {
|
||||||
if h.browser == nil {
|
if h.browser == nil {
|
||||||
return config.DefaultAppDomain
|
return "guess.dev"
|
||||||
}
|
}
|
||||||
return h.browser.AppDomain()
|
return h.browser.AppDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) BeginExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
auth, ok := h.externalAuth(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeExternalAuthError(w, h, slug)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
auth.LoginHandler(w, r)
|
auth.LoginHandler(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CompleteExternalLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
auth, ok := h.externalAuth(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeExternalAuthError(w, h, slug)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
auth.CallbackHandler(w, r)
|
auth.CallbackHandler(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ExternalLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
auth, ok := h.externalAuth(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeExternalAuthError(w, h, slug)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
auth.LogoutHandler(w, r)
|
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) {
|
func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
auth, slug, ok := h.externalAuthWithSlug(r)
|
auth, slug, ok := h.externalAuthWithSlug(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeExternalAuthError(w, h, slug)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := r.Cookie(golooseauth.SessionCookie); err != nil {
|
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)
|
writeError(w, http.StatusForbidden, errTenantMismatch)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
internalError(w, r, err)
|
internalError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|
@ -84,17 +79,6 @@ func (h *Handler) ExternalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
})).ServeHTTP(w, r)
|
})).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) {
|
func (h *Handler) externalAuth(r *http.Request) (*goloose.BrowserAuth, bool) {
|
||||||
auth, _, ok := h.externalAuthWithSlug(r)
|
auth, _, ok := h.externalAuthWithSlug(r)
|
||||||
return auth, ok
|
return auth, ok
|
||||||
|
|
@ -104,19 +88,5 @@ func (h *Handler) externalAuthWithSlug(r *http.Request) (*goloose.BrowserAuth, s
|
||||||
if h.browser == nil {
|
if h.browser == nil {
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
}
|
}
|
||||||
return h.browser.ForHost(golooseauth.Host(r))
|
return h.browser.ForHost(r.Host)
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
|
||||||
func UserToWeb(value servicemodel.User) webmodel.User {
|
func UserToWeb(value servicemodel.User) webmodel.User {
|
||||||
return webmodel.User{
|
return webmodel.User{
|
||||||
ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role,
|
ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role,
|
||||||
AvatarURL: value.AvatarURL,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,11 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danyel/go-guess/backend/internal/config"
|
|
||||||
"github.com/danyel/go-loose/client"
|
"github.com/danyel/go-loose/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
const guessApplication = "guess"
|
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.
|
// 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})?$`)
|
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.
|
// from the X-Tenant-Id header, not from process configuration.
|
||||||
//
|
//
|
||||||
// GO_LOOSE_BASE_URL may contain one %s placeholder for that tenant. The
|
// 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.
|
// default is https://%s.auth.dev. A URL without %s is used as-is.
|
||||||
// A URL without %s is used as-is.
|
|
||||||
func GoLooseMiddleware() func(http.Handler) http.Handler {
|
func GoLooseMiddleware() func(http.Handler) http.Handler {
|
||||||
pattern := os.Getenv("GO_LOOSE_BASE_URL")
|
pattern := os.Getenv("GO_LOOSE_BASE_URL")
|
||||||
if pattern == "" {
|
if pattern == "" {
|
||||||
pattern = defaultBaseURLPattern()
|
pattern = "https://%s.auth.dev"
|
||||||
}
|
}
|
||||||
httpClient := &http.Client{Timeout: 2 * time.Second}
|
httpClient := &http.Client{Timeout: 2 * time.Second}
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/danyel/go-guess/backend/internal/config"
|
|
||||||
"github.com/danyel/go-loose/client"
|
"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) {
|
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) {
|
handler := GoLooseMiddleware()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
t.Fatal("handler reached for unsafe tenant")
|
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)
|
tenantID := r.Header.Get(client.XTenantId)
|
||||||
if tenantID == "" {
|
if tenantID == "" {
|
||||||
tenantID = golooseauth.TenantFromHost(golooseauth.Host(r), appDomain)
|
tenantID = golooseauth.TenantFromHost(r.Host, appDomain)
|
||||||
}
|
}
|
||||||
if tenantID == "" || db == nil {
|
if tenantID == "" || db == nil {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,6 @@ type User struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"`
|
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 {
|
type CreateUserRequest struct {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string)
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
spec := openapi.New()
|
spec := openapi.New()
|
||||||
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
|
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(mw.TenantSchemaMiddleware(h.Db(), h.AppDomain()))
|
||||||
router.Use(middleware.Timeout(2 * time.Minute))
|
router.Use(middleware.Timeout(2 * time.Minute))
|
||||||
register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP,
|
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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
requestOrigin := r.Header.Get("Origin")
|
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-Origin", requestOrigin)
|
||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Tenant-Id")
|
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
|
func allowedCORSOrigin(requestOrigin, configuredOrigin string) bool {
|
||||||
// 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 {
|
|
||||||
if requestOrigin == "" {
|
if requestOrigin == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -198,11 +194,5 @@ func allowedCORSOrigin(requestOrigin, configuredOrigin, appDomain string) bool {
|
||||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
hostname := strings.ToLower(parsed.Hostname())
|
return strings.HasSuffix(strings.ToLower(parsed.Hostname()), ".guess.local")
|
||||||
for _, suffix := range []string{"." + appDomain, ".guess.local"} {
|
|
||||||
if suffix != "." && strings.HasSuffix(hostname, suffix) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ func (authStub) Login(context.Context, string, string) (string, model.User, erro
|
||||||
return "", model.User{}, service.ErrInvalidCredentials
|
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
|
return "", model.User{}, service.ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,8 +313,8 @@ func TestOpenAPIDoesNotRequireTenant(t *testing.T) {
|
||||||
func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
|
func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
|
||||||
h := newTestHandler(t, nil)
|
h := newTestHandler(t, nil)
|
||||||
browser, err := golooseauth.New(config.GoLooseConfig{
|
browser, err := golooseauth.New(config.GoLooseConfig{
|
||||||
AuthDomain: config.DefaultAuthDomain,
|
AuthDomain: "auth.dev",
|
||||||
AppDomain: config.DefaultAppDomain,
|
AppDomain: "guess.dev",
|
||||||
Tenants: map[string]config.GoLooseTenant{
|
Tenants: map[string]config.GoLooseTenant{
|
||||||
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
|
"nmbs": {ClientID: "glc_nmbs", ClientSecret: "secret"},
|
||||||
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
|
"ypto": {ClientID: "glc_ypto", ClientSecret: "secret"},
|
||||||
|
|
@ -328,184 +328,19 @@ func TestTenantHostLoginRedirectsToMatchingGoLooseClient(t *testing.T) {
|
||||||
|
|
||||||
for _, slug := range []string{"nmbs", "ypto"} {
|
for _, slug := range []string{"nmbs", "ypto"} {
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
|
||||||
request.Host = slug + ".guess-dev.urpi.be"
|
request.Host = slug + ".guess.dev"
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
api.ServeHTTP(response, request)
|
api.ServeHTTP(response, request)
|
||||||
location := response.Header().Get("Location")
|
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)
|
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)
|
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{}
|
type stubDriver struct{}
|
||||||
|
|
||||||
func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil }
|
func (stubDriver) Open(string) (driver.Conn, error) { return stubConn{}, nil }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: go-guess
|
name: go-guess
|
||||||
description: Go Guess recruitment workspace with per-tenant web hosts
|
description: Go Guess recruitment workspace
|
||||||
type: application
|
type: application
|
||||||
version: 0.3.0
|
version: 0.2.0
|
||||||
appVersion: "0.3.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 }}.
|
Go Guess has been installed in namespace {{ .Release.Namespace }}.
|
||||||
|
{{- if .Values.goLoose.enabled }}
|
||||||
Components:
|
Browser sign-in uses Go Loose at https://<tenant>.{{ .Values.goLoose.authDomain }}.
|
||||||
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
|
|
||||||
{{- else }}
|
{{- else }}
|
||||||
{{ $tenant }}: no client credentials configured, password login only
|
Browser sign-in uses the application password login. Enable goLoose to redirect tenant hosts.
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
{{- 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 }}
|
{{- 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:
|
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.
|
Then open http://127.0.0.1:8080.
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
@ -39,72 +39,3 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
{{- define "go-guess.rabbitmqName" -}}
|
{{- define "go-guess.rabbitmqName" -}}
|
||||||
{{- printf "%s-rabbitmq" (include "go-guess.fullname" .) }}
|
{{- printf "%s-rabbitmq" (include "go-guess.fullname" .) }}
|
||||||
{{- end }}
|
{{- 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 }}
|
{{- if .Values.ingress.enabled }}
|
||||||
{{- $webService := include "go-guess.webDeploymentName" . }}
|
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -14,25 +13,23 @@ spec:
|
||||||
{{- with .Values.ingress.className }}
|
{{- with .Values.ingress.className }}
|
||||||
ingressClassName: {{ . }}
|
ingressClassName: {{ . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.ingress.tls.enabled }}
|
{{- with .Values.ingress.tls }}
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- range .Values.tenants }}
|
|
||||||
- {{ printf "%s.%s" . $.Values.domain | quote }}
|
|
||||||
{{- end }}
|
|
||||||
secretName: {{ include "go-guess.tlsSecretName" . }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
rules:
|
rules:
|
||||||
{{- range .Values.tenants }}
|
{{- range .Values.ingress.hosts }}
|
||||||
- host: {{ printf "%s.%s" . $.Values.domain | quote }}
|
- host: {{ .host | quote }}
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: {{ $.Values.ingress.path }}
|
{{- range .paths }}
|
||||||
pathType: {{ $.Values.ingress.pathType }}
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: {{ $webService }}
|
name: {{ include "go-guess.fullname" $ }}
|
||||||
port:
|
port:
|
||||||
number: {{ $.Values.web.service.port }}
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
@ -32,14 +32,3 @@ data:
|
||||||
rabbitmq-url: {{ $rabbitmqURL | b64enc | quote }}
|
rabbitmq-url: {{ $rabbitmqURL | b64enc | quote }}
|
||||||
postgresql-password: {{ $postgresPassword | b64enc | quote }}
|
postgresql-password: {{ $postgresPassword | b64enc | quote }}
|
||||||
rabbitmq-password: {{ $rabbitmqPassword | 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
|
- wget
|
||||||
args:
|
args:
|
||||||
- -qO-
|
- -qO-
|
||||||
- "http://{{ include "go-guess.apiServiceName" . }}:{{ .Values.api.service.port }}/api/health"
|
- "http://{{ include "go-guess.fullname" . }}:{{ .Values.service.port }}/api/health"
|
||||||
- name: wget-web
|
|
||||||
image: busybox:1.37
|
|
||||||
command:
|
|
||||||
- wget
|
|
||||||
args:
|
|
||||||
- -qO-
|
|
||||||
- "http://{{ include "go-guess.webDeploymentName" . }}:{{ .Values.web.service.port }}/"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
image:
|
||||||
# `latest` image tags. Browser sign-in goes through the development identity
|
tag: latest
|
||||||
# domain auth-dev.urpi.be, never the production auth.urpi.be.
|
pullPolicy: Always
|
||||||
domain: guess-dev.urpi.be
|
|
||||||
|
|
||||||
tenants:
|
app:
|
||||||
- nmbs
|
|
||||||
- ypto
|
|
||||||
|
|
||||||
api:
|
|
||||||
environment: development
|
environment: development
|
||||||
image:
|
frontendURL: http://localhost:30080
|
||||||
tag: latest
|
|
||||||
pullPolicy: Always
|
|
||||||
goLoose:
|
|
||||||
enabled: true
|
|
||||||
authDomain: auth-dev.urpi.be
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 96Mi
|
|
||||||
limits:
|
|
||||||
cpu: 300m
|
|
||||||
memory: 384Mi
|
|
||||||
|
|
||||||
web:
|
service:
|
||||||
replicaCount: 1
|
type: NodePort
|
||||||
image:
|
nodePort: 30080
|
||||||
tag: latest
|
|
||||||
pullPolicy: Always
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 25m
|
|
||||||
memory: 32Mi
|
|
||||||
limits:
|
|
||||||
cpu: 200m
|
|
||||||
memory: 96Mi
|
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: false
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 96Mi
|
||||||
|
limits:
|
||||||
|
cpu: 300m
|
||||||
|
memory: 384Mi
|
||||||
|
|
||||||
postgresql:
|
postgresql:
|
||||||
persistence:
|
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
|
image:
|
||||||
# tags, and self-signed certificates served for https://<tenant>.guess.urpi.be.
|
tag: latest
|
||||||
domain: guess.urpi.be
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
tenants:
|
app:
|
||||||
- nmbs
|
|
||||||
- ypto
|
|
||||||
|
|
||||||
api:
|
|
||||||
environment: production
|
environment: production
|
||||||
image:
|
frontendURL: https://goguess.urpi.be
|
||||||
tag: "0.0.1"
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
goLoose:
|
|
||||||
enabled: true
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 200m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
cpu: "1"
|
|
||||||
memory: 768Mi
|
|
||||||
|
|
||||||
web:
|
service:
|
||||||
replicaCount: 2
|
type: NodePort
|
||||||
image:
|
nodePort: 31374
|
||||||
tag: "0.0.1"
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 50m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
cpu: 300m
|
|
||||||
memory: 128Mi
|
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: false
|
||||||
className: nginx
|
|
||||||
|
|
||||||
certManager:
|
resources:
|
||||||
enabled: true
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 768Mi
|
||||||
|
|
||||||
postgresql:
|
postgresql:
|
||||||
persistence:
|
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: ""
|
nameOverride: ""
|
||||||
fullnameOverride: ""
|
fullnameOverride: ""
|
||||||
|
|
||||||
# Public application domain. Every tenant is served from https://<tenant>.<domain>.
|
replicaCount: 1
|
||||||
domain: guess.urpi.be
|
|
||||||
|
|
||||||
# Tenant slugs. Each one gets its own ingress host and TLS SAN.
|
image:
|
||||||
tenants:
|
repository: batty1039.startdedicated.net:5000/go-guess
|
||||||
- nmbs
|
tag: latest
|
||||||
- ypto
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
imagePullSecrets:
|
imagePullSecrets: []
|
||||||
- name: nexus-registry
|
|
||||||
|
|
||||||
api:
|
app:
|
||||||
podSecurityContext:
|
environment: production
|
||||||
runAsNonRoot: true
|
frontendURL: http://localhost:8080
|
||||||
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: ""
|
|
||||||
tokenTTL: 8h
|
tokenTTL: 8h
|
||||||
maxUploadMB: 10
|
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:
|
goLoose:
|
||||||
replicaCount: 2
|
enabled: false
|
||||||
image:
|
authDomain: auth.dev
|
||||||
repository: nexus.urpi.be/docker-hosted/go-guess
|
appDomain: guess.dev
|
||||||
tag: "0.0.1"
|
ca:
|
||||||
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.
|
|
||||||
existingSecret: ""
|
existingSecret: ""
|
||||||
# Defaults to <fullname>-web-tls when empty and certManager.enabled is true.
|
key: ca.crt
|
||||||
secretName: ""
|
mountPath: /certs/local-ca.crt
|
||||||
|
tenants: []
|
||||||
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: ""
|
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
jwtSecret: ""
|
jwtSecret: ""
|
||||||
|
|
@ -156,6 +33,49 @@ secrets:
|
||||||
postgresqlPassword: ""
|
postgresqlPassword: ""
|
||||||
rabbitmqPassword: ""
|
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:
|
postgresql:
|
||||||
enabled: true
|
enabled: true
|
||||||
image:
|
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
|
The `go-guess` service should show `80:31374/TCP`. The PostgreSQL and RabbitMQ
|
||||||
services should remain `ClusterIP` services without NodePorts.
|
services should remain `ClusterIP` services without NodePorts.
|
||||||
|
|
||||||
Browser sign-in also calls Go Loose from the application pod, using
|
Protected interviewer routes also call Go Loose from the application pod, using
|
||||||
`GO_LOOSE_AUTH_DOMAIN` (`auth-dev.urpi.be` outside production, `auth.urpi.be` in
|
`app.goLooseBaseURL` (default `https://%s.auth.dev`, where `%s` is the
|
||||||
production) as `https://<tenant>.<GO_LOOSE_AUTH_DOMAIN>`. That address must be
|
request tenant). That address must be reachable from the cluster. The browser
|
||||||
reachable from the pod. The browser forwards browser authentication and API
|
forwards browser authentication and API requests without adding application
|
||||||
requests without adding application credentials. Nginx does not add or strip
|
credentials. Nginx does not add or strip authorization headers.
|
||||||
authorization headers.
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_GO_LOOSE_API_KEY: gl_bdd_key_that_is_long_enough_for_client
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable&search_path=ypto
|
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
|
JWT_SECRET: bdd-test-secret-that-is-at-least-32-characters
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,10 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
|
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
|
||||||
JWT_SECRET: local-development-secret-change-me-12345
|
JWT_SECRET: local-development-secret-change-me-12345
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess-dev.urpi.be}
|
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess.local}
|
||||||
# Where the go-bananas styling contract is served from. Empty disables it.
|
|
||||||
THEME_BASE_URL: ${THEME_BASE_URL:-}
|
|
||||||
APP_ENV: ${APP_ENV:-development}
|
APP_ENV: ${APP_ENV:-development}
|
||||||
GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth-dev.urpi.be}
|
GO_LOOSE_AUTH_DOMAIN: auth.dev
|
||||||
GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess-dev.urpi.be}
|
GO_LOOSE_APP_DOMAIN: guess.dev
|
||||||
GO_LOOSE_CA_FILE: /certs/local-ca.crt
|
GO_LOOSE_CA_FILE: /certs/local-ca.crt
|
||||||
GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-}
|
GO_LOOSE_NMBS_CLIENT_ID: ${GO_LOOSE_NMBS_CLIENT_ID:-}
|
||||||
GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-}
|
GO_LOOSE_NMBS_CLIENT_SECRET: ${GO_LOOSE_NMBS_CLIENT_SECRET:-}
|
||||||
|
|
@ -54,7 +52,7 @@ services:
|
||||||
- go_guess_network
|
- go_guess_network
|
||||||
- local_dev_edge
|
- local_dev_edge
|
||||||
volumes:
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,6 @@ seed
|
||||||
|
|
||||||
mkdir -p /tmp/nginx
|
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 &
|
||||||
api_pid=$!
|
api_pid=$!
|
||||||
nginx -e /dev/stderr -g 'daemon off;' &
|
nginx -e /dev/stderr -g 'daemon off;' &
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,5 @@ RUN npm run build
|
||||||
FROM nginx:1.29-alpine
|
FROM nginx:1.29-alpine
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
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
|
EXPOSE 80
|
||||||
ENTRYPOINT ["/usr/local/bin/go-guess-frontend-entrypoint"]
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
|
||||||
|
|
@ -36,64 +36,15 @@ application shell.
|
||||||
|
|
||||||
## API integration
|
## API integration
|
||||||
|
|
||||||
For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and
|
For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives
|
||||||
`nmbs.guess.urpi.be` in production, `src/api/client.ts` derives tenant `nmbs`,
|
tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends
|
||||||
calls same-origin `/api` through the shared HTTPS proxy, and sends
|
|
||||||
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
|
`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.
|
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
|
On the configured tenant hosts, the app checks `/api/auth/session` and redirects
|
||||||
tenant as a possible Go Loose host and asks `/api/auth/session` whether browser
|
to `/api/auth/login` when no Go Loose browser session exists. The callback
|
||||||
login is available there. A `404` means the API has no Go Loose login for the
|
exchanges that session for the bearer token used by the typed API client.
|
||||||
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.
|
|
||||||
Invitation and participant-meeting URLs use the validated request origin so
|
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
|
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.
|
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
|
`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
|
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
|
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
|
`/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
|
token-based. The app also schedules logout from the JWT expiration claim so an idle
|
||||||
expired session cannot remain on a protected page.
|
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="theme-color" content="#081c2c" />
|
||||||
<meta name="description" content="Go Guess recruitment assessment workspace" />
|
<meta name="description" content="Go Guess recruitment assessment workspace" />
|
||||||
<title>Go Guess · Recruitment workspace</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 { 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'
|
import { subscribeToDomainEvent } from './domainEvents'
|
||||||
|
|
||||||
describe('API client', () => {
|
describe('API client', () => {
|
||||||
|
|
@ -8,11 +8,12 @@ describe('API client', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the same-origin API behind the local HTTPS proxy', () => {
|
it('uses the same-origin API behind the local HTTPS proxy', () => {
|
||||||
expect(tenantFromHostname('nmbs.guess-dev.urpi.be')).toBe('nmbs')
|
expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs')
|
||||||
expect(tenantFromHostname('ypto.guess-dev.urpi.be')).toBe('ypto')
|
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto')
|
||||||
expect(tenantFromHostname('nmbs.guess.urpi.be')).toBe('nmbs')
|
|
||||||
expect(tenantFromHostname('localhost')).toBeNull()
|
expect(tenantFromHostname('localhost')).toBeNull()
|
||||||
expect(tenantFromHostname('127.0.0.1')).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 () => {
|
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
|
return labels.length >= 3 ? labels[0] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every tenant host serves the API from its own origin: nginx and Traefik proxy
|
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
|
||||||
// /api to the API container, so the base is always the same-origin path.
|
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
|
||||||
|
if (hostname.endsWith('.guess.local')) {
|
||||||
|
return '/api'
|
||||||
|
}
|
||||||
|
return '/api'
|
||||||
|
}
|
||||||
|
|
||||||
function apiURL(path: string): string {
|
function apiURL(path: string): string {
|
||||||
return `/api${path}`
|
return `${apiBaseURL(window.location)}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function tenantHeader(): Record<string, string> {
|
function tenantHeader(): Record<string, string> {
|
||||||
|
|
|
||||||
|
|
@ -258,17 +258,10 @@ describe('Go Guess frontend', () => {
|
||||||
'href',
|
'href',
|
||||||
'/invitations',
|
'/invitations',
|
||||||
)
|
)
|
||||||
// The person is reached through the account menu rather than a bare profile
|
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
|
||||||
// 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(
|
|
||||||
'href',
|
'href',
|
||||||
'/profile',
|
'/profile',
|
||||||
)
|
)
|
||||||
expect(within(accountNavigation).getByRole('button', { name: 'Sign out' })).toBeInTheDocument()
|
|
||||||
await userEvent.keyboard('{Escape}')
|
|
||||||
|
|
||||||
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
|
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -336,10 +329,7 @@ describe('Go Guess frontend', () => {
|
||||||
renderApp('/participants/3')
|
renderApp('/participants/3')
|
||||||
|
|
||||||
const input = await screen.findByLabelText(/replace cv/i)
|
const input = await screen.findByLabelText(/replace cv/i)
|
||||||
await user.upload(
|
await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }))
|
||||||
input,
|
|
||||||
new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await screen.findByText('React')).toBeInTheDocument()
|
expect(await screen.findByText('React')).toBeInTheDocument()
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,33 @@
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import {Route, Routes, useLocation} from 'react-router-dom'
|
||||||
import { useAuthSession } from '../components/actions'
|
import {useAuthSession} from '../components/actions'
|
||||||
import { NotFound } from '../components/ui/NotFound'
|
import {NotFound} from '../components/ui/NotFound'
|
||||||
import { ParticipantInterviewPage } from '../features/assessments'
|
import {ParticipantInterviewPage} from '../features/assessments'
|
||||||
import { ParticipantMeetingPage } from '../features/interviews'
|
import {ParticipantMeetingPage} from '../features/interviews'
|
||||||
import { LoginPage } from '../features/login/LoginPage'
|
import {LoginPage} from '../features/login/LoginPage'
|
||||||
import { AuthenticatedApp } from './AuthenticatedApp'
|
import {AuthenticatedApp} from './AuthenticatedApp'
|
||||||
import { DataProvider } from './DataContext'
|
import {DataProvider} from './DataContext'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } = useAuthSession()
|
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
|
||||||
if (location.pathname.startsWith('/participant/')) {
|
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 (
|
return (
|
||||||
<Routes>
|
<DataProvider>
|
||||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
|
<AuthenticatedApp onLogout={logout}/>
|
||||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
|
</DataProvider>
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
// 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">
|
<div className="app-shell">
|
||||||
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
|
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<WorkspaceUtilities user={user} onSignOut={onLogout}/>
|
<WorkspaceUtilities user={user}/>
|
||||||
<ApplicationRoutes/>
|
<ApplicationRoutes/>
|
||||||
<AppFooter/>
|
<AppFooter/>
|
||||||
</main>
|
</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 { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
|
||||||
authStorage,
|
|
||||||
invalidateSession,
|
|
||||||
tenantFromHostname,
|
|
||||||
SESSION_EXPIRED_EVENT,
|
|
||||||
} from '../../api/client'
|
|
||||||
|
|
||||||
const SESSION_PATH = '/api/auth/session'
|
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
|
||||||
const LOGOUT_PATH = '/api/auth/logout'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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() {
|
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() {
|
export function useAuthSession() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// A participant opens the application through a token link and is never a
|
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
|
||||||
// member of the tenant, so nothing here may probe or redirect them.
|
|
||||||
const participant = location.pathname.startsWith('/participant/')
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
||||||
const [sessionExpired, setSessionExpired] = useState(false)
|
const [sessionExpired, setSessionExpired] = useState(false)
|
||||||
// Whether Go Loose login is configured for this host. A host with no tenant
|
const [redirecting, setRedirecting] = useState(false)
|
||||||
// label cannot have it, so that is known without asking: false there, and null
|
const needsSSO = sso && !authenticated && !authStorage.token()
|
||||||
// on a tenant host until /api/auth/session has answered.
|
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking'
|
||||||
const [ssoAvailable, setSsoAvailable] = useState<boolean | null>(() =>
|
const navigate = useNavigate()
|
||||||
isSSOHost() ? null : false,
|
|
||||||
)
|
|
||||||
const storedToken = authStorage.token()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleSessionExpired() {
|
function handleSessionExpired() {
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
setSessionExpired(true)
|
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)
|
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||||
}, [])
|
}, [navigate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A host without a tenant label cannot have Go Loose login configured, and a
|
if (!needsSSO) return
|
||||||
// participant is not signing in at all.
|
|
||||||
if (participant || !isSSOHost() || storedToken) return
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(SESSION_PATH, {
|
const response = await fetch('/api/auth/session', {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
})
|
})
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (response.ok) {
|
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())
|
authStorage.save(await response.json())
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
setSessionExpired(false)
|
setSessionExpired(false)
|
||||||
return
|
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 {
|
} catch {
|
||||||
// An unreachable API leaves ssoAvailable null, which keeps the single
|
// The browser redirect below starts a fresh Go Loose login.
|
||||||
// sign-on button on screen: it is the only way in on such a host anyway.
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setRedirecting(true)
|
||||||
|
window.location.replace('/api/auth/login')
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [storedToken, participant])
|
}, [needsSSO])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authenticated) return
|
if (!authenticated) return
|
||||||
|
|
@ -113,8 +79,6 @@ export function useAuthSession() {
|
||||||
function completeLogin() {
|
function completeLogin() {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
setSessionExpired(false)
|
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 })
|
navigate('/', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,10 +86,8 @@ export function useAuthSession() {
|
||||||
authStorage.clear()
|
authStorage.clear()
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
setSessionExpired(false)
|
setSessionExpired(false)
|
||||||
// Signing out also ends the Go Loose session, so the next application in the
|
if (isSSOHost()) window.location.assign('/api/auth/logout')
|
||||||
// family asks for credentials again rather than letting this one back in.
|
|
||||||
if (isSSOHost()) window.location.assign(LOGOUT_PATH)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { CalendarDays, Mail } from 'lucide-react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
import { ThemeControls } from '../../theme'
|
|
||||||
import { UserMenu } from './UserMenu'
|
|
||||||
|
|
||||||
export function WorkspaceUtilities({
|
export function WorkspaceUtilities({ user }: { user: User | null }) {
|
||||||
user,
|
const profileLabel = user?.displayName || user?.email || 'Profile'
|
||||||
onSignOut,
|
|
||||||
}: {
|
|
||||||
user: User | null
|
|
||||||
onSignOut: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<nav className="workspace-utilities" aria-label="Account navigation">
|
<nav className="workspace-utilities" aria-label="Account navigation">
|
||||||
<NavLink className="utility-link" to="/invitations">
|
<NavLink className="utility-link" to="/invitations">
|
||||||
|
|
@ -21,8 +14,12 @@ export function WorkspaceUtilities({
|
||||||
<CalendarDays size={18} aria-hidden="true" />
|
<CalendarDays size={18} aria-hidden="true" />
|
||||||
<span>Schedule</span>
|
<span>Schedule</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<ThemeControls />
|
<NavLink className="utility-link profile-utility" to="/profile">
|
||||||
<UserMenu user={user} onSignOut={onSignOut} />
|
<span className="avatar tiny" aria-hidden="true">
|
||||||
|
{profileLabel.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span>{profileLabel}</span>
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</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 { SyntheticEvent, useState } from 'react'
|
||||||
import Logo from '../../components/layout/Logo'
|
import Logo from '../../components/layout/Logo'
|
||||||
import { FormField } from '../../components/form'
|
import { FormField } from '../../components/form'
|
||||||
import { ErrorAlert, errorMessage } from '../../components/actions'
|
import { ErrorAlert, errorMessage } from '../../components/actions'
|
||||||
import { ThemeControls } from '../../theme'
|
|
||||||
import { api, authStorage } from '../../api/client'
|
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({
|
export function LoginPage({
|
||||||
onLogin,
|
onLogin,
|
||||||
sessionExpired,
|
sessionExpired,
|
||||||
ssoAvailable,
|
|
||||||
}: {
|
}: {
|
||||||
onLogin: () => void
|
onLogin: () => void
|
||||||
sessionExpired: boolean
|
sessionExpired: boolean
|
||||||
/** null until the API has said whether Go Loose login is configured here. */
|
|
||||||
ssoAvailable: boolean | null
|
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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>) {
|
async function submit(event: SyntheticEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -74,10 +47,7 @@ export function LoginPage({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-foot">
|
<p className="login-foot">Designed for fair, collaborative recruitment.</p>
|
||||||
<ThemeControls />
|
|
||||||
<p>Designed for fair, collaborative recruitment.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<section className="login-panel" aria-labelledby="login-title">
|
<section className="login-panel" aria-labelledby="login-title">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
|
|
@ -88,46 +58,25 @@ export function LoginPage({
|
||||||
Your session is no longer active. Sign in again to continue.
|
Your session is no longer active. Sign in again to continue.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="muted">
|
<p className="muted">Use your work account to continue.</p>
|
||||||
Use your work account to continue. If you are already signed in elsewhere, this takes
|
<form onSubmit={submit} className="stack-form">
|
||||||
you straight through.
|
<FormField label="Email address" htmlFor="email">
|
||||||
</p>
|
<input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</FormField>
|
||||||
{ssoAvailable === false ? (
|
<FormField label="Password" htmlFor="password">
|
||||||
<form onSubmit={submit} className="stack-form">
|
<input
|
||||||
<p className="muted">
|
id="password"
|
||||||
Single sign-on is not configured for this host. Sign in with a local account.
|
name="password"
|
||||||
</p>
|
type="password"
|
||||||
<FormField label="Email address" htmlFor="email">
|
autoComplete="current-password"
|
||||||
<input id="email" name="email" type="email" autoComplete="email" required />
|
required
|
||||||
</FormField>
|
/>
|
||||||
<FormField label="Password" htmlFor="password">
|
</FormField>
|
||||||
<input
|
{error && <ErrorAlert message={error} />}
|
||||||
id="password"
|
<button className="button primary wide" type="submit" disabled={loading}>
|
||||||
name="password"
|
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
|
||||||
type="password"
|
</button>
|
||||||
autoComplete="current-password"
|
</form>
|
||||||
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="demo-note">
|
<p className="demo-note">
|
||||||
<CircleHelp size={16} /> Contact your administrator for access.
|
<CircleHelp size={16} /> Contact your administrator for access.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Mail, ShieldCheck, UserRound } from 'lucide-react'
|
import { Mail, ShieldCheck, UserRound } from 'lucide-react'
|
||||||
import { authStorage } from '../../api/client'
|
import { authStorage } from '../../api/client'
|
||||||
import { Avatar } from '../../components/ui/Avatar'
|
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const user = authStorage.user()
|
const user = authStorage.user()
|
||||||
|
|
@ -15,8 +14,9 @@ export function ProfilePage() {
|
||||||
</header>
|
</header>
|
||||||
<section className="card profile-card">
|
<section className="card profile-card">
|
||||||
<div className="profile-identity">
|
<div className="profile-identity">
|
||||||
{/* The picture Go Loose serves, with the initials as the fallback. */}
|
<span className="avatar profile-avatar" aria-hidden="true">
|
||||||
<Avatar user={user} size="large" className="profile-avatar" />
|
{user.displayName.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1>{user.displayName}</h1>
|
<h1>{user.displayName}</h1>
|
||||||
<span className={`status status-${user.role}`}>{user.role}</span>
|
<span className={`status status-${user.role}`}>{user.role}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
import { StrictMode } from 'react'
|
import {StrictMode} from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import {createRoot} from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import {BrowserRouter} from 'react-router-dom'
|
||||||
import App from './app/App'
|
import App from './app/App'
|
||||||
import { installThemeRuntime } from './theme/installThemeRuntime'
|
|
||||||
import './styles/global.css'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App/>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</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');
|
@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 {
|
:root {
|
||||||
font-family: 'DM Sans', system-ui, sans-serif;
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
color: var(--ink);
|
color: #142b3a;
|
||||||
background: var(--page);
|
background: #f3f7f8;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
--navy: #081c2c;
|
||||||
/* Surfaces, from the page up to the most raised. */
|
--navy-soft: #123449;
|
||||||
--page: var(--bn-bg, #f3f7f8);
|
--ink: #142b3a;
|
||||||
--surface: var(--bn-surface, #ffffff);
|
--muted: #667986;
|
||||||
--raised: var(--bn-surface-raised, #ffffff);
|
--line: #dce5e8;
|
||||||
--sunken: var(--bn-surface-sunken, #eef3f4);
|
--surface: #ffffff;
|
||||||
|
--cyan: #00a9c8;
|
||||||
/* The navigation rail is a distinct plane rather than another surface, so it
|
--cyan-dark: #007f98;
|
||||||
* takes the sunken step: readable against the page in either appearance. */
|
--lime: #d9ef4d;
|
||||||
--navy: var(--bn-surface-sunken, #081c2c);
|
--orange: #ff7c52;
|
||||||
--navy-soft: var(--bn-surface, #123449);
|
--radius: 16px;
|
||||||
/* Text on the rail. It comes from the contract's own foreground pairing rather
|
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
|
||||||
* 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 {
|
.eligible-row {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +31,7 @@
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
background: var(--page);
|
background: #f3f7f8;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -294,7 +247,7 @@ button {
|
||||||
}
|
}
|
||||||
.login-story {
|
.login-story {
|
||||||
background: var(--navy);
|
background: var(--navy);
|
||||||
color: var(--on-rail);
|
color: white;
|
||||||
padding: 2.2rem clamp(2rem, 6vw, 6rem);
|
padding: 2.2rem clamp(2rem, 6vw, 6rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -372,12 +325,12 @@ button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
background: var(--page);
|
background: #f3f7f8;
|
||||||
}
|
}
|
||||||
.login-card {
|
.login-card {
|
||||||
width: min(430px, 100%);
|
width: min(430px, 100%);
|
||||||
padding: clamp(1.8rem, 5vw, 3rem);
|
padding: clamp(1.8rem, 5vw, 3rem);
|
||||||
background: var(--raised);
|
background: white;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +364,7 @@ button {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--danger);
|
color: #a92e2e;
|
||||||
}
|
}
|
||||||
.error-alert {
|
.error-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -458,7 +411,7 @@ button {
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
width: 255px;
|
width: 255px;
|
||||||
background: var(--navy);
|
background: var(--navy);
|
||||||
color: var(--on-rail);
|
color: white;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1.6rem 1.1rem;
|
padding: 1.6rem 1.1rem;
|
||||||
|
|
@ -721,8 +674,8 @@ button {
|
||||||
color: var(--cyan-dark);
|
color: var(--cyan-dark);
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--raised);
|
background: white;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid #e1e9eb;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
|
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
|
||||||
}
|
}
|
||||||
|
|
@ -2849,135 +2802,3 @@ fieldset {
|
||||||
animation-iteration-count: 1 !important;
|
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
|
email: string
|
||||||
displayName: string
|
displayName: string
|
||||||
role: 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 {
|
export interface AuthResponse {
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,7 @@ import { defineConfig } from 'vitest/config'
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: [
|
allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'],
|
||||||
'nmbs.guess-dev.urpi.be',
|
|
||||||
'ypto.guess-dev.urpi.be',
|
|
||||||
'nmbs.guess.urpi.be',
|
|
||||||
'ypto.guess.urpi.be',
|
|
||||||
'nmbs.guess.local',
|
|
||||||
'ypto.guess.local',
|
|
||||||
],
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
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
|
set -eu
|
||||||
|
|
||||||
github_url='https://github.com/danyel/go-guess.git'
|
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'
|
remote='origin'
|
||||||
|
|
||||||
current_url=$(git remote get-url "$remote")
|
current_url=$(git remote get-url "$remote")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue