feat: add isolated scaling measurements
This commit is contained in:
parent
3a35af6782
commit
24e5e9ce82
|
|
@ -5,6 +5,12 @@ MAILPIT_PORT=8027
|
||||||
# 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
|
||||||
|
# Docker-provider isolation and names. A second Compose project must use its
|
||||||
|
# own project constraint, router/service name, Docker network, and Host rule.
|
||||||
|
TRAEFIK_PROJECT_CONSTRAINT=who_need_help
|
||||||
|
TRAEFIK_APP_NAME=who-need-help
|
||||||
|
TRAEFIK_DOCKER_NETWORK=who_need_help_internal
|
||||||
|
TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
|
||||||
PHX_HOST=localhost
|
PHX_HOST=localhost
|
||||||
PHX_SCHEME=http
|
PHX_SCHEME=http
|
||||||
PHX_URL_PORT=4010
|
PHX_URL_PORT=4010
|
||||||
|
|
|
||||||
53
.env.load.example
Normal file
53
.env.load.example
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Copying is handled by scripts/ensure-local-load-env.sh, which replaces every
|
||||||
|
# GENERATE_* marker with an independent local secret and restricts the result
|
||||||
|
# to mode 600. This profile owns a separate Compose project and PostgreSQL
|
||||||
|
# volume; it must never use the staging project name.
|
||||||
|
LOAD_PROJECT=who_need_help_load
|
||||||
|
LOAD_HOST=load.local
|
||||||
|
HTTP_PORT=4110
|
||||||
|
MAILPIT_PORT=8127
|
||||||
|
PHX_HOST=load.local
|
||||||
|
PHX_SCHEME=https
|
||||||
|
PHX_URL_PORT=443
|
||||||
|
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
|
||||||
|
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
|
||||||
|
TRAEFIK_APP_NAME=who-need-help-load
|
||||||
|
TRAEFIK_DOCKER_NETWORK=who_need_help_load_internal
|
||||||
|
TRAEFIK_ROUTER_RULE='Host(`load.local`)'
|
||||||
|
|
||||||
|
POSTGRES_DB=who_need_help_load
|
||||||
|
POSTGRES_USER=wnh_load
|
||||||
|
POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD
|
||||||
|
DATABASE_URL=GENERATE_DATABASE_URL
|
||||||
|
POOL_SIZE=10
|
||||||
|
SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE
|
||||||
|
HANDOVER_SECRET=GENERATE_HANDOVER_SECRET
|
||||||
|
RELEASE_COOKIE=GENERATE_RELEASE_COOKIE
|
||||||
|
METRICS_TOKEN=GENERATE_METRICS_TOKEN
|
||||||
|
|
||||||
|
SMTP_RELAY=mailpit
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_AUTH=never
|
||||||
|
SMTP_TLS=never
|
||||||
|
SMTP_SSL=false
|
||||||
|
EMAIL_FROM_NAME="Who Need Help load profile"
|
||||||
|
EMAIL_FROM_ADDRESS=load-profile@example.invalid
|
||||||
|
|
||||||
|
CODEX_SESSION_ID=load-profile
|
||||||
|
RATE_LIMIT_POLICIES_JSON={}
|
||||||
|
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
|
GITHUB_OAUTH_CLIENT_ID=
|
||||||
|
GITHUB_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# These are reproducible experiment inputs, not capacity requirements,
|
||||||
|
# production traffic forecasts, alert thresholds, or recommended limits.
|
||||||
|
LOAD_WEB_REPLICAS=2
|
||||||
|
LOAD_WORKER_REPLICAS=2
|
||||||
|
LOAD_HTTP_VUS=40
|
||||||
|
LOAD_WS_VUS=40
|
||||||
|
LOAD_DURATION=30s
|
||||||
|
LOAD_WS_HOLD_MS=5000
|
||||||
|
LOAD_WS_CONNECT_TIMEOUT_MS=3000
|
||||||
|
LOAD_HTTP_THINK_SECONDS=0.1
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,6 +43,7 @@ npm-debug.log
|
||||||
/.env
|
/.env
|
||||||
/.env.*
|
/.env.*
|
||||||
!/.env.example
|
!/.env.example
|
||||||
|
!/.env.load.example
|
||||||
|
|
||||||
# Local browser automation state and generated verification artifacts.
|
# Local browser automation state and generated verification artifacts.
|
||||||
/.playwright-cli/
|
/.playwright-cli/
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ and no fallback provider. Recommendations require a human moderator action.
|
||||||
- [Architecture](docs/architecture.md)
|
- [Architecture](docs/architecture.md)
|
||||||
- [Trust and safety](docs/trust-safety.md)
|
- [Trust and safety](docs/trust-safety.md)
|
||||||
- [Operations runbook](docs/operations.md)
|
- [Operations runbook](docs/operations.md)
|
||||||
|
- [Performance measurement](docs/performance.md)
|
||||||
- [Implementation verification and known limits](docs/verification.md)
|
- [Implementation verification and known limits](docs/verification.md)
|
||||||
- [Verified dependency baseline](docs/dependency-baseline.md)
|
- [Verified dependency baseline](docs/dependency-baseline.md)
|
||||||
- [PostgreSQL/PostGIS ADR](docs/decisions/0001-postgresql-postgis-over-spacetimedb.md)
|
- [PostgreSQL/PostGIS ADR](docs/decisions/0001-postgresql-postgis-over-spacetimedb.md)
|
||||||
|
|
|
||||||
19
compose.load.yaml
Normal file
19
compose.load.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
aliases:
|
||||||
|
- ${LOAD_HOST}
|
||||||
|
internal: {}
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
image: who-need-help:load
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: who-need-help:load
|
||||||
|
labels:
|
||||||
|
- traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded
|
||||||
|
- traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: who-need-help:load
|
||||||
10
compose.yaml
10
compose.yaml
|
|
@ -35,6 +35,7 @@ services:
|
||||||
- --api.dashboard=false
|
- --api.dashboard=false
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`)
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
- --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:
|
||||||
|
|
@ -92,10 +93,11 @@ services:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=who_need_help_internal
|
- traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_internal}
|
||||||
- traefik.http.routers.who-need-help.rule=PathPrefix(`/`)
|
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)}
|
||||||
- traefik.http.routers.who-need-help.entrypoints=web
|
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web
|
||||||
- traefik.http.services.who-need-help.loadbalancer.server.port=4000
|
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help}
|
||||||
|
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
|
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
|
||||||
| PostGIS | 3.6.4 |
|
| PostGIS | 3.6.4 |
|
||||||
| Traefik | 3.7.8 |
|
| Traefik | 3.7.8 |
|
||||||
| Mailpit | 1.30.4 |
|
| Mailpit | 1.30.4 |
|
||||||
|
| k6 load generator | 2.1.0 |
|
||||||
| Debian builder/runner snapshot | trixie-20260713-slim |
|
| Debian builder/runner snapshot | trixie-20260713-slim |
|
||||||
|
|
||||||
Every external Compose/kind service image and every Dockerfile base image is
|
Every external Compose/kind service image and every Dockerfile base image is
|
||||||
|
|
@ -74,6 +75,7 @@ because the official SDK channel identifies it as a QPR beta.
|
||||||
- [PostGIS release notes](https://postgis.net/docs/en/release_notes.html)
|
- [PostGIS release notes](https://postgis.net/docs/en/release_notes.html)
|
||||||
- [Traefik releases](https://github.com/traefik/traefik/releases)
|
- [Traefik releases](https://github.com/traefik/traefik/releases)
|
||||||
- [Mailpit releases](https://github.com/axllent/mailpit/releases)
|
- [Mailpit releases](https://github.com/axllent/mailpit/releases)
|
||||||
|
- [k6 releases](https://github.com/grafana/k6/releases)
|
||||||
- [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes)
|
- [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes)
|
||||||
- [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk)
|
- [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk)
|
||||||
- [Gradle release notes](https://docs.gradle.org/current/release-notes.html)
|
- [Gradle release notes](https://docs.gradle.org/current/release-notes.html)
|
||||||
|
|
|
||||||
121
docs/performance.md
Normal file
121
docs/performance.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Performance measurement
|
||||||
|
|
||||||
|
No production capacity, minimum resource requirement, SLO, alert threshold,
|
||||||
|
pool size, or autoscaling threshold is known yet. The repository therefore
|
||||||
|
contains a reproducible measurement profile, not a capacity claim or blocking
|
||||||
|
resource preflight.
|
||||||
|
|
||||||
|
The profile uses a separate Compose project, generated independent secrets, and
|
||||||
|
a separate PostgreSQL volume. Its Traefik instance is constrained to that exact
|
||||||
|
Compose project; its router, service, middleware, and Host rule are unique, so
|
||||||
|
the load profile cannot join the staging router. It exercises:
|
||||||
|
|
||||||
|
- dynamic public home and Safety pages;
|
||||||
|
- database-backed readiness queries;
|
||||||
|
- Phoenix WebSocket upgrades and protocol heartbeats;
|
||||||
|
- the configured number of web and worker replicas;
|
||||||
|
- cross-node Phoenix PubSub after each run.
|
||||||
|
|
||||||
|
It does not authenticate users, create requests, send chat messages, update
|
||||||
|
locations, render a real browser, or reproduce an unknown future production
|
||||||
|
traffic mix. It also does not sample the k6 load-generator container itself.
|
||||||
|
Its results only describe the recorded host, container versions, replica
|
||||||
|
counts, database state, and experiment inputs.
|
||||||
|
|
||||||
|
## Create the isolated profile
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/ensure-local-load-env.sh
|
||||||
|
./scripts/load-stack-up.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated `.env.load` is ignored, restricted to mode 600, and contains
|
||||||
|
independent PostgreSQL and application secrets. Edit its `LOAD_*` inputs to
|
||||||
|
define a specific experiment. Values in `.env.load.example` are reproducible
|
||||||
|
measurement points, not recommendations.
|
||||||
|
|
||||||
|
## Run and compare replica counts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/load-run.sh two-web
|
||||||
|
./scripts/load-stack-up.sh 3
|
||||||
|
# Set LOAD_WEB_REPLICAS=3 in .env.load so the recorded expected topology
|
||||||
|
# matches the running topology, then:
|
||||||
|
./scripts/load-run.sh three-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Each run writes ignored evidence below `output/performance/<label>/`:
|
||||||
|
|
||||||
|
- the exact inputs and Docker host observation;
|
||||||
|
- a machine-readable k6 summary and complete console report;
|
||||||
|
- periodic Docker CPU/memory/network/block-I/O/PID samples for every load
|
||||||
|
stack container;
|
||||||
|
- before/after application table counts and their diff;
|
||||||
|
- final Compose state, readiness response, application logs, and cross-node
|
||||||
|
PubSub result.
|
||||||
|
|
||||||
|
The k6 image is pinned to version 2.1.0 by digest and runs locally with anonymous
|
||||||
|
usage reporting disabled. No Grafana Cloud account, API token, OpenAI API, or
|
||||||
|
usage-based service is involved.
|
||||||
|
|
||||||
|
## Observed local comparison
|
||||||
|
|
||||||
|
Observed on 2026-07-18 with Docker Engine 29.6.2 on the recorded 32-CPU,
|
||||||
|
100289265664-byte Docker host. Neither application containers nor the load
|
||||||
|
generator had CPU or memory limits. Both runs used 40 HTTP VUs, 40 WebSocket
|
||||||
|
VUs, a 30-second duration, a 0.1-second HTTP think time, and five-second socket
|
||||||
|
sessions. Each HTTP iteration requested the home page, Safety page, and
|
||||||
|
database-backed readiness endpoint.
|
||||||
|
|
||||||
|
| Observation | 2 web replicas | 3 web replicas |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| HTTP checks | 35,160 / 35,160 passed | 35,163 / 35,163 passed |
|
||||||
|
| HTTP failed rate | 0% | 0% |
|
||||||
|
| Observed HTTP rate | 1,169.12 requests/s | 1,169.42 requests/s |
|
||||||
|
| HTTP duration p95 | 2.87 ms | 3.36 ms |
|
||||||
|
| WebSocket opened / heartbeat replies | 240 / 240 | 240 / 240 |
|
||||||
|
| WebSocket errors | 0 | 0 |
|
||||||
|
| WebSocket connect p95 | 46.65 ms | 24.12 ms |
|
||||||
|
| Average CPU per web container | 66.57%, 66.63% | 50.25%, 49.77%, 49.18% |
|
||||||
|
| Maximum CPU per web container | 79.80%, 79.21% | 58.16%, 59.23%, 59.08% |
|
||||||
|
| Maximum observed memory per web container | 268.3, 238.3 MiB | 221.0, 223.2, 219.4 MiB |
|
||||||
|
|
||||||
|
Each resource series contained 11 sequential `docker stats --no-stream`
|
||||||
|
samples. Both before/after database diffs were empty, both cross-node PubSub
|
||||||
|
probes passed, readiness remained healthy, and the captured proxy/web/worker
|
||||||
|
logs contained no application error or warning. The public staging root and
|
||||||
|
readiness endpoint also returned HTTP 200 while the isolated project was
|
||||||
|
running.
|
||||||
|
|
||||||
|
An earlier harness smoke exposed that fixed Traefik router/service names are
|
||||||
|
global to the Docker provider: starting a second Compose project temporarily
|
||||||
|
made staging readiness return HTTP 404. The load project was stopped
|
||||||
|
immediately and staging returned HTTP 200. The final Compose configuration
|
||||||
|
parameterizes the project constraint, router/service name, Docker network, and
|
||||||
|
router rule. Merged-config inspection found no shared router/service label key,
|
||||||
|
the two running proxies showed different exact project constraints, and both
|
||||||
|
origins returned HTTP 200 concurrently before the recorded final runs.
|
||||||
|
|
||||||
|
The nearly identical request rate is expected from the selected paced workload:
|
||||||
|
40 HTTP VUs each issue three requests and then wait 0.1 seconds, giving an
|
||||||
|
approximate workload ceiling of 1,200 requests/s before response time is added.
|
||||||
|
The result therefore does not show the saturation point of two replicas and
|
||||||
|
does not justify an HPA threshold. The lower per-container CPU and lower
|
||||||
|
WebSocket connect p95 with three replicas are observations for this run only;
|
||||||
|
the slightly higher HTTP p95 also shows why one short comparison is not a
|
||||||
|
production sizing study.
|
||||||
|
|
||||||
|
Ignored evidence:
|
||||||
|
|
||||||
|
- `output/performance/two-web-final-20260718/`
|
||||||
|
- `output/performance/three-web-final-20260718/`
|
||||||
|
|
||||||
|
Stop the isolated containers without deleting their database volume:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/load-stack-stop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The stop script reads the actual `LOAD_PROJECT`, refuses the staging project
|
||||||
|
name, and does not delete the volume. Volume deletion is intentionally not
|
||||||
|
automated.
|
||||||
|
|
@ -135,8 +135,11 @@ for that reverse lookup selected the index. The table contained no block rows,
|
||||||
so this confirms query shape, not production performance.
|
so this confirms query shape, not production performance.
|
||||||
|
|
||||||
Exact production capacity, minimum CPU/RAM, and scaling thresholds are unknown:
|
Exact production capacity, minimum CPU/RAM, and scaling thresholds are unknown:
|
||||||
there is no representative load dataset or target-environment measurement.
|
there is no representative load dataset or target-environment measurement. An
|
||||||
The Helm chart therefore does not invent resource limits or an HPA policy.
|
isolated 40 HTTP VU / 40 WebSocket VU local comparison is recorded in
|
||||||
|
`docs/performance.md`; it tested a paced public/readiness/heartbeat workload
|
||||||
|
with 2 and 3 web replicas but did not find a saturation point. The Helm chart
|
||||||
|
therefore does not invent resource limits or an HPA policy.
|
||||||
|
|
||||||
## Public staging observation
|
## Public staging observation
|
||||||
|
|
||||||
|
|
|
||||||
154
load/k6/public-and-websocket.js
Normal file
154
load/k6/public-and-websocket.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { check, sleep } from "k6";
|
||||||
|
import http from "k6/http";
|
||||||
|
import { Counter } from "k6/metrics";
|
||||||
|
import { WebSocket } from "k6/websockets";
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const value = __ENV[name];
|
||||||
|
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(name) {
|
||||||
|
const value = Number(required(name));
|
||||||
|
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeNumber(name) {
|
||||||
|
const value = Number(required(name));
|
||||||
|
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
throw new Error(`${name} must be a non-negative number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = required("BASE_URL").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
if (!/^https?:\/\/[^/?#]+$/.test(baseUrl)) {
|
||||||
|
throw new Error("BASE_URL must be an HTTP(S) origin without a path");
|
||||||
|
}
|
||||||
|
|
||||||
|
const websocketUrl =
|
||||||
|
baseUrl.replace(/^http/, "ws") + "/live/websocket?vsn=2.0.0";
|
||||||
|
const websocketHoldMs = positiveInteger("WS_HOLD_MS");
|
||||||
|
const websocketConnectTimeoutMs = positiveInteger("WS_CONNECT_TIMEOUT_MS");
|
||||||
|
const httpThinkSeconds = nonNegativeNumber("HTTP_THINK_SECONDS");
|
||||||
|
|
||||||
|
const websocketOpened = new Counter("wnh_websocket_opened");
|
||||||
|
const websocketErrors = new Counter("wnh_websocket_errors");
|
||||||
|
const heartbeatReplies = new Counter("wnh_websocket_heartbeat_replies");
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
discardResponseBodies: true,
|
||||||
|
scenarios: {
|
||||||
|
public_http: {
|
||||||
|
executor: "constant-vus",
|
||||||
|
exec: "publicHttp",
|
||||||
|
vus: positiveInteger("HTTP_VUS"),
|
||||||
|
duration: required("DURATION"),
|
||||||
|
},
|
||||||
|
phoenix_websocket: {
|
||||||
|
executor: "constant-vus",
|
||||||
|
exec: "phoenixWebsocket",
|
||||||
|
vus: positiveInteger("WS_VUS"),
|
||||||
|
duration: required("DURATION"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function publicHttp() {
|
||||||
|
const responses = http.batch([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
url: `${baseUrl}/`,
|
||||||
|
tags: { endpoint: "home" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
url: `${baseUrl}/safety`,
|
||||||
|
tags: { endpoint: "safety" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
url: `${baseUrl}/healthz/ready`,
|
||||||
|
tags: { endpoint: "readiness" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
check(responses[0], { "home returned 200": (response) => response.status === 200 });
|
||||||
|
check(responses[1], { "safety returned 200": (response) => response.status === 200 });
|
||||||
|
check(responses[2], {
|
||||||
|
"readiness returned 200": (response) => response.status === 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(httpThinkSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phoenixWebsocket() {
|
||||||
|
const socket = new WebSocket(websocketUrl, [], {
|
||||||
|
tags: { endpoint: "phoenix_live_socket" },
|
||||||
|
});
|
||||||
|
const ref = `${__VU}-${__ITER}`;
|
||||||
|
let opened = false;
|
||||||
|
let closeTimer;
|
||||||
|
const connectTimer = setTimeout(() => {
|
||||||
|
if (!opened) {
|
||||||
|
websocketErrors.add(1);
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}, websocketConnectTimeoutMs);
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
opened = true;
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
websocketOpened.add(1);
|
||||||
|
socket.send(JSON.stringify([null, ref, "phoenix", "heartbeat", {}]));
|
||||||
|
closeTimer = setTimeout(() => socket.close(), websocketHoldMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
let frame;
|
||||||
|
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(event.data);
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Array.isArray(frame) &&
|
||||||
|
frame[1] === ref &&
|
||||||
|
frame[2] === "phoenix" &&
|
||||||
|
frame[3] === "phx_reply" &&
|
||||||
|
frame[4] &&
|
||||||
|
frame[4].status === "ok"
|
||||||
|
) {
|
||||||
|
heartbeatReplies.add(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
websocketErrors.add(1);
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
|
||||||
|
if (closeTimer !== undefined) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
61
scripts/ensure-local-load-env.sh
Executable file
61
scripts/ensure-local-load-env.sh
Executable file
|
|
@ -0,0 +1,61 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
TEMPLATE="$ROOT/.env.load.example"
|
||||||
|
ENV_FILE="$ROOT/.env.load"
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo ".env.load already exists; no secret or experiment input was changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for command in openssl perl; do
|
||||||
|
if ! command -v "$command" >/dev/null 2>&1; then
|
||||||
|
echo "Required command is unavailable: $command" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "$TEMPLATE" ]; then
|
||||||
|
echo "Missing tracked template: $TEMPLATE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
postgres_password=$(openssl rand -hex 32)
|
||||||
|
secret_key_base=$(openssl rand -hex 64)
|
||||||
|
handover_secret=$(openssl rand -hex 64)
|
||||||
|
release_cookie=$(openssl rand -hex 64)
|
||||||
|
metrics_token=$(openssl rand -hex 32)
|
||||||
|
database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load"
|
||||||
|
temporary=$(mktemp "${ENV_FILE}.XXXXXX")
|
||||||
|
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
POSTGRES_PASSWORD_VALUE=$postgres_password \
|
||||||
|
DATABASE_URL_VALUE=$database_url \
|
||||||
|
SECRET_KEY_BASE_VALUE=$secret_key_base \
|
||||||
|
HANDOVER_SECRET_VALUE=$handover_secret \
|
||||||
|
RELEASE_COOKIE_VALUE=$release_cookie \
|
||||||
|
METRICS_TOKEN_VALUE=$metrics_token \
|
||||||
|
perl -0pe '
|
||||||
|
s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g;
|
||||||
|
s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g;
|
||||||
|
s/GENERATE_SECRET_KEY_BASE/$ENV{SECRET_KEY_BASE_VALUE}/g;
|
||||||
|
s/GENERATE_HANDOVER_SECRET/$ENV{HANDOVER_SECRET_VALUE}/g;
|
||||||
|
s/GENERATE_RELEASE_COOKIE/$ENV{RELEASE_COOKIE_VALUE}/g;
|
||||||
|
s/GENERATE_METRICS_TOKEN/$ENV{METRICS_TOKEN_VALUE}/g;
|
||||||
|
' "$TEMPLATE" >"$temporary"
|
||||||
|
|
||||||
|
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
|
||||||
|
echo "A secret marker was not replaced; refusing to publish .env.load." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod 600 "$temporary"
|
||||||
|
mv "$temporary" "$ENV_FILE"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
unset postgres_password secret_key_base handover_secret release_cookie metrics_token database_url
|
||||||
|
|
||||||
|
echo "Generated independent load-profile secrets in ignored .env.load."
|
||||||
204
scripts/load-run.sh
Executable file
204
scripts/load-run.sh
Executable file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
ENV_FILE="$ROOT/.env.load"
|
||||||
|
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
|
||||||
|
LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_HTTP_VUS LOAD_WS_VUS \
|
||||||
|
LOAD_DURATION LOAD_WS_HOLD_MS LOAD_WS_CONNECT_TIMEOUT_MS \
|
||||||
|
LOAD_HTTP_THINK_SECONDS; do
|
||||||
|
if [[ -z "${!name:-}" ]]; then
|
||||||
|
echo "$name is missing from .env.load" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
|
||||||
|
echo "The load profile must not use the staging Compose project." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--env-file "$ENV_FILE"
|
||||||
|
-p "$LOAD_PROJECT"
|
||||||
|
-f compose.yaml
|
||||||
|
-f compose.load.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
mapfile -t web_containers < <("${compose[@]}" ps -q web)
|
||||||
|
|
||||||
|
if [[ "${#web_containers[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then
|
||||||
|
echo "Expected $LOAD_WEB_REPLICAS running web replicas; observed ${#web_containers[@]}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
network_id=$(
|
||||||
|
docker network ls \
|
||||||
|
--filter "label=com.docker.compose.project=$LOAD_PROJECT" \
|
||||||
|
--filter "label=com.docker.compose.network=edge" \
|
||||||
|
--quiet |
|
||||||
|
head -n 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -z "$network_id" ]]; then
|
||||||
|
echo "The isolated load-profile edge network was not found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t measured_containers < <(
|
||||||
|
"${compose[@]}" ps -q web worker db proxy
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${#measured_containers[@]}" -lt 4 ]]; then
|
||||||
|
echo "The isolated load stack is incomplete." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
output_dir="$ROOT/output/performance/$LABEL"
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
chmod 700 "$ROOT/output" "$ROOT/output/performance" "$output_dir"
|
||||||
|
running_marker="$output_dir/.sampling"
|
||||||
|
resource_log="$output_dir/docker-stats.jsonl"
|
||||||
|
touch "$running_marker"
|
||||||
|
|
||||||
|
snapshot_database() {
|
||||||
|
local destination=$1
|
||||||
|
|
||||||
|
"${compose[@]}" exec -T db sh -c \
|
||||||
|
'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \
|
||||||
|
>"$destination" <<'SQL'
|
||||||
|
BEGIN READ ONLY;
|
||||||
|
SELECT 'users' AS table_name, count(*) AS row_count FROM users
|
||||||
|
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
|
||||||
|
UNION ALL SELECT 'messages', count(*) FROM messages
|
||||||
|
UNION ALL SELECT 'categories', count(*) FROM categories
|
||||||
|
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
|
||||||
|
UNION ALL SELECT 'activities', count(*) FROM activities
|
||||||
|
UNION ALL SELECT 'reports', count(*) FROM reports
|
||||||
|
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
|
||||||
|
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
|
||||||
|
ORDER BY table_name;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
sample_resources() {
|
||||||
|
while [[ -e "$running_marker" ]]; do
|
||||||
|
observed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
docker stats --no-stream --format '{{json .}}' "${measured_containers[@]}" |
|
||||||
|
jq -c --arg observed_at "$observed_at" '. + {ObservedAt: $observed_at}' \
|
||||||
|
>>"$resource_log"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_sampler() {
|
||||||
|
rm -f "$running_marker"
|
||||||
|
|
||||||
|
if [[ -n "${sampler_pid:-}" ]]; then
|
||||||
|
wait "$sampler_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_sampler EXIT HUP INT TERM
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'observed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
printf 'k6_image=%s\n' "$K6_IMAGE"
|
||||||
|
printf 'load_project=%s\n' "$LOAD_PROJECT"
|
||||||
|
printf 'web_replicas=%s\n' "$LOAD_WEB_REPLICAS"
|
||||||
|
printf 'http_vus=%s\n' "$LOAD_HTTP_VUS"
|
||||||
|
printf 'websocket_vus=%s\n' "$LOAD_WS_VUS"
|
||||||
|
printf 'duration=%s\n' "$LOAD_DURATION"
|
||||||
|
printf 'websocket_hold_ms=%s\n' "$LOAD_WS_HOLD_MS"
|
||||||
|
printf 'websocket_connect_timeout_ms=%s\n' "$LOAD_WS_CONNECT_TIMEOUT_MS"
|
||||||
|
printf 'http_think_seconds=%s\n' "$LOAD_HTTP_THINK_SECONDS"
|
||||||
|
docker info --format 'docker_cpus={{.NCPU}} docker_memory_bytes={{.MemTotal}} docker_server={{.ServerVersion}}'
|
||||||
|
docker compose version
|
||||||
|
uname -a
|
||||||
|
} >"$output_dir/environment.txt"
|
||||||
|
|
||||||
|
snapshot_database "$output_dir/database-before.txt"
|
||||||
|
sample_resources &
|
||||||
|
sampler_pid=$!
|
||||||
|
|
||||||
|
set +e
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--network "$network_id" \
|
||||||
|
--volume "$ROOT/load/k6:/scripts:ro" \
|
||||||
|
--volume "$output_dir:/output" \
|
||||||
|
--env "BASE_URL=http://$LOAD_HOST" \
|
||||||
|
--env "HTTP_VUS=$LOAD_HTTP_VUS" \
|
||||||
|
--env "WS_VUS=$LOAD_WS_VUS" \
|
||||||
|
--env "DURATION=$LOAD_DURATION" \
|
||||||
|
--env "WS_HOLD_MS=$LOAD_WS_HOLD_MS" \
|
||||||
|
--env "WS_CONNECT_TIMEOUT_MS=$LOAD_WS_CONNECT_TIMEOUT_MS" \
|
||||||
|
--env "HTTP_THINK_SECONDS=$LOAD_HTTP_THINK_SECONDS" \
|
||||||
|
"$K6_IMAGE" run \
|
||||||
|
--no-usage-report \
|
||||||
|
--summary-mode=full \
|
||||||
|
--summary-export=/output/k6-summary.json \
|
||||||
|
--new-machine-readable-summary \
|
||||||
|
/scripts/public-and-websocket.js 2>&1 |
|
||||||
|
tee "$output_dir/k6.log"
|
||||||
|
k6_status=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cleanup_sampler
|
||||||
|
sampler_pid=
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
snapshot_database "$output_dir/database-after.txt"
|
||||||
|
diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \
|
||||||
|
>"$output_dir/database-diff.txt" || true
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose \
|
||||||
|
>"$output_dir/pubsub-probe.txt"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--header "Host: $LOAD_HOST" \
|
||||||
|
"http://localhost:$HTTP_PORT/healthz/ready" \
|
||||||
|
>"$output_dir/readiness-after.json"
|
||||||
|
"${compose[@]}" ps -a >"$output_dir/compose-after.txt"
|
||||||
|
"${compose[@]}" logs --since 10m proxy web worker >"$output_dir/application.log" 2>&1
|
||||||
|
|
||||||
|
if ! jq -e '
|
||||||
|
def metric($name):
|
||||||
|
([.results.metrics[] | select(.name == $name) | .values][0] // {});
|
||||||
|
|
||||||
|
(.results.checks.metrics[] |
|
||||||
|
select(.name == "checks_failed") |
|
||||||
|
.values.matches) == 0 and
|
||||||
|
metric("http_req_failed").matches == 0 and
|
||||||
|
metric("wnh_websocket_opened").count > 0 and
|
||||||
|
metric("wnh_websocket_opened").count ==
|
||||||
|
metric("wnh_websocket_heartbeat_replies").count and
|
||||||
|
(metric("wnh_websocket_errors").count // 0) == 0
|
||||||
|
' "$output_dir/k6-summary.json" >/dev/null; then
|
||||||
|
echo "Functional load checks failed; evidence is in $output_dir." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$k6_status" -ne 0 ]]; then
|
||||||
|
echo "k6 exited with status $k6_status; evidence is in $output_dir." >&2
|
||||||
|
exit "$k6_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Load evidence: %s\n' "$output_dir"
|
||||||
31
scripts/load-stack-stop.sh
Executable file
31
scripts/load-stack-stop.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
ENV_FILE="$ROOT/.env.load"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Missing $ENV_FILE; no load-profile project was selected." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
||||||
|
|
||||||
|
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||||
|
echo "Refusing to stop the staging Compose project." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
docker compose \
|
||||||
|
--env-file "$ENV_FILE" \
|
||||||
|
-p "$LOAD_PROJECT" \
|
||||||
|
-f compose.yaml \
|
||||||
|
-f compose.load.yaml \
|
||||||
|
stop
|
||||||
|
|
||||||
|
echo "Stopped the isolated load-profile containers; its database volume remains."
|
||||||
63
scripts/load-stack-up.sh
Executable file
63
scripts/load-stack-up.sh
Executable file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
ENV_FILE="$ROOT/.env.load"
|
||||||
|
REPLICAS=${1:-}
|
||||||
|
|
||||||
|
"$ROOT/scripts/ensure-local-load-env.sh"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
||||||
|
: "${LOAD_HOST:?LOAD_HOST is missing from .env.load}"
|
||||||
|
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from .env.load}"
|
||||||
|
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from .env.load}"
|
||||||
|
|
||||||
|
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||||
|
echo "The load profile must not use the staging Compose project." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$REPLICAS" ]; then
|
||||||
|
LOAD_WEB_REPLICAS=$REPLICAS
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$LOAD_WEB_REPLICAS:$LOAD_WORKER_REPLICAS" in
|
||||||
|
*[!0-9:]* | 0:* | *:0 | :* | *:)
|
||||||
|
echo "Web and worker replica counts must be positive integers." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
docker compose \
|
||||||
|
--env-file "$ENV_FILE" \
|
||||||
|
-p "$LOAD_PROJECT" \
|
||||||
|
-f compose.yaml \
|
||||||
|
-f compose.load.yaml \
|
||||||
|
up -d --build --wait \
|
||||||
|
--scale "web=$LOAD_WEB_REPLICAS" \
|
||||||
|
--scale "worker=$LOAD_WORKER_REPLICAS"
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
|
||||||
|
status=$(
|
||||||
|
curl --silent --show-error \
|
||||||
|
--header "Host: $LOAD_HOST" \
|
||||||
|
--output /dev/null \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
"http://localhost:$HTTP_PORT/healthz/ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$status" != 200 ]; then
|
||||||
|
echo "Load-profile readiness returned HTTP $status instead of 200." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--header "Host: $LOAD_HOST" \
|
||||||
|
"http://localhost:$HTTP_PORT/healthz/ready"
|
||||||
|
printf '\nLoad profile is ready with %s web and %s worker replicas.\n' \
|
||||||
|
"$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS"
|
||||||
|
|
@ -3,10 +3,11 @@ set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
MODE=${1:-compose}
|
MODE=${1:-compose}
|
||||||
|
COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-who_need_help}
|
||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
compose)
|
compose)
|
||||||
TARGET=$(docker compose -p who_need_help ps -q web | head -n 1)
|
TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q web | head -n 1)
|
||||||
if [ -z "$TARGET" ]; then
|
if [ -z "$TARGET" ]; then
|
||||||
echo "No running Compose web replica was found." >&2
|
echo "No running Compose web replica was found." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user