fix: harden runtime and reduce request overhead

This commit is contained in:
SimpleTest 2026-07-20 06:38:32 +03:00
parent eacfdb2451
commit 16ebd810a5
54 changed files with 1791 additions and 297 deletions

View File

@ -11,6 +11,5 @@
# backend path and the same warning class. # backend path and the same warning class.
[ [
{"lib/who_need_help/help.ex", :call_without_opaque}, {"lib/who_need_help/help.ex", :call_without_opaque},
{"lib/who_need_help/tracking.ex", :call_without_opaque},
{"lib/who_need_help_web/gettext.ex", :call_without_opaque} {"lib/who_need_help_web/gettext.ex", :call_without_opaque}
] ]

View File

@ -3,6 +3,7 @@
# reuses the staging Compose project or database volume. # reuses the staging Compose project or database volume.
HTTP_PORT=0 HTTP_PORT=0
MAILPIT_PORT=0 MAILPIT_PORT=0
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_RETRY_ATTEMPTS=3
TRAEFIK_PROJECT_CONSTRAINT=GENERATED_UNIQUE_E2E_PROJECT TRAEFIK_PROJECT_CONSTRAINT=GENERATED_UNIQUE_E2E_PROJECT
@ -19,7 +20,11 @@ POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD
DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e
POOL_SIZE=10 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2
WEB_REPLICAS=2
WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE

View File

@ -2,6 +2,8 @@
# required values. Replace every credential before any public deployment. # required values. Replace every credential before any public deployment.
HTTP_PORT=4010 HTTP_PORT=4010
MAILPIT_PORT=8027 MAILPIT_PORT=8027
MAILPIT_BIND_ADDRESS=127.0.0.1
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
# Comma-separated proxy IP/CIDR values whose X-Forwarded-* headers Traefik # Comma-separated proxy IP/CIDR values whose X-Forwarded-* headers Traefik
# accepts. Keep loopback locally; set the exact VPN proxy address for staging. # accepts. Keep loopback locally; set the exact VPN proxy address for staging.
TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_TRUSTED_IPS=127.0.0.1/32
@ -10,7 +12,7 @@ TRAEFIK_RETRY_ATTEMPTS=3
# own project constraint, router/service name, Docker network, and Host rule. # own project constraint, router/service name, Docker network, and Host rule.
TRAEFIK_PROJECT_CONSTRAINT=who_need_help TRAEFIK_PROJECT_CONSTRAINT=who_need_help
TRAEFIK_APP_NAME=who-need-help TRAEFIK_APP_NAME=who-need-help
TRAEFIK_DOCKER_NETWORK=who_need_help_internal TRAEFIK_DOCKER_NETWORK=who_need_help_ingress
TRAEFIK_ROUTER_RULE='PathPrefix(`/`)' TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=localhost PHX_HOST=localhost
PHX_SCHEME=http PHX_SCHEME=http
@ -62,7 +64,11 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret
DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help
POOL_SIZE=10 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2
WEB_REPLICAS=2
WORKER_REPLICAS=2
# Maximum simultaneously existing Erlang ports (files, sockets and drivers). # Maximum simultaneously existing Erlang ports (files, sockets and drivers).
# Keeping this explicit prevents a host's very large nofile ulimit from making # Keeping this explicit prevents a host's very large nofile ulimit from making
# every BEAM instance preallocate a multi-gigabyte port table. # every BEAM instance preallocate a multi-gigabyte port table.

View File

@ -6,6 +6,7 @@ LOAD_PROJECT=who_need_help_load
LOAD_HOST=load.local LOAD_HOST=load.local
HTTP_PORT=4110 HTTP_PORT=4110
MAILPIT_PORT=8127 MAILPIT_PORT=8127
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
PHX_HOST=load.local PHX_HOST=load.local
PHX_SCHEME=https PHX_SCHEME=https
PHX_URL_PORT=443 PHX_URL_PORT=443
@ -13,14 +14,18 @@ TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_RETRY_ATTEMPTS=3
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
TRAEFIK_APP_NAME=who-need-help-load TRAEFIK_APP_NAME=who-need-help-load
TRAEFIK_DOCKER_NETWORK=who_need_help_load_internal TRAEFIK_DOCKER_NETWORK=who_need_help_load_ingress
TRAEFIK_ROUTER_RULE='Host(`load.local`)' TRAEFIK_ROUTER_RULE='Host(`load.local`)'
POSTGRES_DB=who_need_help_load POSTGRES_DB=who_need_help_load
POSTGRES_USER=wnh_load POSTGRES_USER=wnh_load
POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD
DATABASE_URL=GENERATE_DATABASE_URL DATABASE_URL=GENERATE_DATABASE_URL
POOL_SIZE=10 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2
WEB_REPLICAS=2
WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE
HANDOVER_SECRET=GENERATE_HANDOVER_SECRET HANDOVER_SECRET=GENERATE_HANDOVER_SECRET

9
Dockerfile.postgis Normal file
View File

@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1.20.0
FROM postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a63116392b95726a4c913766cf6
# PostgreSQL 18's image-owned volume path is already writable by this account.
# Starting non-root removes the entrypoint's need for the bundled gosu binary.
RUN rm /usr/local/bin/gosu
USER postgres

11
Dockerfile.socket-proxy Normal file
View File

@ -0,0 +1,11 @@
# syntax=docker/dockerfile:1.20.0
FROM tecnativa/docker-socket-proxy:v0.4.2@sha256:1f3a6f303320723d199d2316a3e82b2e2685d86c275d5e3deeaf182573b47476
USER root
# The upstream release image is immutable but its Alpine packages predate
# currently available security fixes. Keep the reviewed proxy implementation
# and apply the repository's current fixes; scripts/quality.sh scans the result.
RUN apk upgrade --no-cache
USER haproxy

14
compose.cpu-replay.yaml Normal file
View File

@ -0,0 +1,14 @@
# Optional measurement-only override. It constrains CPU visibility for each
# long-running BEAM container so scheduler-count and memory behavior can be
# reproduced on a many-core development host. Values are experiment inputs,
# not production recommendations or minimum requirements.
services:
web:
cpus: ${CPU_REPLAY_WEB_CPUS:?Set CPU_REPLAY_WEB_CPUS for this experiment}
environment:
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}:${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}"
worker:
cpus: ${CPU_REPLAY_WORKER_CPUS:?Set CPU_REPLAY_WORKER_CPUS for this experiment}
environment:
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}:${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}"

View File

@ -15,7 +15,7 @@ services:
edge: edge:
aliases: aliases:
- ${LOAD_HOST} - ${LOAD_HOST}
internal: {} ingress: {}
migrate: migrate:
image: who-need-help:load image: who-need-help:load

View File

@ -1,6 +1,6 @@
services: services:
db: db:
image: postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a63116392b95726a4c913766cf6 image: ${QUALITY_POSTGIS_IMAGE:?Set QUALITY_POSTGIS_IMAGE}
environment: environment:
POSTGRES_DB: postgres POSTGRES_DB: postgres
POSTGRES_USER: ${QUALITY_POSTGRES_USER:?Set QUALITY_POSTGRES_USER} POSTGRES_USER: ${QUALITY_POSTGRES_USER:?Set QUALITY_POSTGRES_USER}

View File

@ -16,7 +16,6 @@ x-app-environment: &app-environment
PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env} PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env}
PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env} PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env}
PORT: "4000" PORT: "4000"
POOL_SIZE: ${POOL_SIZE:?Set POOL_SIZE in .env after measuring the target profile}
SMTP_RELAY: ${SMTP_RELAY:?Set SMTP_RELAY in .env} SMTP_RELAY: ${SMTP_RELAY:?Set SMTP_RELAY in .env}
SMTP_PORT: ${SMTP_PORT:?Set SMTP_PORT in .env} SMTP_PORT: ${SMTP_PORT:?Set SMTP_PORT in .env}
SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_USERNAME: ${SMTP_USERNAME:-}
@ -45,11 +44,40 @@ x-app-environment: &app-environment
PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_MS:-} PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_MS:-}
services: services:
docker-api-proxy:
image: who-need-help:socket-proxy-local
build:
context: .
dockerfile: Dockerfile.socket-proxy
environment:
CONTAINERS: "1"
EVENTS: "1"
INFO: "1"
NETWORKS: "1"
PING: "1"
POST: "0"
VERSION: "1"
LOG_LEVEL: warning
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- ${DOCKER_SOCKET_GID:?Set DOCKER_SOCKET_GID to the numeric group of /var/run/docker.sock}
networks: [docker-api]
read_only: true
tmpfs:
- /run:uid=99,gid=99,mode=0755
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped
proxy: proxy:
image: traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5 image: traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5
command: command:
- --api.dashboard=false - --api.dashboard=false
- --providers.docker=true - --providers.docker=true
- --providers.docker.endpoint=tcp://docker-api-proxy:2375
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
- --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`) - --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`)
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
@ -57,13 +85,22 @@ services:
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32} - --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
ports: ports:
- "${HTTP_PORT:-4010}:80" - "${HTTP_PORT:-4010}:80"
volumes: depends_on:
- /var/run/docker.sock:/var/run/docker.sock:ro - docker-api-proxy
networks: [edge, internal] networks: [docker-api, edge, ingress]
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
db: db:
image: postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a63116392b95726a4c913766cf6 image: who-need-help:postgis-local
build:
context: .
dockerfile: Dockerfile.postgis
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env} POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
@ -79,13 +116,22 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
networks: [internal] networks: [internal]
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
mailpit: mailpit:
image: axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6 image: axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6
user: "65534:65534"
ports: ports:
- "${MAILPIT_PORT:-8027}:8025" - "${MAILPIT_BIND_ADDRESS:-127.0.0.1}:${MAILPIT_PORT:-8027}:8025"
networks: [edge, internal] networks: [edge, internal]
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
migrate: migrate:
@ -95,11 +141,18 @@ services:
environment: environment:
<<: *app-environment <<: *app-environment
APP_ROLE: migrate APP_ROLE: migrate
POOL_SIZE: ${MIGRATE_POOL_SIZE:-${POOL_SIZE:?Set MIGRATE_POOL_SIZE in .env}}
command: ["/app/bin/migrate"] command: ["/app/bin/migrate"]
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: [internal] networks: [internal]
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: "no" restart: "no"
web: web:
@ -108,12 +161,13 @@ services:
<<: *app-environment <<: *app-environment
APP_ROLE: web APP_ROLE: web
PHX_SERVER: "true" PHX_SERVER: "true"
POOL_SIZE: ${WEB_POOL_SIZE:-${POOL_SIZE:?Set WEB_POOL_SIZE in .env}}
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_internal} - traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_ingress}
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)}
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help}
@ -126,8 +180,14 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
deploy: deploy:
replicas: 2 replicas: ${WEB_REPLICAS:-2}
networks: [internal] networks: [ingress, internal, egress]
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
worker: worker:
@ -135,19 +195,36 @@ services:
environment: environment:
<<: *app-environment <<: *app-environment
APP_ROLE: worker APP_ROLE: worker
POOL_SIZE: ${WORKER_POOL_SIZE:-${POOL_SIZE:?Set WORKER_POOL_SIZE in .env}}
command: ["/app/bin/who_need_help", "start"] command: ["/app/bin/who_need_help", "start"]
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s
timeout: 3s
retries: 10
deploy: deploy:
replicas: 2 replicas: ${WORKER_REPLICAS:-2}
networks: [internal] networks: [internal, egress]
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
networks: networks:
docker-api:
internal: true
edge: edge:
ingress:
internal: true
internal: internal:
internal: true internal: true
egress:
volumes: volumes:
postgres_data: postgres_data:

View File

@ -179,16 +179,16 @@ config :who_need_help,
), ),
push_delivery_options: Keyword.delete(push_configuration, :adapter) push_delivery_options: Keyword.delete(push_configuration, :adapter)
if config_env() == :prod and app_role == :web do if config_env() == :prod and app_role in [:web, :worker] do
metrics_token = metrics_token =
System.get_env("METRICS_TOKEN") || System.get_env("METRICS_TOKEN") ||
raise """ raise """
environment variable METRICS_TOKEN is missing for the web role. environment variable METRICS_TOKEN is missing for the web or worker role.
Generate an independent random value and store it in the deployment secret. Generate an independent random value and store it in the deployment secret.
""" """
if metrics_token == "" do if metrics_token == "" do
raise "METRICS_TOKEN must not be empty for the web role." raise "METRICS_TOKEN must not be empty for the web or worker role."
end end
config :who_need_help, :metrics_token, metrics_token config :who_need_help, :metrics_token, metrics_token

View File

@ -80,7 +80,7 @@ spec:
- name: PORT - name: PORT
value: {{ $root.Values.app.port | quote }} value: {{ $root.Values.app.port | quote }}
- name: POOL_SIZE - name: POOL_SIZE
value: {{ $root.Values.app.poolSize | quote }} value: {{ $settings.poolSize | quote }}
- name: SMTP_RELAY - name: SMTP_RELAY
value: {{ $root.Values.app.smtpRelay | quote }} value: {{ $root.Values.app.smtpRelay | quote }}
- name: SMTP_PORT - name: SMTP_PORT
@ -93,7 +93,6 @@ spec:
value: {{ $root.Values.app.mapTileUrl | quote }} value: {{ $root.Values.app.mapTileUrl | quote }}
- name: DNS_CLUSTER_QUERY - name: DNS_CLUSTER_QUERY
value: "{{ include "who-need-help.fullname" $root }}-headless.{{ $root.Release.Namespace }}.svc.cluster.local" value: "{{ include "who-need-help.fullname" $root }}-headless.{{ $root.Release.Namespace }}.svc.cluster.local"
{{- if eq $component "web" }}
ports: ports:
- name: http - name: http
containerPort: 4000 containerPort: 4000
@ -107,7 +106,6 @@ spec:
path: /healthz/live path: /healthz/live
port: http port: http
periodSeconds: 10 periodSeconds: 10
{{- end }}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true

View File

@ -35,6 +35,8 @@ spec:
value: migrate value: migrate
- name: ERL_ZFLAGS - name: ERL_ZFLAGS
value: {{ printf "+Q %d" (int .Values.app.erlangPortLimit) | quote }} value: {{ printf "+Q %d" (int .Values.app.erlangPortLimit) | quote }}
- name: POOL_SIZE
value: {{ .Values.app.migratePoolSize | quote }}
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true

View File

@ -19,6 +19,23 @@ spec:
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata:
name: {{ include "who-need-help.fullname" . }}-worker-metrics
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
app.kubernetes.io/component: worker
spec:
type: ClusterIP
selector:
{{- include "who-need-help.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: worker
ports:
- name: http
port: 4000
targetPort: http
---
apiVersion: v1
kind: Service
metadata: metadata:
name: {{ include "who-need-help.fullname" . }}-headless name: {{ include "who-need-help.fullname" . }}-headless
labels: labels:

View File

@ -5,9 +5,11 @@ image:
web: web:
replicas: 2 replicas: 2
poolSize: "4"
worker: worker:
replicas: 2 replicas: 2
poolSize: "2"
service: service:
type: ClusterIP type: ClusterIP
@ -19,7 +21,7 @@ app:
scheme: https scheme: https
urlPort: "443" urlPort: "443"
port: "4000" port: "4000"
poolSize: "10" migratePoolSize: "2"
# OTP otherwise derives this from the container nofile ulimit. Some # OTP otherwise derives this from the container nofile ulimit. Some
# Kubernetes runtimes expose a value so large that each BEAM instance # Kubernetes runtimes expose a value so large that each BEAM instance
# preallocates a multi-gigabyte port table. # preallocates a multi-gigabyte port table.

View File

@ -21,6 +21,23 @@ before any rollout. Compose and Helm now set that value explicitly, and the
startup and rolling-verification scripts check the effective value in every startup and rolling-verification scripts check the effective value in every
running web and worker VM. running web and worker VM.
On a host with more CPUs than the intended server, the optional
`compose.cpu-replay.yaml` override can constrain the CPUs visible to each
long-running BEAM container:
```sh
CPU_REPLAY_WEB_CPUS=1 CPU_REPLAY_WORKER_CPUS=1 \
CPU_REPLAY_WEB_SCHEDULERS=1 CPU_REPLAY_WORKER_SCHEDULERS=1 \
docker compose -f compose.yaml -f compose.cpu-replay.yaml up -d --wait
```
All four values are explicit experiment inputs. The override does not set a memory
limit or represent a whole-server CPU budget. The scheduler inputs make total
and online BEAM scheduler counts match the intended replay instead of leaving
offline scheduler threads sized from the development host. None of these
inputs is a production sizing recommendation. Running ordinary
`docker compose up` without the override removes the replay constraints.
After the Helm rollout, the unchanged kind control-plane container measured After the Helm rollout, the unchanged kind control-plane container measured
1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups 1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups
measured 188.5215.6 MiB in the first post-change sample. A subsequent measured 188.5215.6 MiB in the first post-change sample. A subsequent
@ -295,6 +312,35 @@ Ignored before/after evidence:
- `output/performance/pool-sql-final-20260720/` - `output/performance/pool-sql-final-20260720/`
- `output/performance/pool-sql-chat-fixed-canonical-20260720/` - `output/performance/pool-sql-chat-fixed-canonical-20260720/`
## Observed role-specific connection pools
On 2026-07-20 the same 3-web/2-worker, 40 public HTTP VU, 40 Phoenix
heartbeat VU, and 8 authenticated mutual-aid VU profile was repeated with
four Repo connections per web replica and two per worker replica. The migrate
role also started and completed with a two-connection pool. These are measured
development defaults, not production minimums.
The 16 configured long-running Repo slots produced 38,678 HTTP requests with
zero failures, 1,760 complete authenticated page/chat/tracking chains, and 240
Phoenix heartbeat sessions. The application recorded 131,409 web-side queries
with 34.82 microseconds average pool queue time, compared with 31.15
microseconds in the earlier 10-connections-per-replica run. PostgreSQL observed
21 peak client backends, 6 peak active backends, 76 minimum non-reserved
connection headroom, no idle transaction, no lock wait, no rollback, no
deadlock, no conflict, and no temporary file.
The run-scoped application log had no match for DBConnection/Postgrex
connection errors, pool timeouts, exceptions, deadlocks, `FATAL`, or `PANIC`.
All 1,760 messages, tracking sessions, and tracking samples were recorded;
explicit stop left zero current positions, and fixture cleanup restored the
tracked table counts exactly. The result supports using web=4, worker=2, and
migrate=2 as the repository's current measured starting profile. It does not
establish saturation, an SLO, or a production capacity limit.
Ignored evidence:
- `output/performance/role-pools-4-2-20260720/`
## Observed 10-minute authenticated soak ## Observed 10-minute authenticated soak
Observed on 2026-07-19 with the same 3-web/2-worker topology and the same Observed on 2026-07-19 with the same 3-web/2-worker topology and the same

View File

@ -11,6 +11,43 @@ defmodule WhoNeedHelp.Accounts do
## Database getters ## Database getters
@public_user_fields [:id, :display_name, :bio, :tip_url, :inserted_at]
@public_social_identity_fields [
:id,
:provider,
:profile_url,
:handle,
:verified_at,
:user_id,
:inserted_at
]
@doc """
Returns the deliberately small user projection used by public and
participant-facing views.
Authentication, account settings, and moderation queries must continue to
load the full schema explicitly. In particular, this projection never loads
email, password hashes, roles, or moderation notes.
"""
def public_user_query(options \\ []) do
query =
from user in User,
select: struct(user, ^@public_user_fields)
if Keyword.get(options, :social_identities, false) do
public_social_identity = public_social_identity_query()
preload(query, social_identities: ^public_social_identity)
else
query
end
end
def public_social_identity_query do
from identity in SocialIdentity,
select: struct(identity, ^@public_social_identity_fields)
end
@doc """ @doc """
Gets a user by email. Gets a user by email.
@ -72,33 +109,31 @@ defmodule WhoNeedHelp.Accounts do
def eligible_for_trust_actions?(_user), do: false def eligible_for_trust_actions?(_user), do: false
def eligible_user_id?(user_id) do def eligible_user_id?(user_id) do
case Repo.get(User, user_id) do Repo.exists?(
%User{} = user -> eligible_for_trust_actions?(user) from user in User,
nil -> false where:
end user.id == ^user_id and user.moderation_status == :active and
not is_nil(user.confirmed_at) and not is_nil(user.accepted_terms_at)
)
end end
def moderator?(%User{role: role}), do: role in [:moderator, :admin] def moderator?(%User{role: role}), do: role in [:moderator, :admin]
def moderator?(_user), do: false def moderator?(_user), do: false
def moderator_authorized?(%User{id: id}) do def moderator_authorized?(%User{id: id, role: role})
case Repo.get(User, id) do when role in [:moderator, :admin],
%User{} = user -> moderator?(user) do:
nil -> false Repo.exists?(
end from user in User, where: user.id == ^id and user.role in [:moderator, :admin]
end )
def moderator_authorized?(_user), do: false def moderator_authorized?(_user), do: false
def admin?(%User{role: :admin}), do: true def admin?(%User{role: :admin}), do: true
def admin?(_user), do: false def admin?(_user), do: false
def admin_authorized?(%User{id: id}) do def admin_authorized?(%User{id: id, role: :admin}),
case Repo.get(User, id) do do: Repo.exists?(from user in User, where: user.id == ^id and user.role == :admin)
%User{} = user -> admin?(user)
nil -> false
end
end
def admin_authorized?(_user), do: false def admin_authorized?(_user), do: false

View File

@ -5,6 +5,7 @@ defmodule WhoNeedHelp.Activities do
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Activities.{Activity, Message, Participant} alias WhoNeedHelp.Activities.{Activity, Message, Participant}
alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog
@ -36,6 +37,7 @@ defmodule WhoNeedHelp.Activities do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_creator = Accounts.public_user_query(social_identities: true)
Activity Activity
|> where( |> where(
@ -59,9 +61,9 @@ defmodule WhoNeedHelp.Activities do
|> after_open_activity(cursor) |> after_open_activity(cursor)
|> order_by([activity], asc: activity.starts_at, asc: activity.id) |> order_by([activity], asc: activity.starts_at, asc: activity.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(category: :parent, creator: :social_identities, participants: :user) |> with_approved_participant_count()
|> preload([activity], category: :parent, creator: ^public_creator)
|> Repo.all() |> Repo.all()
|> Enum.map(&public_activity/1)
|> Pagination.page(limit, &{&1.starts_at, &1.id}) |> Pagination.page(limit, &{&1.starts_at, &1.id})
end end
@ -82,7 +84,8 @@ defmodule WhoNeedHelp.Activities do
|> before_my_activity(cursor) |> before_my_activity(cursor)
|> order_by([activity], desc: activity.starts_at, desc: activity.id) |> order_by([activity], desc: activity.starts_at, desc: activity.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(category: :parent, participants: :user) |> with_approved_participant_count()
|> preload([activity], category: :parent)
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.starts_at, &1.id}) |> Pagination.page(limit, &{&1.starts_at, &1.id})
end end
@ -121,13 +124,11 @@ defmodule WhoNeedHelp.Activities do
end end
def get_activity(%Scope{user: user}, id) do def get_activity(%Scope{user: user}, id) do
case Repo.get(Activity, id) do case get_loaded_activity(id) do
nil -> nil ->
{:error, :not_found} {:error, :not_found}
activity -> activity ->
activity = load_activity(activity.id)
cond do cond do
activity.creator_id == user.id -> activity.creator_id == user.id ->
{:ok, activity} {:ok, activity}
@ -154,6 +155,7 @@ defmodule WhoNeedHelp.Activities do
if activity.creator_id == user.id or approved_participant?(activity, user.id) do if activity.creator_id == user.id or approved_participant?(activity, user.id) do
limit = Pagination.limit(options, 50) limit = Pagination.limit(options, 50)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
page = page =
Message Message
@ -161,7 +163,7 @@ defmodule WhoNeedHelp.Activities do
|> before_message(cursor) |> before_message(cursor)
|> order_by([message], desc: message.inserted_at, desc: message.id) |> order_by([message], desc: message.inserted_at, desc: message.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(:sender) |> preload([message], sender: ^public_user)
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
@ -356,7 +358,7 @@ defmodule WhoNeedHelp.Activities do
|> Repo.insert() |> Repo.insert()
|> case do |> case do
{:ok, message} -> {:ok, message} ->
message = Repo.preload(message, :sender) message = Repo.preload(message, sender: Accounts.public_user_query())
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub, WhoNeedHelp.PubSub,
@ -559,18 +561,34 @@ defmodule WhoNeedHelp.Activities do
end end
defp load_activity(id) do defp load_activity(id) do
recent_messages =
from message in Message,
order_by: [desc: message.inserted_at, desc: message.id],
limit: 50,
preload: :sender
Activity Activity
|> Repo.get!(id) |> Repo.get!(id)
|> Repo.preload( |> preload_activity()
end
defp get_loaded_activity(id) do
Activity
|> Repo.get(id)
|> preload_activity()
end
defp preload_activity(nil), do: nil
defp preload_activity(activity) do
public_user = Accounts.public_user_query(social_identities: true)
public_sender = Accounts.public_user_query()
recent_messages =
Message
|> order_by([message], desc: message.inserted_at, desc: message.id)
|> limit(50)
|> preload([message], sender: ^public_sender)
Repo.preload(
activity,
category: :parent, category: :parent,
creator: :social_identities, creator: public_user,
participants: [user: :social_identities], participants: [user: public_user],
messages: recent_messages messages: recent_messages
) )
end end
@ -587,8 +605,24 @@ defmodule WhoNeedHelp.Activities do
} }
end end
defp public_activity(activity) do defp with_approved_participant_count(query) do
%{activity | participants: Enum.filter(activity.participants, &(&1.status == :approved))} counts =
Participant
|> where([participant], participant.status == :approved)
|> group_by([participant], participant.activity_id)
|> select([participant], %{
activity_id: participant.activity_id,
approved_count: count(participant.id)
})
query
|> join(:left, [activity], count in subquery(counts),
on: count.activity_id == activity.id,
as: :approved_counts
)
|> select_merge([approved_counts: count], %{
approved_participant_count: fragment("coalesce(?, 0)", count.approved_count)
})
end end
defp locked_activity(id) do defp locked_activity(id) do

View File

@ -24,6 +24,7 @@ defmodule WhoNeedHelp.Activities.Activity do
field :completed_at, :utc_datetime field :completed_at, :utc_datetime
field :hidden_at, :utc_datetime field :hidden_at, :utc_datetime
field :hidden_reason, :string field :hidden_reason, :string
field :approved_participant_count, :integer, virtual: true, default: 0
belongs_to :creator, WhoNeedHelp.Accounts.User belongs_to :creator, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category belongs_to :category, WhoNeedHelp.Catalog.Category

View File

@ -26,9 +26,19 @@ defmodule WhoNeedHelp.Application do
role_children = role_children =
case Application.fetch_env!(:who_need_help, :app_role) do case Application.fetch_env!(:who_need_help, :app_role) do
:web -> [{Oban, oban_client_config}, WhoNeedHelpWeb.Presence, WhoNeedHelpWeb.Endpoint] :web ->
:worker -> [{Oban, oban_config}] [
:migrate -> [{Oban, oban_client_config}] {Oban, oban_client_config},
WhoNeedHelpWeb.Presence,
WhoNeedHelp.TrackingPresenceCleanup,
WhoNeedHelpWeb.Endpoint
]
:worker ->
[{Oban, oban_config}, WhoNeedHelpWeb.WorkerMetricsPlug]
:migrate ->
[{Oban, oban_client_config}]
end end
children = common_children ++ role_children children = common_children ++ role_children

View File

@ -40,13 +40,15 @@ defmodule WhoNeedHelp.Catalog do
def paginate_proposals(options \\ []) do def paginate_proposals(options \\ []) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
CategoryProposal CategoryProposal
|> where([p], p.status == :open) |> where([p], p.status == :open)
|> preload([:proposer, :parent, :votes])
|> before_proposal(cursor) |> before_proposal(cursor)
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> with_vote_count()
|> preload([proposal], proposer: ^public_user, parent: [])
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
end end
@ -163,7 +165,8 @@ defmodule WhoNeedHelp.Catalog do
|> before_proposal(cursor) |> before_proposal(cursor)
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload([:proposer, :parent, :merged_into, :reviewed_by, :votes]) |> with_vote_count()
|> preload([proposal], [:parent, :merged_into])
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
else else
@ -833,6 +836,22 @@ defmodule WhoNeedHelp.Catalog do
] ]
end end
defp with_vote_count(query) do
counts =
CategoryVote
|> group_by([vote], vote.proposal_id)
|> select([vote], %{proposal_id: vote.proposal_id, vote_count: count(vote.id)})
query
|> join(:left, [proposal], count in subquery(counts),
on: count.proposal_id == proposal.id,
as: :vote_counts
)
|> select_merge([vote_counts: count], %{
vote_count: fragment("coalesce(?, 0)", count.vote_count)
})
end
defp before_proposal(query, nil), do: query defp before_proposal(query, nil), do: query
defp before_proposal(query, {inserted_at, id}) do defp before_proposal(query, {inserted_at, id}) do

View File

@ -16,6 +16,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
field :reviewed_at, :utc_datetime field :reviewed_at, :utc_datetime
field :moderation_note, :string field :moderation_note, :string
field :vote_count, :integer, virtual: true, default: 0
has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)

View File

@ -19,7 +19,7 @@ defmodule WhoNeedHelp.CatalogModeration do
reason: proposal.reason, reason: proposal.reason,
mode: proposal.mode, mode: proposal.mode,
parent_slug: parent_slug(proposal.parent), parent_slug: parent_slug(proposal.parent),
community_votes: length(proposal.votes) community_votes: proposal.vote_count
} }
end) end)

View File

@ -57,8 +57,8 @@ defmodule WhoNeedHelp.Help do
|> after_open_request(cursor) |> after_open_request(cursor)
|> order_by([request], asc: request.expires_at, asc: request.id) |> order_by([request], asc: request.expires_at, asc: request.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload([:category, :requester, assignment: :helper])
|> Repo.all() |> Repo.all()
|> preload_request_relations()
|> Pagination.page(limit, &{&1.expires_at, &1.id}) |> Pagination.page(limit, &{&1.expires_at, &1.id})
end end
@ -77,8 +77,8 @@ defmodule WhoNeedHelp.Help do
|> before_my_request(cursor) |> before_my_request(cursor)
|> order_by([request], desc: request.inserted_at, desc: request.id) |> order_by([request], desc: request.inserted_at, desc: request.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload([:category, :requester, assignment: :helper])
|> Repo.all() |> Repo.all()
|> preload_request_relations()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
end end
@ -107,31 +107,39 @@ defmodule WhoNeedHelp.Help do
def get_request!(id) do def get_request!(id) do
HelpRequest HelpRequest
|> Repo.get!(id) |> Repo.get!(id)
|> Repo.preload([ |> then(&preload_request_relations([&1], social_identities: true))
:category, |> hd()
requester: :social_identities,
assignment: [helper: :social_identities]
])
end end
def get_request(%Scope{user: user} = scope, id) do def get_request(%Scope{user: user} = scope, id) do
case Repo.get(HelpRequest, id) do request =
HelpRequest
|> Repo.get(id)
|> case do
nil ->
nil
request ->
[request]
|> preload_request_relations(social_identities: true)
|> hd()
end
case request do
nil -> nil ->
{:error, :not_found} {:error, :not_found}
request -> request ->
request = get_request!(request.id)
cond do cond do
Accounts.moderator_authorized?(user) ->
{:ok, request}
request.requester_id == user.id -> request.requester_id == user.id ->
{:ok, request} {:ok, request}
request.assignment && participant?(scope, request.assignment) -> request.assignment && participant?(scope, request.assignment) ->
{:ok, request} {:ok, request}
Accounts.moderator_authorized?(user) ->
{:ok, request}
not is_nil(request.hidden_at) -> not is_nil(request.hidden_at) ->
{:error, :not_found} {:error, :not_found}
@ -146,7 +154,10 @@ defmodule WhoNeedHelp.Help do
def get_assignment_for_participant(%Scope{} = scope, id) do def get_assignment_for_participant(%Scope{} = scope, id) do
with {:ok, id} <- Ecto.UUID.cast(id), with {:ok, id} <- Ecto.UUID.cast(id),
%Assignment{} = assignment <- Repo.get(Assignment, id), %Assignment{} = assignment <-
Assignment
|> Repo.get(id)
|> Repo.preload(:request),
true <- participant?(scope, assignment) do true <- participant?(scope, assignment) do
{:ok, assignment} {:ok, assignment}
else else
@ -248,7 +259,12 @@ defmodule WhoNeedHelp.Help do
{:ok, assignment} -> {:ok, assignment} ->
request = get_request!(assignment.request_id) request = get_request!(assignment.request_id)
broadcast({:request_updated, request}) broadcast({:request_updated, request})
{:ok, Repo.preload(assignment, [:helper, :request])}
{:ok,
Repo.preload(assignment,
helper: Accounts.public_user_query(),
request: []
)}
other -> other ->
other other
@ -389,6 +405,55 @@ defmodule WhoNeedHelp.Help do
user.id in [assignment.helper_id, request.requester_id] user.id in [assignment.helper_id, request.requester_id]
end end
defp preload_request_relations(requests, options \\ []) do
requests = Repo.preload(requests, [:category, :assignment])
user_ids =
requests
|> Enum.flat_map(fn request ->
[request.requester_id, request.assignment && request.assignment.helper_id]
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
users =
Accounts.public_user_query()
|> where([user], user.id in ^user_ids)
|> Repo.all()
|> maybe_preload_social_identities(options)
|> Map.new(&{&1.id, &1})
Enum.map(requests, fn request ->
assignment =
case request.assignment do
%Assignment{} = assignment ->
%{assignment | helper: Map.fetch!(users, assignment.helper_id)}
nil ->
nil
end
request
|> Map.put(:requester, Map.fetch!(users, request.requester_id))
|> Map.put(:assignment, assignment)
|> attach_assignment_request()
end)
end
defp maybe_preload_social_identities(users, options) do
if Keyword.get(options, :social_identities, false) do
Repo.preload(users, social_identities: Accounts.public_social_identity_query())
else
users
end
end
defp attach_assignment_request(%HelpRequest{assignment: %Assignment{} = assignment} = request) do
%{request | assignment: %{assignment | request: request}}
end
defp attach_assignment_request(%HelpRequest{} = request), do: request
def requester?(%Scope{user: user}, %HelpRequest{requester_id: id}), do: user.id == id def requester?(%Scope{user: user}, %HelpRequest{requester_id: id}), do: user.id == id
def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id
@ -506,7 +571,13 @@ defmodule WhoNeedHelp.Help do
request = get_request!(assignment.request_id) request = get_request!(assignment.request_id)
broadcast({:request_updated, request}) broadcast({:request_updated, request})
if assignment.status == :completed, do: Trust.record_completion_signals(assignment) if assignment.status == :completed, do: Trust.record_completion_signals(assignment)
{:ok, Repo.preload(assignment, [:helper, :request], force: true)}
{:ok,
Repo.preload(
assignment,
[helper: Accounts.public_user_query(), request: []],
force: true
)}
end end
defp after_transition(other), do: other defp after_transition(other), do: other

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelp.Messaging do
@moduledoc "Durable match chat with PubSub fan-out after commit." @moduledoc "Durable match chat with PubSub fan-out after commit."
import Ecto.Query import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Help alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.Assignment alias WhoNeedHelp.Help.Assignment
@ -20,10 +21,13 @@ defmodule WhoNeedHelp.Messaging do
end end
def paginate_messages(%Scope{} = scope, %Assignment{} = assignment, options \\ []) do def paginate_messages(%Scope{} = scope, %Assignment{} = assignment, options \\ []) do
assignment = Repo.preload(assignment, :request)
if Trust.eligible?(scope) and Help.participant?(scope, assignment) and if Trust.eligible?(scope) and Help.participant?(scope, assignment) and
not blocked_assignment?(scope, assignment) do not blocked_assignment?(scope, assignment) do
limit = Pagination.limit(options, 50) limit = Pagination.limit(options, 50)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
page = page =
Message Message
@ -31,7 +35,7 @@ defmodule WhoNeedHelp.Messaging do
|> before_message(cursor) |> before_message(cursor)
|> order_by([message], desc: message.inserted_at, desc: message.id) |> order_by([message], desc: message.inserted_at, desc: message.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(:sender) |> preload([message], sender: ^public_user)
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
@ -42,10 +46,12 @@ defmodule WhoNeedHelp.Messaging do
end end
def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
assignment = Repo.preload(assignment, :request)
with {:ok, _limit} <- Trust.authorize_action(scope, :send_message), with {:ok, _limit} <- Trust.authorize_action(scope, :send_message),
true <- Help.participant?(scope, assignment), true <- Help.participant?(scope, assignment),
false <- blocked_assignment?(scope, assignment) do false <- blocked_assignment?(scope, assignment) do
request = assignment |> Repo.preload(:request) |> Map.fetch!(:request) request = Map.fetch!(assignment, :request)
recipient_id = counterpart_id(user.id, assignment, request) recipient_id = counterpart_id(user.id, assignment, request)
result = result =
@ -66,7 +72,7 @@ defmodule WhoNeedHelp.Messaging do
end) end)
with {:ok, message} <- result do with {:ok, message} <- result do
message = Repo.preload(message, :sender) message = Repo.preload(message, sender: Accounts.public_user_query())
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub, WhoNeedHelp.PubSub,

View File

@ -9,7 +9,7 @@ defmodule WhoNeedHelp.Tracking do
""" """
import Ecto.Query import Ecto.Query
alias Ecto.Multi alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Help alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.Assignment alias WhoNeedHelp.Help.Assignment
@ -21,15 +21,11 @@ defmodule WhoNeedHelp.Tracking do
Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}") Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}")
end end
def active_session?(%Scope{user: user} = scope, %Assignment{} = assignment) do def active_session?(%Scope{user: user} = scope, %Assignment{} = assignment),
Help.participant?(scope, assignment) && do: not is_nil(active_session(scope, assignment, user.id))
Repo.exists?(
from session in TrackingSession, def active_session(%Scope{user: user} = scope, %Assignment{} = assignment),
where: do: active_session(scope, assignment, user.id)
session.assignment_id == ^assignment.id and session.user_id == ^user.id and
session.active
)
end
def start_session( def start_session(
%Scope{user: user} = scope, %Scope{user: user} = scope,
@ -97,43 +93,31 @@ defmodule WhoNeedHelp.Tracking do
def stop_session(%Scope{user: user} = scope, %Assignment{} = assignment) do def stop_session(%Scope{user: user} = scope, %Assignment{} = assignment) do
if Help.participant?(scope, assignment) do if Help.participant?(scope, assignment) do
case Repo.get_by(TrackingSession, stop_locked_session(assignment.id, user.id)
assignment_id: assignment.id,
user_id: user.id,
active: true
) do
nil ->
{:ok, :already_stopped}
session ->
now = DateTime.utc_now(:second)
Multi.new()
|> Multi.delete_all(:positions, where(Position, tracking_session_id: ^session.id))
|> Multi.update(
:session,
TrackingSession.changeset(session, %{active: false, ended_at: now})
)
|> Repo.transaction()
|> case do
{:ok, _} ->
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment.id}",
{:tracking_stopped, user.id}
)
{:ok, :stopped}
{:error, _step, reason, _} ->
{:error, reason}
end
end
else else
{:error, :forbidden} {:error, :forbidden}
end end
end end
def stop_browser_session(assignment_id, user_id, tracking_session_id) do
with {:ok, assignment_id} <- Ecto.UUID.cast(assignment_id),
{:ok, user_id} <- Ecto.UUID.cast(user_id),
{:ok, tracking_session_id} <- Ecto.UUID.cast(tracking_session_id),
%Assignment{} = assignment <-
Assignment
|> Repo.get(assignment_id)
|> Repo.preload(:request),
%Accounts.User{} = user <- Repo.get(Accounts.User, user_id) do
if Help.participant?(Scope.for_user(user), assignment) do
stop_locked_session(assignment.id, user.id, tracking_session_id)
else
{:error, :forbidden}
end
else
_missing_or_invalid -> {:ok, :already_stopped}
end
end
def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do
if Trust.eligible?(scope) and Help.participant?(scope, assignment) do if Trust.eligible?(scope) and Help.participant?(scope, assignment) do
TrackingSession TrackingSession
@ -176,6 +160,67 @@ defmodule WhoNeedHelp.Tracking do
end) end)
end end
defp active_session(scope, assignment, user_id) do
if Help.participant?(scope, assignment) do
Repo.get_by(TrackingSession,
assignment_id: assignment.id,
user_id: user_id,
active: true
)
end
end
defp stop_locked_session(assignment_id, user_id, tracking_session_id \\ nil) do
result =
Repo.transact(fn ->
query =
from session in TrackingSession,
where:
session.assignment_id == ^assignment_id and session.user_id == ^user_id and
session.active,
lock: "FOR UPDATE"
query =
if tracking_session_id,
do: where(query, [session], session.id == ^tracking_session_id),
else: query
case Repo.one(query) do
nil ->
{:ok, :already_stopped}
session ->
Position
|> where([position], position.tracking_session_id == ^session.id)
|> Repo.delete_all()
now = DateTime.utc_now(:second)
case Repo.update(TrackingSession.changeset(session, %{active: false, ended_at: now})) do
{:ok, _session} -> {:ok, :stopped}
{:error, changeset} -> {:error, changeset}
end
end
end)
case result do
{:ok, :stopped} ->
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment_id}",
{:tracking_stopped, user_id}
)
{:ok, :stopped}
{:ok, :already_stopped} ->
{:ok, :already_stopped}
{:error, reason} ->
{:error, reason}
end
end
defp public_position(%Position{ defp public_position(%Position{
position: %Geo.Point{coordinates: {lng, lat}}, position: %Geo.Point{coordinates: {lng, lat}},
accuracy_meters: accuracy, accuracy_meters: accuracy,
@ -188,7 +233,6 @@ defmodule WhoNeedHelp.Tracking do
previous = previous =
Position Position
|> where([position], position.tracking_session_id == ^session.id) |> where([position], position.tracking_session_id == ^session.id)
|> lock("FOR UPDATE")
|> Repo.one() |> Repo.one()
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
@ -214,21 +258,9 @@ defmodule WhoNeedHelp.Tracking do
}) })
|> Repo.update() |> Repo.update()
assignment =
Assignment
|> where([candidate], candidate.id == ^assignment.id)
|> lock("FOR UPDATE")
|> Repo.one!()
assignment =
maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now) maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now)
maybe_mark_proximity(assignment, session, position, now)
assignment = maybe_mark_proximity(assignment, session, position, now) evidence = assignment_evidence(assignment.id)
evidence = %{
helper_movement_observed_at: assignment.helper_movement_observed_at,
proximity_observed_at: assignment.proximity_observed_at
}
{:ok, {position, evidence}} {:ok, {position, evidence}}
end end
@ -237,17 +269,21 @@ defmodule WhoNeedHelp.Tracking do
defp maybe_mark_helper_movement(assignment, user_id, movement_observed_at, now) do defp maybe_mark_helper_movement(assignment, user_id, movement_observed_at, now) do
if assignment.helper_id == user_id and not is_nil(movement_observed_at) and if assignment.helper_id == user_id and not is_nil(movement_observed_at) and
is_nil(assignment.helper_movement_observed_at) do is_nil(assignment.helper_movement_observed_at) do
assignment Assignment
|> Assignment.changeset(%{helper_movement_observed_at: now}) |> where(
|> Repo.update!() [candidate],
else candidate.id == ^assignment.id and
assignment is_nil(candidate.helper_movement_observed_at)
end )
|> Repo.update_all(set: [helper_movement_observed_at: now, updated_at: now])
end end
defp maybe_mark_proximity(%Assignment{proximity_observed_at: observed} = assignment, _, _, _) :ok
end
defp maybe_mark_proximity(%Assignment{proximity_observed_at: observed}, _, _, _)
when not is_nil(observed), when not is_nil(observed),
do: assignment do: :ok
defp maybe_mark_proximity(assignment, session, position, now) do defp maybe_mark_proximity(assignment, session, position, now) do
counterpart_positions = counterpart_positions =
@ -262,12 +298,25 @@ defmodule WhoNeedHelp.Tracking do
|> Repo.all() |> Repo.all()
if Enum.any?(counterpart_positions, &accuracy_envelopes_overlap?(&1, position)) do if Enum.any?(counterpart_positions, &accuracy_envelopes_overlap?(&1, position)) do
assignment Assignment
|> Assignment.changeset(%{proximity_observed_at: now}) |> where(
|> Repo.update!() [candidate],
else candidate.id == ^assignment.id and is_nil(candidate.proximity_observed_at)
assignment )
|> Repo.update_all(set: [proximity_observed_at: now, updated_at: now])
end end
:ok
end
defp assignment_evidence(assignment_id) do
Assignment
|> where([assignment], assignment.id == ^assignment_id)
|> select([assignment], %{
helper_movement_observed_at: assignment.helper_movement_observed_at,
proximity_observed_at: assignment.proximity_observed_at
})
|> Repo.one!()
end end
defp movement_evidence(nil, _current), do: 0.0 defp movement_evidence(nil, _current), do: 0.0

View File

@ -0,0 +1,32 @@
defmodule WhoNeedHelp.TrackingPresenceCleanup do
@moduledoc false
use GenServer
alias WhoNeedHelp.Tracking
alias WhoNeedHelpWeb.Presence
def start_link(options), do: GenServer.start_link(__MODULE__, options, name: __MODULE__)
def maybe_stop(key, assignment_id, user_id, tracking_session_id) do
GenServer.cast(
__MODULE__,
{:maybe_stop, key, assignment_id, user_id, tracking_session_id}
)
end
@impl GenServer
def init(_options), do: {:ok, %{}}
@impl GenServer
def handle_cast(
{:maybe_stop, key, assignment_id, user_id, tracking_session_id},
state
) do
if Presence.get_by_key(Presence.tracking_topic(), key) == [] do
Tracking.stop_browser_session(assignment_id, user_id, tracking_session_id)
end
{:noreply, state}
end
end

View File

@ -25,13 +25,7 @@ defmodule WhoNeedHelp.Trust do
} }
def authorize_action(%Scope{user: %User{} = user}, action) do def authorize_action(%Scope{user: %User{} = user}, action) do
user = Repo.get(User, user.id) if Accounts.eligible_user_id?(user.id) do
cond do
not Accounts.eligible_for_trust_actions?(user) ->
{:error, :account_not_eligible}
true ->
case RateLimiter.check(action, user.id) do case RateLimiter.check(action, user.id) do
{:error, :rate_limited} = error -> {:error, :rate_limited} = error ->
create_velocity_signal_once(user.id, action) create_velocity_signal_once(user.id, action)
@ -40,6 +34,8 @@ defmodule WhoNeedHelp.Trust do
result -> result ->
result result
end end
else
{:error, :account_not_eligible}
end end
end end
@ -107,26 +103,45 @@ defmodule WhoNeedHelp.Trust do
def paginate_visible_reviews(user_id, options \\ []) do def paginate_visible_reviews(user_id, options \\ []) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
Review Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at)) |> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> before_review(cursor) |> before_review(cursor)
|> order_by([review], desc: review.inserted_at, desc: review.id) |> order_by([review], desc: review.inserted_at, desc: review.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(:reviewer) |> preload([review], reviewer: ^public_user)
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
end end
def reputation(user_id) do @empty_reputation %{
completed: 0,
unique_people: 0,
verified_handovers: 0,
location_supported: 0,
rating: nil
}
def reputation(user_id), do: Map.fetch!(reputations([user_id]), user_id)
def reputations([]), do: %{}
def reputations(user_ids) when is_list(user_ids) do
user_ids = Enum.uniq(user_ids)
helper_rows = helper_rows =
Assignment Assignment
|> join(:inner, [assignment], request in HelpRequest, |> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id on: request.id == assignment.request_id
) )
|> where([assignment], assignment.status == :completed and assignment.helper_id == ^user_id) |> where(
[assignment],
assignment.status == :completed and assignment.helper_id in ^user_ids
)
|> select([assignment, request], %{ |> select([assignment, request], %{
id: assignment.id, id: assignment.id,
user_id: assignment.helper_id,
counterpart_id: request.requester_id, counterpart_id: request.requester_id,
verified: not is_nil(assignment.handover_verified_at), verified: not is_nil(assignment.handover_verified_at),
location_supported: location_supported:
@ -141,29 +156,64 @@ defmodule WhoNeedHelp.Trust do
) )
|> where( |> where(
[assignment, request], [assignment, request],
assignment.status == :completed and request.requester_id == ^user_id and assignment.status == :completed and request.requester_id in ^user_ids and
assignment.helper_id != ^user_id assignment.helper_id != request.requester_id
) )
|> select([assignment], %{ |> select([assignment, request], %{
id: assignment.id, id: assignment.id,
user_id: request.requester_id,
counterpart_id: assignment.helper_id, counterpart_id: assignment.helper_id,
verified: not is_nil(assignment.handover_verified_at), verified: not is_nil(assignment.handover_verified_at),
location_supported: not is_nil(assignment.proximity_observed_at) location_supported: not is_nil(assignment.proximity_observed_at)
}) })
aggregate = aggregates =
helper_rows helper_rows
|> union_all(^requester_rows) |> union_all(^requester_rows)
|> subquery() |> subquery()
|> group_by([row], row.user_id)
|> select([row], %{ |> select([row], %{
user_id: row.user_id,
completed: count(row.id), completed: count(row.id),
unique_people: count(row.counterpart_id, :distinct), unique_people: count(row.counterpart_id, :distinct),
verified_handovers: filter(count(row.id), row.verified), verified_handovers: filter(count(row.id), row.verified),
location_supported: filter(count(row.id), row.location_supported) location_supported: filter(count(row.id), row.location_supported)
}) })
|> Repo.one!()
Map.put(aggregate, :rating, average_rating(user_id)) ratings =
Review
|> where(
[review],
review.reviewee_id in ^user_ids and not is_nil(review.revealed_at)
)
|> group_by([review], review.reviewee_id)
|> select([review], %{user_id: review.reviewee_id, rating: avg(review.rating)})
rows =
aggregates
|> subquery()
|> join(:left, [aggregate], rating in subquery(ratings),
on: rating.user_id == aggregate.user_id
)
|> select([aggregate, rating], %{
user_id: aggregate.user_id,
completed: aggregate.completed,
unique_people: aggregate.unique_people,
verified_handovers: aggregate.verified_handovers,
location_supported: aggregate.location_supported,
rating: rating.rating
})
|> Repo.all()
Enum.reduce(rows, Map.new(user_ids, &{&1, @empty_reputation}), fn row, reputations ->
Map.put(
reputations,
row.user_id,
row
|> Map.delete(:user_id)
|> Map.update!(:rating, &decimal_average/1)
)
end)
end end
def leaderboard do def leaderboard do
@ -389,13 +439,14 @@ defmodule WhoNeedHelp.Trust do
def paginate_blocks(%Scope{user: user}, options \\ []) do def paginate_blocks(%Scope{user: user}, options \\ []) do
limit = Pagination.limit(options) limit = Pagination.limit(options)
cursor = Pagination.cursor(options) cursor = Pagination.cursor(options)
public_user = Accounts.public_user_query()
Block Block
|> where([block], block.blocker_id == ^user.id) |> where([block], block.blocker_id == ^user.id)
|> before_block(cursor) |> before_block(cursor)
|> order_by([block], desc: block.inserted_at, desc: block.id) |> order_by([block], desc: block.inserted_at, desc: block.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))
|> preload(:blocked) |> preload([block], blocked: ^public_user)
|> Repo.all() |> Repo.all()
|> Pagination.page(limit, &{&1.inserted_at, &1.id}) |> Pagination.page(limit, &{&1.inserted_at, &1.id})
end end
@ -685,14 +736,6 @@ defmodule WhoNeedHelp.Trust do
|> Repo.insert() |> Repo.insert()
end end
defp average_rating(user_id) do
Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> select([review], avg(review.rating))
|> Repo.one()
|> decimal_average()
end
defp users_by_id([]), do: %{} defp users_by_id([]), do: %{}
defp users_by_id(ids) do defp users_by_id(ids) do

View File

@ -12,44 +12,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
def perform(_job) do def perform(_job) do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
ids = with {:ok, expired} <- expire_available(now, 0),
HelpRequest {:ok, cleanup} <- Tracking.cleanup_finished_sessions() do
|> where([r], r.status == :open and r.expires_at <= ^now)
|> select([request], request.id)
|> Repo.all()
expired =
Enum.count(ids, fn id ->
Repo.transact(fn ->
request =
HelpRequest
|> where(
[request],
request.id == ^id and request.status == :open and request.expires_at <= ^now
)
|> lock("FOR UPDATE")
|> Repo.one()
if request do
with {:ok, request} <-
request
|> Ecto.Changeset.change(status: :expired)
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(nil, "request.expired", "request", request.id) do
{:ok, request}
end
else
{:error, :already_transitioned}
end
end)
|> case do
{:ok, _request} -> true
_other -> false
end
end)
{:ok, cleanup} = Tracking.cleanup_finished_sessions()
{pruned_buckets, _} = RateLimiter.prune_expired() {pruned_buckets, _} = RateLimiter.prune_expired()
{:ok, {:ok,
@ -61,3 +25,41 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
}} }}
end end
end end
# Claim a single row per short transaction. This keeps memory bounded and
# allows another worker to make progress without waiting for a large backlog
# lock. The recursion is tail-recursive and stops on the first empty claim.
defp expire_available(now, expired) do
result =
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.status == :open and request.expires_at <= ^now)
|> order_by([request], asc: request.expires_at, asc: request.id)
|> limit(1)
|> lock("FOR UPDATE SKIP LOCKED")
|> Repo.one()
case request do
nil ->
{:ok, :empty}
request ->
with {:ok, request} <-
request
|> Ecto.Changeset.change(status: :expired)
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(nil, "request.expired", "request", request.id) do
{:ok, request}
end
end
end)
case result do
{:ok, :empty} -> {:ok, expired}
{:ok, %HelpRequest{}} -> expire_available(now, expired + 1)
{:error, reason} -> {:error, reason}
end
end
end

View File

@ -1,6 +1,8 @@
defmodule WhoNeedHelpWeb.MetricsController do defmodule WhoNeedHelpWeb.MetricsController do
use WhoNeedHelpWeb, :controller use WhoNeedHelpWeb, :controller
alias WhoNeedHelpWeb.MetricsAccess
@content_type "text/plain; version=0.0.4" @content_type "text/plain; version=0.0.4"
# Sobelow's HTML-oriented SendResp check cannot infer this fixed Prometheus # Sobelow's HTML-oriented SendResp check cannot infer this fixed Prometheus
@ -9,7 +11,7 @@ defmodule WhoNeedHelpWeb.MetricsController do
def show(conn, _params) do def show(conn, _params) do
token = Application.get_env(:who_need_help, :metrics_token) token = Application.get_env(:who_need_help, :metrics_token)
if authorized?(get_req_header(conn, "authorization"), token) do if MetricsAccess.authorized?(get_req_header(conn, "authorization"), token) do
body = TelemetryMetricsPrometheus.Core.scrape(:prometheus_metrics) body = TelemetryMetricsPrometheus.Core.scrape(:prometheus_metrics)
conn conn
@ -23,12 +25,4 @@ defmodule WhoNeedHelpWeb.MetricsController do
|> send_resp(:unauthorized, "Unauthorized\n") |> send_resp(:unauthorized, "Unauthorized\n")
end end
end end
defp authorized?(["Bearer " <> candidate], expected)
when is_binary(expected) and expected != "" and
byte_size(candidate) == byte_size(expected) do
Plug.Crypto.secure_compare(candidate, expected)
end
defp authorized?(_authorization, _expected), do: false
end end

View File

@ -175,7 +175,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
)} )}
</span> </span>
<span class="badge badge-outline"> <span class="badge badge-outline">
{Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity} {activity.approved_participant_count}/{activity.capacity}
</span> </span>
</div> </div>
<h2 class="text-xl font-bold">{activity.title}</h2> <h2 class="text-xl font-bold">{activity.title}</h2>

View File

@ -148,7 +148,7 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
</p> </p>
</div> </div>
<button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm"> <button phx-click="vote" phx-value-id={proposal.id} class="btn btn-outline btn-sm">
{length(proposal.votes)} {proposal.vote_count}
</button> </button>
</div> </div>
</article> </article>

View File

@ -478,7 +478,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do
</h3> </h3>
<p class="mt-1 text-sm">{proposal.reason}</p> <p class="mt-1 text-sm">{proposal.reason}</p>
<p class="mt-1 text-xs text-base-content/50"> <p class="mt-1 text-xs text-base-content/50">
{gettext("%{count} community votes", count: length(proposal.votes))} {gettext("%{count} community votes", count: proposal.vote_count)}
</p> </p>
<div :if={proposal.status == :open} class="mt-4 grid gap-4 xl:grid-cols-2"> <div :if={proposal.status == :open} class="mt-4 grid gap-4 xl:grid-cols-2">
<.form <.form

View File

@ -2,6 +2,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
use WhoNeedHelpWeb, :live_view use WhoNeedHelpWeb, :live_view
alias WhoNeedHelp.{Help, Messaging, Tracking, Trust} alias WhoNeedHelp.{Help, Messaging, Tracking, Trust}
alias WhoNeedHelpWeb.Presence
@e2e_routes Application.compile_env(:who_need_help, :e2e_routes, false) @e2e_routes Application.compile_env(:who_need_help, :e2e_routes, false)
@ -16,6 +17,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
socket = socket =
socket socket
|> assign(:subscribed_assignment_id, nil) |> assign(:subscribed_assignment_id, nil)
|> assign(:tracking_presence_key, nil)
|> assign(:tracking_session_id, nil)
|> assign(:native_client, native_client) |> assign(:native_client, native_client)
|> assign( |> assign(
:e2e_runtime_node, :e2e_runtime_node,
@ -33,13 +36,18 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
socket = maybe_subscribe_assignment(socket, request) socket = maybe_subscribe_assignment(socket, request)
tracking_active = tracking_session =
request.assignment && request.assignment &&
Tracking.active_session?(socket.assigns.current_scope, request.assignment) Tracking.active_session(socket.assigns.current_scope, request.assignment)
socket = socket =
socket socket
|> load(request, tracking_active) |> assign(
:tracking_session_id,
if(tracking_session, do: tracking_session.id, else: nil)
)
|> load(request, not is_nil(tracking_session))
|> maybe_track_browser_presence()
|> maybe_start_native_tracking() |> maybe_start_native_tracking()
{:ok, socket} {:ok, socket}
@ -88,7 +96,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign_positions(positions) |> assign_positions(positions)
|> then(fn socket -> |> then(fn socket ->
if user_id == socket.assigns.current_scope.user.id do if user_id == socket.assigns.current_scope.user.id do
assign(socket, :tracking_active, false) socket
|> assign(:tracking_active, false)
|> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence()
else else
socket socket
end end
@ -171,10 +182,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("start-tracking", _, socket) do def handle_event("start-tracking", _, socket) do
case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do
{:ok, _} -> {:ok, tracking_session} ->
{:noreply, {:noreply,
socket socket
|> assign(:tracking_active, true) |> assign(:tracking_active, true)
|> assign(:tracking_session_id, tracking_session.id)
|> maybe_track_browser_presence()
|> maybe_start_native_tracking()} |> maybe_start_native_tracking()}
{:error, reason} -> {:error, reason} ->
@ -199,6 +212,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:noreply, {:noreply,
socket socket
|> assign(:tracking_active, false) |> assign(:tracking_active, false)
|> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence()
|> maybe_stop_native_tracking()} |> maybe_stop_native_tracking()}
{:error, reason} -> {:error, reason} ->
@ -268,13 +283,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("block-user", _, socket) do def handle_event("block-user", _, socket) do
case Trust.block(socket.assigns.current_scope, socket.assigns.other_user_id) do case Trust.block(socket.assigns.current_scope, socket.assigns.other_user_id) do
{:ok, _block} -> {:ok, _block} ->
if socket.assigns.tracking_active and socket.assigns.assignment do
Tracking.stop_session(socket.assigns.current_scope, socket.assigns.assignment)
end
{:noreply, {:noreply,
socket socket
|> maybe_untrack_browser_presence()
|> assign(:blocked_by_current, true) |> assign(:blocked_by_current, true)
|> assign(:messages, []) |> assign(:messages, [])
|> assign(:messages_cursor, nil) |> assign(:messages_cursor, nil)
|> assign(:positions, %{}) |> assign(:positions, %{})
|> assign(:tracking_active, false) |> assign(:tracking_active, false)
|> assign(:tracking_session_id, nil)
|> assign( |> assign(
:markers, :markers,
Jason.encode!(request_markers(socket.assigns.current_scope, socket.assigns.request)) Jason.encode!(request_markers(socket.assigns.current_scope, socket.assigns.request))
@ -302,25 +323,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
end end
end end
@impl true
def terminate(_reason, socket) do
case socket.assigns do
%{
tracking_active: true,
native_client: false,
current_scope: scope,
assignment: assignment
}
when not is_nil(assignment) ->
Tracking.stop_session(scope, assignment)
_ ->
:ok
end
:ok
end
defp maybe_start_native_tracking( defp maybe_start_native_tracking(
%{assigns: %{native_client: true, tracking_active: true, assignment: assignment}} = %{assigns: %{native_client: true, tracking_active: true, assignment: assignment}} =
socket socket
@ -337,6 +339,39 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
defp maybe_stop_native_tracking(socket), do: socket defp maybe_stop_native_tracking(socket), do: socket
defp maybe_track_browser_presence(
%{
assigns: %{
native_client: false,
tracking_active: true,
tracking_presence_key: nil,
tracking_session_id: tracking_session_id,
assignment: assignment,
current_scope: %{user: user}
}
} = socket
)
when not is_nil(assignment) do
if connected?(socket) do
case Presence.track_browser(self(), assignment.id, user.id, tracking_session_id) do
{:ok, key} -> assign(socket, :tracking_presence_key, key)
_error -> socket
end
else
socket
end
end
defp maybe_track_browser_presence(socket), do: socket
defp maybe_untrack_browser_presence(%{assigns: %{tracking_presence_key: key}} = socket)
when is_binary(key) do
Presence.untrack_browser(self(), key)
assign(socket, :tracking_presence_key, nil)
end
defp maybe_untrack_browser_presence(socket), do: socket
defp transition(socket, fun, success) do defp transition(socket, fun, success) do
case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do
{:ok, _} -> {:noreply, put_flash(socket, :info, success)} {:ok, _} -> {:noreply, put_flash(socket, :info, success)}
@ -366,6 +401,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
do: Tracking.list_current_positions(socket.assigns.current_scope, assignment), do: Tracking.list_current_positions(socket.assigns.current_scope, assignment),
else: %{} else: %{}
reputations =
[request.requester_id, assignment && assignment.helper_id]
|> Enum.reject(&is_nil/1)
|> Trust.reputations()
socket socket
|> assign(:page_title, request.title) |> assign(:page_title, request.title)
|> assign(:request, request) |> assign(:request, request)
@ -373,10 +413,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:structured_details, :structured_details,
structured_details(request, Gettext.get_locale(WhoNeedHelpWeb.Gettext)) structured_details(request, Gettext.get_locale(WhoNeedHelpWeb.Gettext))
) )
|> assign(:requester_reputation, Trust.reputation(request.requester_id)) |> assign(:requester_reputation, Map.fetch!(reputations, request.requester_id))
|> assign( |> assign(
:helper_reputation, :helper_reputation,
if(assignment, do: Trust.reputation(assignment.helper_id), else: nil) if(assignment, do: Map.fetch!(reputations, assignment.helper_id), else: nil)
) )
|> assign(:assignment, assignment) |> assign(:assignment, assignment)
|> assign(:participant, participant) |> assign(:participant, participant)

View File

@ -0,0 +1,11 @@
defmodule WhoNeedHelpWeb.MetricsAccess do
@moduledoc false
def authorized?(["Bearer " <> candidate], expected)
when is_binary(expected) and expected != "" and
byte_size(candidate) == byte_size(expected) do
Plug.Crypto.secure_compare(candidate, expected)
end
def authorized?(_authorization, _expected), do: false
end

View File

@ -2,4 +2,57 @@ defmodule WhoNeedHelpWeb.Presence do
use Phoenix.Presence, use Phoenix.Presence,
otp_app: :who_need_help, otp_app: :who_need_help,
pubsub_server: WhoNeedHelp.PubSub pubsub_server: WhoNeedHelp.PubSub
@tracking_topic "browser-tracking-presence"
def tracking_topic, do: @tracking_topic
def tracking_key(assignment_id, user_id), do: "#{assignment_id}:#{user_id}"
def track_browser(pid, assignment_id, user_id, tracking_session_id) do
key = tracking_key(assignment_id, user_id)
case track(pid, @tracking_topic, key, %{
assignment_id: assignment_id,
user_id: user_id,
tracking_session_id: tracking_session_id
}) do
{:ok, _ref} -> {:ok, key}
{:error, {:already_tracked, _pid, @tracking_topic, ^key}} -> {:ok, key}
other -> other
end
end
def untrack_browser(pid, key), do: untrack(pid, @tracking_topic, key)
@impl Phoenix.Presence
def init(_options), do: {:ok, %{}}
@impl Phoenix.Presence
def handle_metas(@tracking_topic, %{leaves: leaves}, presences, state) do
Enum.each(leaves, fn {key, %{metas: metas}} ->
if not Map.has_key?(presences, key) do
case List.first(metas) do
%{
assignment_id: assignment_id,
user_id: user_id,
tracking_session_id: tracking_session_id
} ->
WhoNeedHelp.TrackingPresenceCleanup.maybe_stop(
key,
assignment_id,
user_id,
tracking_session_id
)
_other ->
:ok
end
end
end)
{:ok, state}
end
def handle_metas(_topic, _diff, _presences, state), do: {:ok, state}
end end

View File

@ -9,7 +9,7 @@ defmodule WhoNeedHelpWeb.Telemetry do
@impl true @impl true
def init(_arg) do def init(_arg) do
reporter_children = reporter_children =
if Application.fetch_env!(:who_need_help, :app_role) == :web do if Application.fetch_env!(:who_need_help, :app_role) in [:web, :worker] do
[ [
{TelemetryMetricsPrometheus.Core, {TelemetryMetricsPrometheus.Core,
name: :prometheus_metrics, metrics: prometheus_metrics(), start_async: false} name: :prometheus_metrics, metrics: prometheus_metrics(), start_async: false}
@ -43,6 +43,15 @@ defmodule WhoNeedHelpWeb.Telemetry do
end, end,
description: "Cumulative HTTP request duration" description: "Cumulative HTTP request duration"
), ),
distribution("who_need_help.http.request.duration.seconds",
event_name: [:phoenix, :endpoint, :stop],
measurement: :duration,
unit: {:native, :second},
reporter_options: [
buckets: [0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]
],
description: "HTTP request duration distribution"
),
counter("who_need_help.http.exceptions.total", counter("who_need_help.http.exceptions.total",
event_name: [:phoenix, :router_dispatch, :exception], event_name: [:phoenix, :router_dispatch, :exception],
measurement: :duration, measurement: :duration,
@ -76,6 +85,48 @@ defmodule WhoNeedHelpWeb.Telemetry do
end, end,
description: "Cumulative time waiting for a database connection" description: "Cumulative time waiting for a database connection"
), ),
distribution("who_need_help.database.query.duration.seconds",
event_name: [:who_need_help, :repo, :query],
measurement: :total_time,
unit: {:native, :second},
reporter_options: [
buckets: [
0.000_05,
0.000_1,
0.000_25,
0.000_5,
0.001,
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1
]
],
description: "Database query duration distribution"
),
distribution("who_need_help.database.query.queue.duration.seconds",
event_name: [:who_need_help, :repo, :query],
measurement: fn measurements -> Map.get(measurements, :queue_time, 0) end,
unit: {:native, :second},
reporter_options: [
buckets: [
0.000_01,
0.000_025,
0.000_05,
0.000_1,
0.000_25,
0.000_5,
0.001,
0.0025,
0.005,
0.01,
0.025
]
],
description: "Time waiting for an Ecto connection"
),
sum("who_need_help.database.query.decode.duration.microseconds.total", sum("who_need_help.database.query.decode.duration.microseconds.total",
event_name: [:who_need_help, :repo, :query], event_name: [:who_need_help, :repo, :query],
measurement: fn measurements -> measurement: fn measurements ->
@ -90,6 +141,34 @@ defmodule WhoNeedHelpWeb.Telemetry do
measurement: :duration, measurement: :duration,
description: "Completed WebSocket connection attempts" description: "Completed WebSocket connection attempts"
), ),
counter("who_need_help.oban.jobs.completed.total",
event_name: [:oban, :job, :stop],
measurement: :duration,
tags: [:queue],
description: "Completed Oban jobs"
),
counter("who_need_help.oban.jobs.failed.total",
event_name: [:oban, :job, :exception],
measurement: :duration,
tags: [:queue],
description: "Failed Oban job attempts"
),
sum("who_need_help.oban.job.duration.microseconds.total",
event_name: [:oban, :job, :stop],
measurement: fn measurements ->
System.convert_time_unit(measurements.duration, :native, :microsecond)
end,
tags: [:queue],
description: "Cumulative successful Oban execution duration"
),
sum("who_need_help.oban.job.queue.duration.microseconds.total",
event_name: [:oban, :job, :stop],
measurement: fn measurements ->
System.convert_time_unit(measurements.queue_time, :native, :microsecond)
end,
tags: [:queue],
description: "Cumulative Oban queue wait duration"
),
last_value("who_need_help.vm.memory.total.bytes", last_value("who_need_help.vm.memory.total.bytes",
event_name: [:vm, :memory], event_name: [:vm, :memory],
measurement: :total, measurement: :total,

View File

@ -0,0 +1,76 @@
defmodule WhoNeedHelpWeb.WorkerMetricsPlug do
@moduledoc """
Internal-only health and Prometheus endpoint for worker-role releases.
Compose and Kubernetes keep this listener off public ingress. Metrics still
require the same independent bearer credential as web replicas.
"""
import Plug.Conn
alias WhoNeedHelpWeb.MetricsAccess
@content_type "text/plain; version=0.0.4"
def init(options), do: options
def call(%Plug.Conn{method: "GET", path_info: ["healthz", "live"]} = conn, _options) do
json(conn, :ok, %{status: "ok", node: to_string(Node.self()), role: "worker"})
end
def call(%Plug.Conn{method: "GET", path_info: ["healthz", "ready"]} = conn, _options) do
database_ready? =
match?({:ok, _}, Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []))
if database_ready? and is_pid(Oban.whereis(Oban)) do
json(conn, :ok, %{status: "ready", node: to_string(Node.self()), role: "worker"})
else
json(conn, :service_unavailable, %{status: "not_ready", role: "worker"})
end
end
# Sobelow's HTML-oriented SendResp check cannot infer this fixed Prometheus
# text content type. The response is generated by the metrics registry and
# is not rendered in an HTML context.
# sobelow_skip ["XSS.SendResp"]
def call(%Plug.Conn{method: "GET", path_info: ["metrics"]} = conn, _options) do
token = Application.get_env(:who_need_help, :metrics_token)
if MetricsAccess.authorized?(get_req_header(conn, "authorization"), token) do
body = TelemetryMetricsPrometheus.Core.scrape(:prometheus_metrics)
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_content_type(@content_type)
|> send_resp(:ok, body)
else
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_header("www-authenticate", "Bearer")
|> send_resp(:unauthorized, "Unauthorized\n")
end
end
def call(conn, _options), do: send_resp(conn, :not_found, "Not found\n")
def child_spec(_options) do
endpoint_config = Application.fetch_env!(:who_need_help, WhoNeedHelpWeb.Endpoint)
port = endpoint_config |> Keyword.fetch!(:http) |> Keyword.fetch!(:port)
Bandit.child_spec(
plug: __MODULE__,
scheme: :http,
ip: {0, 0, 0, 0},
port: port,
startup_log: false
)
|> Supervisor.child_spec(id: __MODULE__)
end
defp json(conn, status, body) do
conn
|> put_resp_header("cache-control", "no-store")
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
end

View File

@ -173,6 +173,202 @@
], ],
"title": "Database query rate by replica", "title": "Database query rate by replica",
"type": "timeseries" "type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "wnh-prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 16
},
"id": 5,
"targets": [
{
"editorMode": "code",
"expr": "sum(up{job=\"who-need-help-worker\"})",
"legendFormat": "available",
"range": true,
"refId": "A"
}
],
"title": "Available worker replicas",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "wnh-prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 16,
"x": 8,
"y": 16
},
"id": 6,
"targets": [
{
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum by (le, instance) (rate(who_need_help_http_request_duration_seconds_bucket{job=\"who-need-help-web\"}[1m])))",
"legendFormat": "p95 {{instance}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "histogram_quantile(0.99, sum by (le, instance) (rate(who_need_help_http_request_duration_seconds_bucket{job=\"who-need-help-web\"}[1m])))",
"legendFormat": "p99 {{instance}}",
"range": true,
"refId": "B"
}
],
"title": "HTTP request latency by web replica",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "wnh-prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 24
},
"id": 7,
"targets": [
{
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum by (le, instance) (rate(who_need_help_database_query_duration_seconds_bucket[1m])))",
"legendFormat": "query p95 {{instance}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum by (le, instance) (rate(who_need_help_database_query_queue_duration_seconds_bucket[1m])))",
"legendFormat": "pool wait p95 {{instance}}",
"range": true,
"refId": "B"
}
],
"title": "Database query and pool-wait latency",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "wnh-prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "ops"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 24
},
"id": 8,
"targets": [
{
"editorMode": "code",
"expr": "sum by (queue) (rate(who_need_help_oban_jobs_completed_total{job=\"who-need-help-worker\"}[1m]))",
"legendFormat": "completed {{queue}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "sum by (queue) (rate(who_need_help_oban_jobs_failed_total{job=\"who-need-help-worker\"}[1m]))",
"legendFormat": "failed {{queue}}",
"range": true,
"refId": "B"
}
],
"title": "Oban job outcomes by queue",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "wnh-prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "ms"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 32
},
"id": 9,
"targets": [
{
"editorMode": "code",
"expr": "sum by (queue) (rate(who_need_help_oban_job_duration_microseconds_total{job=\"who-need-help-worker\"}[5m])) / sum by (queue) (rate(who_need_help_oban_jobs_completed_total{job=\"who-need-help-worker\"}[5m])) / 1000",
"legendFormat": "execution {{queue}}",
"range": true,
"refId": "A"
},
{
"editorMode": "code",
"expr": "sum by (queue) (rate(who_need_help_oban_job_queue_duration_microseconds_total{job=\"who-need-help-worker\"}[5m])) / sum by (queue) (rate(who_need_help_oban_jobs_completed_total{job=\"who-need-help-worker\"}[5m])) / 1000",
"legendFormat": "queue wait {{queue}}",
"range": true,
"refId": "B"
}
],
"title": "Average Oban execution and queue wait",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "wnh-prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 32
},
"id": 10,
"targets": [
{
"editorMode": "code",
"expr": "who_need_help_vm_run_queue_total",
"legendFormat": "{{job}} {{instance}}",
"range": true,
"refId": "A"
}
],
"title": "BEAM scheduler run queue by replica",
"type": "timeseries"
} }
], ],
"refresh": "5s", "refresh": "5s",

View File

@ -25,3 +25,13 @@ scrape_configs:
- files: - files:
- /runtime/web-targets.json - /runtime/web-targets.json
refresh_interval: __SCRAPE_INTERVAL__ refresh_interval: __SCRAPE_INTERVAL__
- job_name: who-need-help-worker
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /runtime/metrics-token
file_sd_configs:
- files:
- /runtime/worker-targets.json
refresh_interval: __SCRAPE_INTERVAL__

View File

@ -8,3 +8,10 @@ groups:
annotations: annotations:
summary: "The local Prometheus scraper cannot reach one web replica" summary: "The local Prometheus scraper cannot reach one web replica"
description: "The direct metrics target {{ $labels.instance }} is unavailable." description: "The direct metrics target {{ $labels.instance }} is unavailable."
- alert: WhoNeedHelpWorkerReplicaUnavailable
expr: up{job="who-need-help-worker"} == 0
labels:
severity: local-drill
annotations:
summary: "The local Prometheus scraper cannot reach one worker replica"
description: "The direct worker metrics target {{ $labels.instance }} is unavailable."

View File

@ -136,15 +136,17 @@ secret_key_base=$(openssl rand -hex 64)
handover_secret=$(openssl rand -hex 64) handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64) release_cookie=$(openssl rand -hex 64)
metrics_token=$(openssl rand -hex 32) metrics_token=$(openssl rand -hex 32)
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
cat >"$env_file" <<EOF cat >"$env_file" <<EOF
HTTP_PORT=0 HTTP_PORT=0
MAILPIT_PORT=0 MAILPIT_PORT=0
DOCKER_SOCKET_GID=$docker_socket_gid
TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_RETRY_ATTEMPTS=3
TRAEFIK_PROJECT_CONSTRAINT=$project TRAEFIK_PROJECT_CONSTRAINT=$project
TRAEFIK_APP_NAME=wnh-portability-$compact_id TRAEFIK_APP_NAME=wnh-portability-$compact_id
TRAEFIK_DOCKER_NETWORK=${project}_internal TRAEFIK_DOCKER_NETWORK=${project}_ingress
TRAEFIK_ROUTER_RULE='Host(\`portability.local\`)' TRAEFIK_ROUTER_RULE='Host(\`portability.local\`)'
PHX_HOST=portability.local PHX_HOST=portability.local
PHX_SCHEME=http PHX_SCHEME=http
@ -158,7 +160,11 @@ POSTGRES_DB=$postgres_db
POSTGRES_USER=$postgres_user POSTGRES_USER=$postgres_user
POSTGRES_PASSWORD=$postgres_password POSTGRES_PASSWORD=$postgres_password
DATABASE_URL=ecto://$postgres_user:$postgres_password@db/$postgres_db DATABASE_URL=ecto://$postgres_user:$postgres_password@db/$postgres_db
POOL_SIZE=10 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2
WEB_REPLICAS=2
WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=$secret_key_base SECRET_KEY_BASE=$secret_key_base
HANDOVER_SECRET=$handover_secret HANDOVER_SECRET=$handover_secret

View File

@ -22,7 +22,7 @@ mkdir -p "$output_dir"
export TRAEFIK_PROJECT_CONSTRAINT="$project" export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id" export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id"
export TRAEFIK_DOCKER_NETWORK="${project}_internal" export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
export E2E_OUTPUT_DIR="$output_dir" export E2E_OUTPUT_DIR="$output_dir"
export E2E_UID export E2E_UID
E2E_UID=$(id -u) E2E_UID=$(id -u)

View File

@ -40,10 +40,12 @@ secret_key_base=$(openssl rand -hex 64)
handover_secret=$(openssl rand -hex 64) handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64) release_cookie=$(openssl rand -hex 64)
metrics_token=$(openssl rand -hex 32) metrics_token=$(openssl rand -hex 32)
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
cat >"$target" <<EOF cat >"$target" <<EOF
HTTP_PORT=0 HTTP_PORT=0
MAILPIT_PORT=0 MAILPIT_PORT=0
DOCKER_SOCKET_GID=$docker_socket_gid
TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_RETRY_ATTEMPTS=3
TRAEFIK_PROJECT_CONSTRAINT=generated-per-run TRAEFIK_PROJECT_CONSTRAINT=generated-per-run
@ -58,7 +60,11 @@ POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=$postgres_password POSTGRES_PASSWORD=$postgres_password
DATABASE_URL=ecto://postgres:$postgres_password@db/who_need_help_e2e DATABASE_URL=ecto://postgres:$postgres_password@db/who_need_help_e2e
POOL_SIZE=10 WEB_POOL_SIZE=4
WORKER_POOL_SIZE=2
MIGRATE_POOL_SIZE=2
WEB_REPLICAS=2
WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536 ERLANG_PORT_LIMIT=65536
SECRET_KEY_BASE=$secret_key_base SECRET_KEY_BASE=$secret_key_base
HANDOVER_SECRET=$handover_secret HANDOVER_SECRET=$handover_secret

View File

@ -301,6 +301,7 @@ backup_minio_root_user="wnh$(openssl rand -hex 12)"
backup_minio_root_password=$(openssl rand -hex 32) backup_minio_root_password=$(openssl rand -hex 32)
backup_restic_password=$(openssl rand -hex 32) backup_restic_password=$(openssl rand -hex 32)
database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load" database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load"
docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
temporary=$(mktemp "${ENV_FILE}.XXXXXX") temporary=$(mktemp "${ENV_FILE}.XXXXXX")
trap 'rm -f "$temporary"' EXIT HUP INT TERM trap 'rm -f "$temporary"' EXIT HUP INT TERM
@ -315,6 +316,7 @@ OBSERVABILITY_GRAFANA_ADMIN_PASSWORD_VALUE=$observability_grafana_admin_password
BACKUP_MINIO_ROOT_USER_VALUE=$backup_minio_root_user \ BACKUP_MINIO_ROOT_USER_VALUE=$backup_minio_root_user \
BACKUP_MINIO_ROOT_PASSWORD_VALUE=$backup_minio_root_password \ BACKUP_MINIO_ROOT_PASSWORD_VALUE=$backup_minio_root_password \
BACKUP_RESTIC_PASSWORD_VALUE=$backup_restic_password \ BACKUP_RESTIC_PASSWORD_VALUE=$backup_restic_password \
DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \
perl -0pe ' perl -0pe '
s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g; s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g;
s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g; s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g;
@ -327,6 +329,7 @@ BACKUP_RESTIC_PASSWORD_VALUE=$backup_restic_password \
s/GENERATE_BACKUP_MINIO_ROOT_USER/$ENV{BACKUP_MINIO_ROOT_USER_VALUE}/g; s/GENERATE_BACKUP_MINIO_ROOT_USER/$ENV{BACKUP_MINIO_ROOT_USER_VALUE}/g;
s/GENERATE_BACKUP_MINIO_ROOT_PASSWORD/$ENV{BACKUP_MINIO_ROOT_PASSWORD_VALUE}/g; s/GENERATE_BACKUP_MINIO_ROOT_PASSWORD/$ENV{BACKUP_MINIO_ROOT_PASSWORD_VALUE}/g;
s/GENERATE_BACKUP_RESTIC_PASSWORD/$ENV{BACKUP_RESTIC_PASSWORD_VALUE}/g; s/GENERATE_BACKUP_RESTIC_PASSWORD/$ENV{BACKUP_RESTIC_PASSWORD_VALUE}/g;
s/REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID/$ENV{DOCKER_SOCKET_GID_VALUE}/g;
' "$TEMPLATE" >"$temporary" ' "$TEMPLATE" >"$temporary"
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then

View File

@ -18,6 +18,9 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
WEB_POOL_SIZE=${WEB_POOL_SIZE:-${POOL_SIZE:-}}
WORKER_POOL_SIZE=${WORKER_POOL_SIZE:-${POOL_SIZE:-}}
duration_source=".env.load" duration_source=".env.load"
if [[ -n "$duration_override" ]]; then if [[ -n "$duration_override" ]]; then
LOAD_DURATION=$duration_override LOAD_DURATION=$duration_override
@ -28,7 +31,8 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
LOAD_HTTP_VUS LOAD_WS_VUS \ LOAD_HTTP_VUS LOAD_WS_VUS \
LOAD_DURATION LOAD_WS_HOLD_MS LOAD_WS_CONNECT_TIMEOUT_MS \ LOAD_DURATION LOAD_WS_HOLD_MS LOAD_WS_CONNECT_TIMEOUT_MS \
LOAD_HTTP_THINK_SECONDS LOAD_AUTH_VUS LOAD_AUTH_WS_TIMEOUT_MS \ LOAD_HTTP_THINK_SECONDS LOAD_AUTH_VUS LOAD_AUTH_WS_TIMEOUT_MS \
LOAD_AUTH_THINK_SECONDS LOAD_FIXTURE_PASSWORD POSTGRES_DB HTTP_PORT; do LOAD_AUTH_THINK_SECONDS LOAD_FIXTURE_PASSWORD POSTGRES_DB HTTP_PORT \
WEB_POOL_SIZE WORKER_POOL_SIZE; do
if [[ -z "${!name:-}" ]]; then if [[ -z "${!name:-}" ]]; then
echo "$name is missing from .env.load" >&2 echo "$name is missing from .env.load" >&2
exit 1 exit 1
@ -36,7 +40,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
done done
for name in LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS LOAD_HTTP_VUS LOAD_WS_VUS \ for name in LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS LOAD_HTTP_VUS LOAD_WS_VUS \
LOAD_AUTH_VUS; do LOAD_AUTH_VUS WEB_POOL_SIZE WORKER_POOL_SIZE; do
if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then
echo "$name must be a positive integer." >&2 echo "$name must be a positive integer." >&2
exit 1 exit 1
@ -430,17 +434,23 @@ SQL
summarize_database_connections() { summarize_database_connections() {
jq -s \ jq -s \
--argjson pool_size "$POOL_SIZE" \ --argjson web_pool_size "$WEB_POOL_SIZE" \
--argjson app_replicas "$((LOAD_WEB_REPLICAS + LOAD_WORKER_REPLICAS))" ' --argjson worker_pool_size "$WORKER_POOL_SIZE" \
--argjson web_replicas "$LOAD_WEB_REPLICAS" \
--argjson worker_replicas "$LOAD_WORKER_REPLICAS" '
. as $samples | . as $samples |
{ {
schema_version: 1, schema_version: 1,
measurement: "read-only pg_stat_activity samples during the k6 run", measurement: "read-only pg_stat_activity samples during the k6 run",
thresholds_applied: false, thresholds_applied: false,
sample_count: length, sample_count: length,
configured_app_replicas: $app_replicas, configured_web_replicas: $web_replicas,
configured_pool_size_per_replica: $pool_size, configured_worker_replicas: $worker_replicas,
configured_repo_pool_slots: ($app_replicas * $pool_size), configured_web_pool_size_per_replica: $web_pool_size,
configured_worker_pool_size_per_replica: $worker_pool_size,
configured_repo_pool_slots:
(($web_replicas * $web_pool_size) +
($worker_replicas * $worker_pool_size)),
max_connections: (map(.max_connections) | max), max_connections: (map(.max_connections) | max),
reserved_connections: (map(.reserved_connections) | max), reserved_connections: (map(.reserved_connections) | max),
superuser_reserved_connections: superuser_reserved_connections:

View File

@ -15,7 +15,7 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
for name in LOAD_PROJECT LOAD_WEB_REPLICAS POSTGRES_DB METRICS_TOKEN \ for name in LOAD_PROJECT LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS POSTGRES_DB METRICS_TOKEN \
OBSERVABILITY_PROMETHEUS_PORT OBSERVABILITY_ALERTMANAGER_PORT \ OBSERVABILITY_PROMETHEUS_PORT OBSERVABILITY_ALERTMANAGER_PORT \
OBSERVABILITY_GRAFANA_PORT OBSERVABILITY_SCRAPE_INTERVAL \ OBSERVABILITY_GRAFANA_PORT OBSERVABILITY_SCRAPE_INTERVAL \
OBSERVABILITY_EVALUATION_INTERVAL OBSERVABILITY_TIMEOUT_SECONDS \ OBSERVABILITY_EVALUATION_INTERVAL OBSERVABILITY_TIMEOUT_SECONDS \
@ -36,10 +36,12 @@ if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
exit 1 exit 1
fi fi
if [[ ! "$LOAD_WEB_REPLICAS" =~ ^[1-9][0-9]*$ ]]; then for name in LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS; do
echo "LOAD_WEB_REPLICAS must be a positive integer." >&2 if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then
echo "$name must be a positive integer." >&2
exit 1 exit 1
fi fi
done
for name in OBSERVABILITY_PROMETHEUS_PORT OBSERVABILITY_ALERTMANAGER_PORT \ for name in OBSERVABILITY_PROMETHEUS_PORT OBSERVABILITY_ALERTMANAGER_PORT \
OBSERVABILITY_GRAFANA_PORT; do OBSERVABILITY_GRAFANA_PORT; do
@ -216,23 +218,36 @@ fetch_receiver_events() {
} }
wait_for_targets_up() { wait_for_targets_up() {
local expected=$1 local expected_web=$1
local expected_worker=$2
local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS))
while ((SECONDS < deadline)); do while ((SECONDS < deadline)); do
if curl --fail --silent --show-error \ if curl --fail --silent --show-error \
"$prometheus_url/api/v1/targets?state=active" \ "$prometheus_url/api/v1/targets?state=active" \
>"$output_dir/targets-current.json" 2>/dev/null && >"$output_dir/targets-current.json" 2>/dev/null &&
jq -e --argjson expected "$expected" --arg run_id "$LABEL" ' jq -e \
--argjson expected_web "$expected_web" \
--argjson expected_worker "$expected_worker" \
--arg run_id "$LABEL" '
[ [
.data.activeTargets[] .data.activeTargets[]
| select( | select(
.labels.job == "who-need-help-web" and .labels.job == "who-need-help-web" and
.labels.run_id == $run_id .labels.run_id == $run_id
) )
] as $targets ] as $web_targets
| ($targets | length) == $expected | [
and all($targets[]; .health == "up") .data.activeTargets[]
| select(
.labels.job == "who-need-help-worker" and
.labels.run_id == $run_id
)
] as $worker_targets
| ($web_targets | length) == $expected_web
and ($worker_targets | length) == $expected_worker
and all($web_targets[]; .health == "up")
and all($worker_targets[]; .health == "up")
' "$output_dir/targets-current.json" >/dev/null; then ' "$output_dir/targets-current.json" >/dev/null; then
return 0 return 0
fi fi
@ -240,7 +255,7 @@ wait_for_targets_up() {
sleep 1 sleep 1
done done
echo "Timed out waiting for every direct web metrics target." >&2 echo "Timed out waiting for every direct web and worker metrics target." >&2
return 1 return 1
} }
@ -349,12 +364,18 @@ cleanup() {
trap cleanup EXIT HUP INT TERM trap cleanup EXIT HUP INT TERM
mapfile -t web_ids < <(service_ids web) mapfile -t web_ids < <(service_ids web)
mapfile -t worker_ids < <(service_ids worker)
if [[ "${#web_ids[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then if [[ "${#web_ids[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then
echo "Expected $LOAD_WEB_REPLICAS running load web replicas; observed ${#web_ids[@]}." >&2 echo "Expected $LOAD_WEB_REPLICAS running load web replicas; observed ${#web_ids[@]}." >&2
exit 1 exit 1
fi fi
if [[ "${#worker_ids[@]}" -ne "$LOAD_WORKER_REPLICAS" ]]; then
echo "Expected $LOAD_WORKER_REPLICAS running load worker replicas; observed ${#worker_ids[@]}." >&2
exit 1
fi
internal_network=$( internal_network=$(
docker network ls \ docker network ls \
--filter "label=com.docker.compose.project=$LOAD_PROJECT" \ --filter "label=com.docker.compose.project=$LOAD_PROJECT" \
@ -404,6 +425,43 @@ done
jq -s '.' "$target_lines" >"$prometheus_runtime/web-targets.json" jq -s '.' "$target_lines" >"$prometheus_runtime/web-targets.json"
unlink "$target_lines" unlink "$target_lines"
target_lines="$prometheus_runtime/worker-targets.jsonl"
: >"$target_lines"
for worker_id in "${worker_ids[@]}"; do
assert_scope "$worker_id" worker
wait_for_web "$worker_id"
worker_name=$(docker inspect --format '{{.Name}}' "$worker_id")
worker_name=${worker_name#/}
worker_ip=$(
docker inspect \
--format "{{with index .NetworkSettings.Networks \"$internal_network\"}}{{.IPAddress}}{{end}}" \
"$worker_id"
)
if [[ ! "$worker_ip" =~ ^[0-9]+([.][0-9]+){3}$ ]]; then
echo "No observed IPv4 address for $worker_name on $internal_network." >&2
exit 1
fi
jq -cn \
--arg target "$worker_ip:4000" \
--arg instance "$worker_name" \
--arg compose_project "$LOAD_PROJECT" \
--arg run_id "$LABEL" \
'{
targets: [$target],
labels: {
instance: $instance,
compose_project: $compose_project,
run_id: $run_id
}
}' >>"$target_lines"
done
jq -s '.' "$target_lines" >"$prometheus_runtime/worker-targets.json"
unlink "$target_lines"
sed \ sed \
-e "s/__SCRAPE_INTERVAL__/$OBSERVABILITY_SCRAPE_INTERVAL/g" \ -e "s/__SCRAPE_INTERVAL__/$OBSERVABILITY_SCRAPE_INTERVAL/g" \
-e "s/__EVALUATION_INTERVAL__/$OBSERVABILITY_EVALUATION_INTERVAL/g" \ -e "s/__EVALUATION_INTERVAL__/$OBSERVABILITY_EVALUATION_INTERVAL/g" \
@ -413,11 +471,14 @@ printf '%s' "$METRICS_TOKEN" >"$prometheus_runtime/metrics-token"
printf '%s' "$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" \ printf '%s' "$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" \
>"$grafana_runtime/admin-password" >"$grafana_runtime/admin-password"
cp "$prometheus_runtime/web-targets.json" "$output_dir/generated-targets.json" cp "$prometheus_runtime/web-targets.json" "$output_dir/generated-targets.json"
cp "$prometheus_runtime/worker-targets.json" "$output_dir/generated-worker-targets.json"
chmod 600 "$prometheus_runtime/prometheus.yml" \ chmod 600 "$prometheus_runtime/prometheus.yml" \
"$prometheus_runtime/web-targets.json" \ "$prometheus_runtime/web-targets.json" \
"$prometheus_runtime/worker-targets.json" \
"$prometheus_runtime/metrics-token" \ "$prometheus_runtime/metrics-token" \
"$grafana_runtime/admin-password" \ "$grafana_runtime/admin-password" \
"$output_dir/generated-targets.json" "$output_dir/generated-targets.json" \
"$output_dir/generated-worker-targets.json"
set_runtime_owner "$prometheus_runtime" "65534:65534" set_runtime_owner "$prometheus_runtime" "65534:65534"
set_runtime_owner "$grafana_runtime" "472:0" set_runtime_owner "$grafana_runtime" "472:0"
@ -482,33 +543,42 @@ curl --fail --silent --show-error \
if ! jq -e ' if ! jq -e '
.dashboard.uid == "wnh-overview" and .dashboard.uid == "wnh-overview" and
.meta.provisioned == true and .meta.provisioned == true and
(.dashboard.panels | length) == 4 (.dashboard.panels | length) == 10
' "$output_dir/grafana-dashboard.json" >/dev/null; then ' "$output_dir/grafana-dashboard.json" >/dev/null; then
echo "The provisioned Grafana dashboard did not match the tracked dashboard." >&2 echo "The provisioned Grafana dashboard did not match the tracked dashboard." >&2
exit 1 exit 1
fi fi
wait_for_targets_up "$LOAD_WEB_REPLICAS" wait_for_targets_up "$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS"
mv "$output_dir/targets-current.json" "$output_dir/targets-before-drill.json" mv "$output_dir/targets-current.json" "$output_dir/targets-before-drill.json"
jq -n \ jq -n \
--slurpfile expected "$output_dir/generated-targets.json" \ --slurpfile expected_web "$output_dir/generated-targets.json" \
--slurpfile expected_worker "$output_dir/generated-worker-targets.json" \
--slurpfile observed "$output_dir/targets-before-drill.json" ' --slurpfile observed "$output_dir/targets-before-drill.json" '
{ {
expected_instances: ($expected[0] | map(.labels.instance) | sort), expected_web_instances: ($expected_web[0] | map(.labels.instance) | sort),
observed_instances: ( observed_web_instances: (
$observed[0].data.activeTargets $observed[0].data.activeTargets
| map(select(.labels.job == "who-need-help-web") | .labels.instance) | map(select(.labels.job == "who-need-help-web") | .labels.instance)
| sort | sort
),
expected_worker_instances: ($expected_worker[0] | map(.labels.instance) | sort),
observed_worker_instances: (
$observed[0].data.activeTargets
| map(select(.labels.job == "who-need-help-worker") | .labels.instance)
| sort
) )
} }
| . + { | . + {
exact_instance_match: (.expected_instances == .observed_instances) exact_instance_match:
(.expected_web_instances == .observed_web_instances and
.expected_worker_instances == .observed_worker_instances)
} }
' >"$output_dir/target-summary.json" ' >"$output_dir/target-summary.json"
if ! jq -e '.exact_instance_match' "$output_dir/target-summary.json" >/dev/null; then if ! jq -e '.exact_instance_match' "$output_dir/target-summary.json" >/dev/null; then
echo "Prometheus did not preserve every direct web instance target." >&2 echo "Prometheus did not preserve every direct web and worker instance target." >&2
exit 1 exit 1
fi fi
@ -524,7 +594,7 @@ wait_for_webhook_status \
docker start "$drill_web_id" >"$output_dir/started-web.txt" docker start "$drill_web_id" >"$output_dir/started-web.txt"
wait_for_web "$drill_web_id" wait_for_web "$drill_web_id"
wait_for_targets_up "$LOAD_WEB_REPLICAS" wait_for_targets_up "$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS"
mv "$output_dir/targets-current.json" "$output_dir/targets-after-recovery.json" mv "$output_dir/targets-current.json" "$output_dir/targets-after-recovery.json"
wait_for_alert_clear "$drill_instance" wait_for_alert_clear "$drill_instance"
wait_for_webhook_status \ wait_for_webhook_status \
@ -550,9 +620,11 @@ jq -n \
--arg alertmanager_url "$alertmanager_url" \ --arg alertmanager_url "$alertmanager_url" \
--arg grafana_url "$grafana_url" \ --arg grafana_url "$grafana_url" \
--argjson web_replicas "$LOAD_WEB_REPLICAS" \ --argjson web_replicas "$LOAD_WEB_REPLICAS" \
--argjson worker_replicas "$LOAD_WORKER_REPLICAS" \
'{ '{
run_id: $run_id, run_id: $run_id,
web_replicas: $web_replicas, web_replicas: $web_replicas,
worker_replicas: $worker_replicas,
direct_targets_up_before_and_after: true, direct_targets_up_before_and_after: true,
induced_instance: $drill_instance, induced_instance: $drill_instance,
firing_webhook_observed: true, firing_webhook_observed: true,

View File

@ -22,6 +22,9 @@ backup_image="who-need-help:backup-audit-$run_id"
minio_image="who-need-help:minio-audit-$run_id" minio_image="who-need-help:minio-audit-$run_id"
mc_image="who-need-help:mc-audit-$run_id" mc_image="who-need-help:mc-audit-$run_id"
boundary_mock_image="who-need-help:boundary-mock-audit-$run_id" boundary_mock_image="who-need-help:boundary-mock-audit-$run_id"
socket_proxy_image="who-need-help:socket-proxy-audit-$run_id"
postgis_image="who-need-help:postgis-audit-$run_id"
socket_proxy_container="wnh-socket-proxy-audit-$run_id"
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX") scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
scan_list="${scan_dir}.files" scan_list="${scan_dir}.files"
scan_tar="${scan_dir}.tar" scan_tar="${scan_dir}.tar"
@ -30,14 +33,17 @@ umask 077
QUALITY_POSTGRES_USER="wnh_quality_$(openssl rand -hex 6)" QUALITY_POSTGRES_USER="wnh_quality_$(openssl rand -hex 6)"
QUALITY_POSTGRES_PASSWORD=$(openssl rand -base64 48 | tr -d '\n') QUALITY_POSTGRES_PASSWORD=$(openssl rand -base64 48 | tr -d '\n')
export QUALITY_POSTGRES_USER QUALITY_POSTGRES_PASSWORD export QUALITY_POSTGRES_USER QUALITY_POSTGRES_PASSWORD
QUALITY_POSTGIS_IMAGE=$postgis_image
export QUALITY_POSTGIS_IMAGE
compose="docker compose -p $project -f $ROOT/compose.quality.yaml" compose="docker compose -p $project -f $ROOT/compose.quality.yaml"
cleanup() { cleanup() {
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true $compose down --volumes --remove-orphans >/dev/null 2>&1 || true
docker rm --force "$socket_proxy_container" >/dev/null 2>&1 || true
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \ docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
"$backup_image" "$minio_image" "$mc_image" \ "$backup_image" "$minio_image" "$mc_image" \
"$boundary_mock_image" \ "$boundary_mock_image" "$socket_proxy_image" "$postgis_image" \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
rm -rf "$scan_dir" "$scan_list" "$scan_tar" rm -rf "$scan_dir" "$scan_list" "$scan_tar"
} }
@ -56,6 +62,7 @@ docker run --rm \
echo "Checking Dockerfiles with Hadolint 2.14.0" echo "Checking Dockerfiles with Hadolint 2.14.0"
for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \ for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \
Dockerfile.postgis Dockerfile.socket-proxy \
android/Dockerfile e2e/Dockerfile ops/external-boundaries/Dockerfile; do android/Dockerfile e2e/Dockerfile ops/external-boundaries/Dockerfile; do
docker run --rm --interactive "$HADOLINT_IMAGE" \ docker run --rm --interactive "$HADOLINT_IMAGE" \
hadolint --failure-threshold warning - <"$dockerfile" hadolint --failure-threshold warning - <"$dockerfile"
@ -71,9 +78,42 @@ echo "Rendering every Docker Compose profile"
docker compose --env-file .env.example -f compose.yaml config --quiet docker compose --env-file .env.example -f compose.yaml config --quiet
docker compose --env-file .env.example -f compose.yaml config --format json | docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status ' jq --exit-status '
[.services.migrate, .services.web, .services.worker] . as $root
| all(.environment.ERL_ZFLAGS == "+Q 65536") | [$root.services.migrate, $root.services.web, $root.services.worker]
| all(
.environment.ERL_ZFLAGS == "+Q 65536" and
.read_only == true and
.cap_drop == ["ALL"] and
.security_opt == ["no-new-privileges:true"] and
(.tmpfs | index("/tmp") != null)
)
and $root.services.web.environment.POOL_SIZE == "4"
and $root.services.worker.environment.POOL_SIZE == "2"
and $root.services.migrate.environment.POOL_SIZE == "2"
and $root.services.web.deploy.replicas == 2
and $root.services.worker.deploy.replicas == 2
and ($root.services.proxy.networks | keys | sort) == ["docker-api", "edge", "ingress"]
and ($root.services.web.networks | keys | sort) == ["egress", "ingress", "internal"]
and ($root.services.worker.networks | keys | sort) == ["egress", "internal"]
and ($root.services.db.networks | keys) == ["internal"]
and $root.networks.ingress.internal == true
and $root.networks.internal.internal == true
and ($root.networks.egress.internal // false) == false
and $root.services.db.security_opt == ["no-new-privileges:true"]
and $root.services.mailpit.ports[0].host_ip == "127.0.0.1"
' >/dev/null ' >/dev/null
WEB_REPLICAS=1 WORKER_REPLICAS=1 \
docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status '
.services.web.deploy.replicas == 1 and
.services.worker.deploy.replicas == 1
' >/dev/null
CPU_REPLAY_WEB_CPUS=1 \
CPU_REPLAY_WORKER_CPUS=1 \
CPU_REPLAY_WEB_SCHEDULERS=1 \
CPU_REPLAY_WORKER_SCHEDULERS=1 \
docker compose --env-file .env.example \
-f compose.yaml -f compose.cpu-replay.yaml config --quiet
PORTABILITY_IMAGE=who-need-help:portability-render \ PORTABILITY_IMAGE=who-need-help:portability-render \
docker compose --env-file .env.example \ docker compose --env-file .env.example \
-f compose.yaml -f compose.portability.yaml config --quiet -f compose.yaml -f compose.portability.yaml config --quiet
@ -129,6 +169,8 @@ printf '%s' 'isolated-quality-metrics-token' \
>"$scan_dir/observability-runtime/prometheus/metrics-token" >"$scan_dir/observability-runtime/prometheus/metrics-token"
printf '%s\n' '[]' \ printf '%s\n' '[]' \
>"$scan_dir/observability-runtime/prometheus/web-targets.json" >"$scan_dir/observability-runtime/prometheus/web-targets.json"
printf '%s\n' '[]' \
>"$scan_dir/observability-runtime/prometheus/worker-targets.json"
docker run --rm \ docker run --rm \
--user 0:0 \ --user 0:0 \
--volume "$scan_dir/observability-runtime/prometheus:/runtime:ro" \ --volume "$scan_dir/observability-runtime/prometheus:/runtime:ro" \
@ -149,7 +191,7 @@ docker run --rm \
"$PYTHON_IMAGE" python -c \ "$PYTHON_IMAGE" python -c \
'import py_compile; py_compile.compile("/src/mock_server.py", cfile="/tmp/mock_server.pyc", doraise=True)' 'import py_compile; py_compile.compile("/src/mock_server.py", cfile="/tmp/mock_server.pyc", doraise=True)'
jq --exit-status \ jq --exit-status \
'type == "object" and .uid == "wnh-overview" and (.panels | length) == 4' \ 'type == "object" and .uid == "wnh-overview" and (.panels | length) == 10' \
ops/observability/grafana/dashboards/who-need-help-overview.json \ ops/observability/grafana/dashboards/who-need-help-overview.json \
>/dev/null >/dev/null
@ -183,6 +225,77 @@ docker run --rm \
--exit-code 1 \ --exit-code 1 \
/scan /scan
echo "Building, smoke-testing, and scanning pinned runtime infrastructure images"
docker build --tag "$socket_proxy_image" --file Dockerfile.socket-proxy .
docker build --tag "$postgis_image" --file Dockerfile.postgis .
test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy"
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
docker run --rm --entrypoint sh "$postgis_image" -euc '
test ! -e /usr/local/bin/gosu
test "$(id -u)" = 70
'
docker run --detach \
--name "$socket_proxy_container" \
--read-only \
--tmpfs /run:uid=99,gid=99,mode=0755 \
--tmpfs /tmp \
--cap-drop ALL \
--group-add "$(stat -c '%g' /var/run/docker.sock)" \
--security-opt no-new-privileges \
--env CONTAINERS=1 \
--env EVENTS=1 \
--env INFO=1 \
--env NETWORKS=1 \
--env PING=1 \
--env POST=0 \
--env VERSION=1 \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--publish 127.0.0.1::2375 \
"$socket_proxy_image" >/dev/null
socket_proxy_port=$(
docker port "$socket_proxy_container" 2375/tcp |
sed -n 's/.*://p' |
head -n 1
)
test -n "$socket_proxy_port"
socket_proxy_ready=false
for _attempt in $(seq 1 30); do
if curl --fail --silent --show-error \
"http://127.0.0.1:$socket_proxy_port/_ping" >/dev/null; then
socket_proxy_ready=true
break
fi
sleep 1
done
test "$socket_proxy_ready" = true
test "$(
curl --silent --output /dev/null --write-out '%{http_code}' \
"http://127.0.0.1:$socket_proxy_port/containers/json"
)" = "200"
test "$(
curl --silent --output /dev/null --write-out '%{http_code}' \
--request POST \
"http://127.0.0.1:$socket_proxy_port/containers/create"
)" = "403"
docker rm --force "$socket_proxy_container" >/dev/null
for image in \
"$socket_proxy_image" \
"$postgis_image" \
"traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5" \
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$image"
done
echo "Building the pinned quality image and cached Dialyzer PLTs" echo "Building the pinned quality image and cached Dialyzer PLTs"
docker build --target quality --tag "$quality_image" . docker build --target quality --tag "$quality_image" .

View File

@ -106,6 +106,8 @@ fi
export DATABASE_URL="$database_prefix/$rehearsal_db$database_query" export DATABASE_URL="$database_prefix/$rehearsal_db$database_query"
export HTTP_PORT=0 export HTTP_PORT=0
export MAILPIT_PORT=0 export MAILPIT_PORT=0
export DOCKER_SOCKET_GID
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
export PHX_HOST="$rehearsal_phx_host" export PHX_HOST="$rehearsal_phx_host"
export PHX_SCHEME="$rehearsal_phx_scheme" export PHX_SCHEME="$rehearsal_phx_scheme"
export PHX_URL_PORT="$rehearsal_phx_url_port" export PHX_URL_PORT="$rehearsal_phx_url_port"
@ -113,7 +115,7 @@ export MAP_TILE_URL="$rehearsal_map_tile_url"
export TRAEFIK_TRUSTED_IPS=127.0.0.1/32 export TRAEFIK_TRUSTED_IPS=127.0.0.1/32
export TRAEFIK_PROJECT_CONSTRAINT="$project" export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="wnh-upgrade-rehearsal-$compact_id" export TRAEFIK_APP_NAME="wnh-upgrade-rehearsal-$compact_id"
export TRAEFIK_DOCKER_NETWORK="${project}_internal" export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
export REHEARSAL_IMAGE="who-need-help:upgrade-rehearsal-$run_id" export REHEARSAL_IMAGE="who-need-help:upgrade-rehearsal-$run_id"
compose() { compose() {

View File

@ -48,6 +48,29 @@ defmodule WhoNeedHelp.AccountsTest do
end end
end end
describe "public_user_query/1" do
test "does not load authentication or moderation fields" do
user =
user_fixture()
|> set_password()
|> Ecto.Changeset.change(moderation_note: "private moderator note")
|> Repo.update!()
public_user =
Accounts.public_user_query()
|> where([candidate], candidate.id == ^user.id)
|> Repo.one!()
assert public_user.id == user.id
assert public_user.display_name == user.display_name
assert is_nil(public_user.email)
assert is_nil(public_user.hashed_password)
assert is_nil(public_user.confirmed_at)
assert is_nil(public_user.accepted_terms_at)
assert is_nil(public_user.moderation_note)
end
end
describe "register_user/1" do describe "register_user/1" do
test "requires email to be set" do test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{}) {:error, changeset} = Accounts.register_user(%{})

View File

@ -196,6 +196,11 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert length(Enum.uniq(actual_ids)) == 4 assert length(Enum.uniq(actual_ids)) == 4
assert is_binary(first.next_cursor) assert is_binary(first.next_cursor)
assert second.next_cursor == nil assert second.next_cursor == nil
assert Enum.all?(first.entries ++ second.entries, &(&1.approved_participant_count == 1))
assert Enum.all?(first.entries ++ second.entries, fn activity ->
match?(%Ecto.Association.NotLoaded{}, activity.participants)
end)
end end
test "my activities are derived from the organizer and participant membership rows", context do test "my activities are derived from the organizer and participant membership rows", context do

View File

@ -6,6 +6,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust} alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position alias WhoNeedHelp.Tracking.Position
alias WhoNeedHelpWeb.Presence
setup do setup do
category = Catalog.seed_defaults() category = Catalog.seed_defaults()
@ -56,6 +57,20 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert reputation.completed == 1 assert reputation.completed == 1
assert reputation.unique_people == 1 assert reputation.unique_people == 1
assert reputation.verified_handovers == 1 assert reputation.verified_handovers == 1
reputations = Trust.reputations([context.helper.id, context.requester.id])
assert reputations[context.helper.id] == reputation
assert reputations[context.requester.id].completed == 1
unused_user = user_fixture(display_name: "No completed help")
assert Trust.reputations([unused_user.id])[unused_user.id] == %{
completed: 0,
unique_people: 0,
verified_handovers: 0,
location_supported: 0,
rating: nil
}
end end
test "chat is durable and only visible to match participants", context do test "chat is durable and only visible to match participants", context do
@ -199,6 +214,47 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
refute Repo.get(Position, position.id) refute Repo.get(Position, position.id)
end end
test "concurrent tracking stops serialize and broadcast once", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
{:ok, _session} = Tracking.start_session(context.helper_scope, assignment)
:ok = Tracking.subscribe(assignment.id)
results =
1..2
|> Task.async_stream(
fn _ -> Tracking.stop_session(context.helper_scope, assignment) end,
max_concurrency: 2,
ordered: false
)
|> Enum.map(fn {:ok, result} -> result end)
|> Enum.sort()
assert results == [{:ok, :already_stopped}, {:ok, :stopped}]
assert_receive {:tracking_stopped, helper_id}, 1_000
assert helper_id == context.helper.id
refute_receive {:tracking_stopped, ^helper_id}, 100
end
test "stale browser cleanup cannot stop a replacement tracking session", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
{:ok, old_session} = Tracking.start_session(context.helper_scope, assignment)
assert {:ok, :stopped} = Tracking.stop_session(context.helper_scope, assignment)
{:ok, current_session} = Tracking.start_session(context.helper_scope, assignment)
refute current_session.id == old_session.id
assert {:ok, :already_stopped} =
Tracking.stop_browser_session(
assignment.id,
context.helper.id,
old_session.id
)
assert Tracking.active_session?(context.helper_scope, assignment)
end
test "reviews remain hidden until both participants submit", context do test "reviews remain hidden until both participants submit", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs) {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
@ -239,6 +295,15 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert proposal_export["mode"] == "help" assert proposal_export["mode"] == "help"
assert proposal_export["community_votes"] == 0 assert proposal_export["community_votes"] == 0
[listed] = Catalog.list_proposals()
assert listed.id == proposal.id
assert listed.vote_count == 0
assert %Ecto.Association.NotLoaded{} = listed.votes
assert {:ok, _vote} = Catalog.vote(context.helper_scope, proposal.id)
assert [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals()
assert listed_id == proposal.id
refute export =~ context.requester.email refute export =~ context.requester.email
refute export =~ context.requester.display_name refute export =~ context.requester.display_name
end end
@ -331,4 +396,131 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert {:error, :not_found} = Accounts.delete_social_identity(context.helper, identity.id) assert {:error, :not_found} = Accounts.delete_social_identity(context.helper, identity.id)
assert {:ok, _identity} = Accounts.delete_social_identity(context.requester, identity.id) assert {:ok, _identity} = Accounts.delete_social_identity(context.requester, identity.id)
end end
test "request details batch public users and public social identities", context do
{:ok, requester_identity} =
Accounts.upsert_verified_social_identity(context.requester, %{
provider: :github,
provider_uid: "requester-private-provider-id",
profile_url: "https://github.com/requester",
handle: "requester",
verified_at: DateTime.utc_now(:second)
})
{:ok, helper_identity} =
Accounts.upsert_verified_social_identity(context.helper, %{
provider: :github,
provider_uid: "helper-private-provider-id",
profile_url: "https://github.com/helper",
handle: "helper",
verified_at: DateTime.utc_now(:second)
})
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
handler_id = "request-detail-queries-#{System.unique_integer([:positive])}"
:ok =
:telemetry.attach(
handler_id,
[:who_need_help, :repo, :query],
fn _event, _measurements, metadata, receiver ->
send(receiver, {:request_detail_query, metadata.query})
end,
self()
)
loaded =
try do
{:ok, loaded} = Help.get_request(context.requester_scope, request.id)
loaded
after
:telemetry.detach(handler_id)
end
queries = collect_request_detail_queries()
assert Enum.count(queries, &String.contains?(&1, ~s(FROM "users"))) == 1
assert Enum.count(queries, &String.contains?(&1, ~s(FROM "social_identities"))) == 1
assert [loaded_requester_identity] = loaded.requester.social_identities
assert loaded_requester_identity.id == requester_identity.id
assert is_nil(loaded_requester_identity.provider_uid)
assert [loaded_helper_identity] = loaded.assignment.helper.social_identities
assert loaded_helper_identity.id == helper_identity.id
assert is_nil(loaded_helper_identity.provider_uid)
end
test "browser tracking stops only after the last tab leaves", context do
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
{:ok, session} = Tracking.start_session(context.helper_scope, assignment)
:ok = Tracking.subscribe(assignment.id)
parent = self()
start_browser = fn ->
spawn(fn ->
{:ok, key} =
Presence.track_browser(self(), assignment.id, context.helper.id, session.id)
send(parent, {:browser_ready, self(), key})
receive do
:close -> :ok
end
end)
end
first = start_browser.()
second = start_browser.()
key = Presence.tracking_key(assignment.id, context.helper.id)
assert_receive {:browser_ready, ^first, ^key}
assert_receive {:browser_ready, ^second, ^key}
assert wait_for_presence_count(key, 2)
first_ref = Process.monitor(first)
send(first, :close)
assert_receive {:DOWN, ^first_ref, :process, ^first, :normal}
assert wait_for_presence_count(key, 1)
assert Tracking.active_session?(context.helper_scope, assignment)
second_ref = Process.monitor(second)
send(second, :close)
assert_receive {:DOWN, ^second_ref, :process, ^second, :normal}
assert_receive {:tracking_stopped, helper_id}, 1_000
assert helper_id == context.helper.id
refute Tracking.active_session?(context.helper_scope, assignment)
end
defp wait_for_presence_count(key, expected, attempts \\ 100)
defp wait_for_presence_count(_key, _expected, 0), do: false
defp wait_for_presence_count(key, expected, attempts) do
count =
case Presence.get_by_key(Presence.tracking_topic(), key) do
[] -> 0
%{metas: metas} -> length(metas)
end
if count == expected do
true
else
Process.sleep(10)
wait_for_presence_count(key, expected, attempts - 1)
end
end
defp collect_request_detail_queries(queries \\ []) do
receive do
{:request_detail_query, query} ->
collect_request_detail_queries([query | queries])
after
0 -> Enum.reverse(queries)
end
end
end end

View File

@ -0,0 +1,65 @@
defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
use WhoNeedHelp.DataCase, async: false
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Catalog, Help, Repo}
alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Trust.AuditEvent
alias WhoNeedHelp.Workers.ExpireRequests
test "expires an arbitrary backlog without loading it as one result set" do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Expiration requester")
scope = user_scope_fixture(requester)
request_ids =
for number <- 1..12 do
{:ok, request} =
Help.create_request(scope, %{
"title" => "Expiring medicine request #{number}",
"description" => "The reserved legal medicine was not collected in time.",
"pickup_instructions" => "No payment or prescription handling is required.",
"location_label" => "Central district",
"latitude" => "50.4501",
"longitude" => "30.5234",
"urgency" => "today",
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id
})
request.id
end
past = DateTime.utc_now(:second) |> DateTime.add(-1, :minute)
Repo.update_all(
from(request in HelpRequest, where: request.id in ^request_ids),
set: [expires_at: past]
)
assert {:ok, %{expired: 12}} = ExpireRequests.perform(%Oban.Job{})
assert Repo.aggregate(
from(
request in HelpRequest,
where: request.id in ^request_ids and request.status == :expired
),
:count
) == 12
assert Repo.aggregate(
from(
event in AuditEvent,
where:
event.action == "request.expired" and
event.target_id in ^request_ids
),
:count
) == 12
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
end
end

View File

@ -0,0 +1,37 @@
defmodule WhoNeedHelpWeb.WorkerMetricsPlugTest do
use WhoNeedHelp.DataCase, async: false
import Plug.Conn
import Plug.Test
alias WhoNeedHelpWeb.WorkerMetricsPlug
test "liveness and readiness are available without exposing metrics" do
live = WorkerMetricsPlug.call(conn(:get, "/healthz/live"), [])
ready = WorkerMetricsPlug.call(conn(:get, "/healthz/ready"), [])
assert live.status == 200
assert ready.status == 200
assert Jason.decode!(live.resp_body)["role"] == "worker"
assert Jason.decode!(ready.resp_body)["status"] == "ready"
end
test "metrics require the independent bearer token" do
unauthorized = WorkerMetricsPlug.call(conn(:get, "/metrics"), [])
authorized =
conn(:get, "/metrics")
|> put_req_header("authorization", "Bearer test-metrics-token")
|> WorkerMetricsPlug.call([])
assert unauthorized.status == 401
assert get_resp_header(unauthorized, "www-authenticate") == ["Bearer"]
assert authorized.status == 200
assert authorized.resp_body =~ "who_need_help_database_query_duration_seconds"
end
test "all other methods and paths are unavailable" do
assert WorkerMetricsPlug.call(conn(:post, "/metrics"), []).status == 404
assert WorkerMetricsPlug.call(conn(:get, "/"), []).status == 404
end
end