fix: drain replicas and clean isolated load cycles
This commit is contained in:
parent
4a1af1b658
commit
0e81ee7f36
|
|
@ -51,6 +51,10 @@ DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
|
|||
# accepts. Keep loopback locally; set the exact VPN proxy address for staging.
|
||||
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
|
||||
TRAEFIK_RETRY_ATTEMPTS=3
|
||||
# Keep false for ordinary deployments. The isolated load profile enables the
|
||||
# unbound port-8080 API only inside its private Compose networks so its rolling
|
||||
# drill can observe when a drained backend leaves service.
|
||||
TRAEFIK_API_INSECURE=false
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ PHX_SCHEME=https
|
|||
PHX_URL_PORT=443
|
||||
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
|
||||
TRAEFIK_RETRY_ATTEMPTS=3
|
||||
TRAEFIK_API_INSECURE=true
|
||||
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
|
||||
TRAEFIK_APP_NAME=who-need-help-load
|
||||
TRAEFIK_DOCKER_NETWORK=who_need_help_load_ingress
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ services:
|
|||
ingress: {}
|
||||
|
||||
migrate:
|
||||
image: who-need-help:load
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
|
||||
|
||||
web:
|
||||
image: who-need-help:load
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
|
||||
labels:
|
||||
- traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded,${TRAEFIK_APP_NAME}-retry
|
||||
- traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https
|
||||
|
|
@ -32,4 +32,4 @@ services:
|
|||
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true
|
||||
|
||||
worker:
|
||||
image: who-need-help:load
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile.traefik
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --api.dashboard=${TRAEFIK_API_INSECURE:-false}
|
||||
- --api.insecure=${TRAEFIK_API_INSECURE:-false}
|
||||
- --providers.docker=true
|
||||
- --providers.docker.endpoint=tcp://docker-api-proxy:2375
|
||||
- --providers.docker.exposedbydefault=false
|
||||
|
|
@ -241,7 +242,11 @@ services:
|
|||
- 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}.middlewares=${TRAEFIK_APP_NAME:-who-need-help}-retry
|
||||
- traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.attempts=${TRAEFIK_RETRY_ATTEMPTS:-3}
|
||||
- traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.status=500-599
|
||||
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000
|
||||
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.path=/healthz/ready
|
||||
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.interval=10s
|
||||
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.timeout=3s
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
|
||||
interval: 10s
|
||||
|
|
|
|||
|
|
@ -521,10 +521,15 @@ The isolated load project can exercise process crashes, sequential container
|
|||
replacement, and a real Oban retry without touching the normal Compose project:
|
||||
|
||||
```bash
|
||||
./scripts/load-stack-up.sh
|
||||
./scripts/load-resilience-run.sh local-resilience
|
||||
./scripts/load-cycle.sh local-resilience resilience
|
||||
```
|
||||
|
||||
The lifecycle wrapper assigns unique Compose and image names and removes that
|
||||
exact run's containers, networks, named volumes, temporary environment, and
|
||||
image tags on success, failure, or interruption. Use `load-stack-up.sh` plus
|
||||
`load-resilience-run.sh` only when the isolated stack must remain available for
|
||||
manual inspection.
|
||||
|
||||
The resilience script refuses `LOAD_PROJECT=who_need_help` and verifies the
|
||||
Compose project/service labels of every container before stopping it. It:
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,21 @@ counts, database state, and experiment inputs.
|
|||
|
||||
## Create the isolated profile
|
||||
|
||||
For an ordinary one-run measurement, prefer the lifecycle wrapper. It creates
|
||||
unique Compose and image names, then removes only that run's containers,
|
||||
networks, named volumes, temporary environment, and image tags on success,
|
||||
failure, or interruption:
|
||||
|
||||
```sh
|
||||
./scripts/load-cycle.sh local-load load
|
||||
./scripts/load-cycle.sh local-resilience resilience
|
||||
./scripts/load-cycle.sh local-complete both
|
||||
```
|
||||
|
||||
Evidence remains under `output/performance/` and `output/resilience/`. The
|
||||
commands below are the manual diagnostic workflow; unlike the lifecycle
|
||||
wrapper, it deliberately keeps the isolated database volume between commands.
|
||||
|
||||
```sh
|
||||
./scripts/ensure-local-load-env.sh
|
||||
./scripts/load-stack-up.sh
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ defmodule WhoNeedHelp.Application do
|
|||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
WhoNeedHelpWeb.DrainState.reset()
|
||||
|
||||
if @e2e_routes do
|
||||
boot_id = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
|
||||
:persistent_term.put({WhoNeedHelp, :e2e_boot_id}, boot_id)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ defmodule WhoNeedHelpWeb.HealthController do
|
|||
def live(conn, _params), do: json(conn, %{status: "ok"})
|
||||
|
||||
def ready(conn, _params) do
|
||||
if WhoNeedHelpWeb.DrainState.draining?() do
|
||||
conn |> put_status(:service_unavailable) |> json(%{status: "draining"})
|
||||
else
|
||||
case Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []) do
|
||||
{:ok, _} -> json(conn, %{status: "ready"})
|
||||
{:error, _} -> conn |> put_status(:service_unavailable) |> json(%{status: "not_ready"})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
26
lib/who_need_help_web/drain_state.ex
Normal file
26
lib/who_need_help_web/drain_state.ex
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
defmodule WhoNeedHelpWeb.DrainState do
|
||||
@moduledoc """
|
||||
Tracks whether the current web node is draining before a planned shutdown.
|
||||
|
||||
The flag is node-local by design. Operators can mark one replica as draining
|
||||
through a release RPC; readiness then fails on that replica while liveness
|
||||
remains available.
|
||||
"""
|
||||
|
||||
@key {__MODULE__, :draining}
|
||||
|
||||
@spec reset() :: :ok
|
||||
def reset do
|
||||
:persistent_term.put(@key, false)
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec begin_drain() :: :ok
|
||||
def begin_drain do
|
||||
:persistent_term.put(@key, true)
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec draining?() :: boolean()
|
||||
def draining?, do: :persistent_term.get(@key, false)
|
||||
end
|
||||
|
|
@ -59,6 +59,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
|||
needs_resilience_interval=true
|
||||
needs_resilience_request_timeout=true
|
||||
needs_traefik_retry_attempts=true
|
||||
needs_traefik_api_insecure=true
|
||||
needs_observability_prometheus_port=true
|
||||
needs_observability_alertmanager_port=true
|
||||
needs_observability_grafana_port=true
|
||||
|
|
@ -90,6 +91,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
|||
grep -q '^LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS=' "$ENV_FILE" &&
|
||||
needs_resilience_request_timeout=false
|
||||
grep -q '^TRAEFIK_RETRY_ATTEMPTS=' "$ENV_FILE" && needs_traefik_retry_attempts=false
|
||||
grep -q '^TRAEFIK_API_INSECURE=' "$ENV_FILE" && needs_traefik_api_insecure=false
|
||||
grep -q '^OBSERVABILITY_PROMETHEUS_PORT=' "$ENV_FILE" &&
|
||||
needs_observability_prometheus_port=false
|
||||
grep -q '^OBSERVABILITY_ALERTMANAGER_PORT=' "$ENV_FILE" &&
|
||||
|
|
@ -135,6 +137,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
|||
[ "$needs_resilience_interval" = false ] &&
|
||||
[ "$needs_resilience_request_timeout" = false ] &&
|
||||
[ "$needs_traefik_retry_attempts" = false ] &&
|
||||
[ "$needs_traefik_api_insecure" = false ] &&
|
||||
[ "$needs_observability_prometheus_port" = false ] &&
|
||||
[ "$needs_observability_alertmanager_port" = false ] &&
|
||||
[ "$needs_observability_grafana_port" = false ] &&
|
||||
|
|
@ -235,6 +238,10 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
|||
printf 'TRAEFIK_RETRY_ATTEMPTS=3\n'
|
||||
fi
|
||||
|
||||
if [ "$needs_traefik_api_insecure" = true ]; then
|
||||
printf 'TRAEFIK_API_INSECURE=true\n'
|
||||
fi
|
||||
|
||||
if [ "$needs_observability_prometheus_port" = true ] ||
|
||||
[ "$needs_observability_alertmanager_port" = true ] ||
|
||||
[ "$needs_observability_grafana_port" = true ] ||
|
||||
|
|
@ -338,7 +345,8 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
|||
needs_fixture_password needs_oban_maintenance_concurrency \
|
||||
needs_oban_push_concurrency needs_resilience_timeout \
|
||||
needs_resilience_interval needs_resilience_request_timeout \
|
||||
needs_traefik_retry_attempts needs_observability_prometheus_port \
|
||||
needs_traefik_retry_attempts needs_traefik_api_insecure \
|
||||
needs_observability_prometheus_port \
|
||||
needs_observability_alertmanager_port needs_observability_grafana_port \
|
||||
needs_observability_scrape_interval needs_observability_evaluation_interval \
|
||||
needs_observability_timeout needs_observability_grafana_user \
|
||||
|
|
|
|||
153
scripts/load-cycle.sh
Executable file
153
scripts/load-cycle.sh
Executable file
|
|
@ -0,0 +1,153 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
BASE_ENV=${WNH_LOAD_BASE_ENV_FILE:-"$ROOT/.env.load"}
|
||||
LABEL=${1:-"cycle-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||
MODE=${2:-load}
|
||||
|
||||
if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
load | resilience | both) ;;
|
||||
*)
|
||||
echo "Mode must be load, resilience, or both." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
WNH_LOAD_ENV_FILE="$BASE_ENV" "$ROOT/scripts/ensure-local-load-env.sh"
|
||||
|
||||
if [[ ! -f "$BASE_ENV" ]]; then
|
||||
echo "Missing base load environment: $BASE_ENV." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
||||
safe_id=$(printf '%s' "$run_id" | tr -cd 'A-Za-z0-9')
|
||||
project="who_need_help_load_$safe_id"
|
||||
temporary_env=$(mktemp "${TMPDIR:-/tmp}/wnh-load-cycle.XXXXXX.env")
|
||||
export WNH_LOAD_ENV_FILE=$temporary_env
|
||||
export WNH_LOAD_TOOLS_IMAGE="who-need-help:load-tools-$safe_id"
|
||||
|
||||
cp "$BASE_ENV" "$temporary_env"
|
||||
chmod 600 "$temporary_env"
|
||||
|
||||
set_env_value() {
|
||||
local name=$1
|
||||
local value=$2
|
||||
local replacement="$name=$value"
|
||||
|
||||
if grep -q "^${name}=" "$temporary_env"; then
|
||||
sed -i "s|^${name}=.*|$replacement|" "$temporary_env"
|
||||
else
|
||||
printf '%s\n' "$replacement" >>"$temporary_env"
|
||||
fi
|
||||
}
|
||||
|
||||
set_env_value LOAD_PROJECT "$project"
|
||||
set_env_value COMPOSE_PROJECT_NAME "$project"
|
||||
set_env_value TRAEFIK_PROJECT_CONSTRAINT "$project"
|
||||
set_env_value TRAEFIK_DOCKER_NETWORK "${project}_ingress"
|
||||
set_env_value APP_IMAGE "who-need-help:load-$safe_id"
|
||||
set_env_value SOCKET_PROXY_IMAGE "who-need-help:socket-proxy-load-$safe_id"
|
||||
set_env_value POSTGIS_IMAGE "who-need-help:postgis-load-$safe_id"
|
||||
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. "$temporary_env"
|
||||
set +a
|
||||
|
||||
for name in APP_IMAGE SOCKET_PROXY_IMAGE POSTGIS_IMAGE; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "$name is missing from the generated cycle environment." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$project")" ]] ||
|
||||
[[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$project")" ]] ||
|
||||
[[ -n "$(docker volume ls -q --filter "label=com.docker.compose.project=$project")" ]]; then
|
||||
echo "Generated load-cycle project already owns Docker resources: $project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
started=0
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
local cleanup_status=0
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
if [[ "$started" -eq 1 ]] ||
|
||||
[[ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$project")" ]] ||
|
||||
[[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$project")" ]]; then
|
||||
"$ROOT/scripts/load-stack-stop.sh" >/dev/null 2>&1 || cleanup_status=1
|
||||
fi
|
||||
|
||||
while IFS= read -r volume; do
|
||||
[[ -n "$volume" ]] || continue
|
||||
observed_project=$(docker volume inspect --format \
|
||||
'{{index .Labels "com.docker.compose.project"}}' "$volume")
|
||||
references=$(docker ps -aq --filter "volume=$volume")
|
||||
|
||||
if [[ "$observed_project" != "$project" ]] || [[ -n "$references" ]]; then
|
||||
echo "Refusing unexpected or referenced load-cycle volume: $volume" >&2
|
||||
cleanup_status=1
|
||||
continue
|
||||
fi
|
||||
|
||||
docker volume rm "$volume" >/dev/null || cleanup_status=1
|
||||
done < <(docker volume ls -q --filter "label=com.docker.compose.project=$project")
|
||||
|
||||
for image in "$APP_IMAGE" "$SOCKET_PROXY_IMAGE" "$POSTGIS_IMAGE" \
|
||||
"$WNH_LOAD_TOOLS_IMAGE"; do
|
||||
if image_id=$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null); then
|
||||
if [[ -n "$(docker ps -aq --filter "ancestor=$image_id")" ]]; then
|
||||
echo "Refusing referenced load-cycle image: $image" >&2
|
||||
cleanup_status=1
|
||||
else
|
||||
docker image rm "$image" >/dev/null || cleanup_status=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f "$temporary_env"
|
||||
|
||||
if [[ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$project")" ]] ||
|
||||
[[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$project")" ]] ||
|
||||
[[ -n "$(docker volume ls -q --filter "label=com.docker.compose.project=$project")" ]]; then
|
||||
echo "Load-cycle cleanup left project resources behind: $project." >&2
|
||||
cleanup_status=1
|
||||
fi
|
||||
|
||||
if [[ "$status" -eq 0 && "$cleanup_status" -ne 0 ]]; then
|
||||
status=$cleanup_status
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
"$ROOT/scripts/load-stack-up.sh"
|
||||
started=1
|
||||
|
||||
case "$MODE" in
|
||||
load)
|
||||
"$ROOT/scripts/load-run.sh" "$LABEL"
|
||||
;;
|
||||
resilience)
|
||||
"$ROOT/scripts/load-resilience-run.sh" "$LABEL"
|
||||
;;
|
||||
both)
|
||||
"$ROOT/scripts/load-run.sh" "$LABEL-load"
|
||||
"$ROOT/scripts/load-resilience-run.sh" "$LABEL-resilience"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Load cycle completed; its project resources will now be removed: $project"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
set -euo pipefail
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
||||
LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
|
|
@ -19,13 +19,18 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
|
|||
LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \
|
||||
LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \
|
||||
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
|
||||
HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
|
||||
TRAEFIK_API_INSECURE HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "$name is missing from .env.load" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$TRAEFIK_API_INSECURE" != "true" ]]; then
|
||||
echo "The isolated resilience profile requires TRAEFIK_API_INSECURE=true." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
|
||||
echo "The resilience profile must not use the staging Compose project." >&2
|
||||
exit 1
|
||||
|
|
@ -291,11 +296,109 @@ delete_retry_job() {
|
|||
|
||||
restore_topology() {
|
||||
"${compose[@]}" up -d --no-deps \
|
||||
--force-recreate \
|
||||
--scale "web=$LOAD_WEB_REPLICAS" \
|
||||
--scale "worker=$LOAD_WORKER_REPLICAS" \
|
||||
web worker >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
traefik_service_json() {
|
||||
local proxy_id edge_network proxy_ip
|
||||
|
||||
proxy_id=$(service_ids proxy | head -n 1)
|
||||
assert_scope "$proxy_id" proxy
|
||||
edge_network="${LOAD_PROJECT}_edge"
|
||||
proxy_ip=$(
|
||||
docker inspect --format \
|
||||
"{{(index .NetworkSettings.Networks \"$edge_network\").IPAddress}}" \
|
||||
"$proxy_id"
|
||||
)
|
||||
|
||||
if [[ -z "$proxy_ip" ]]; then
|
||||
echo "The isolated Traefik proxy has no address on $edge_network." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
curl --silent --show-error --fail \
|
||||
--max-time "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" \
|
||||
"http://$proxy_ip:8080/api/http/services/${TRAEFIK_APP_NAME}@docker"
|
||||
}
|
||||
|
||||
drain_web_from_traefik() {
|
||||
local container_id=$1
|
||||
local destination=$2
|
||||
local ingress_network observed_project observed_role ingress_ip server_url
|
||||
local deadline status
|
||||
|
||||
ingress_network=$(
|
||||
docker inspect --format \
|
||||
'{{index .Config.Labels "traefik.docker.network"}}' \
|
||||
"$container_id"
|
||||
)
|
||||
|
||||
if [[ -z "$ingress_network" ]]; then
|
||||
echo "Container $container_id has no declared Traefik ingress network." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! docker inspect --format '{{json .NetworkSettings.Networks}}' \
|
||||
"$container_id" | jq -e --arg network "$ingress_network" \
|
||||
'has($network)' >/dev/null; then
|
||||
echo "Container $container_id is not attached to $ingress_network." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
observed_project=$(
|
||||
docker network inspect --format \
|
||||
'{{index .Labels "com.docker.compose.project"}}' \
|
||||
"$ingress_network"
|
||||
)
|
||||
observed_role=$(
|
||||
docker network inspect --format \
|
||||
'{{index .Labels "com.docker.compose.network"}}' \
|
||||
"$ingress_network"
|
||||
)
|
||||
|
||||
if [[ "$observed_project" != "$LOAD_PROJECT" ||
|
||||
"$observed_role" != "ingress" ]]; then
|
||||
echo "Ingress network scope mismatch for $ingress_network." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
ingress_ip=$(
|
||||
docker inspect --format \
|
||||
"{{(index .NetworkSettings.Networks \"$ingress_network\").IPAddress}}" \
|
||||
"$container_id"
|
||||
)
|
||||
server_url="http://$ingress_ip:4000"
|
||||
|
||||
docker exec "$container_id" /app/bin/who_need_help rpc \
|
||||
'IO.inspect(WhoNeedHelpWeb.DrainState.begin_drain())' >"$destination"
|
||||
|
||||
deadline=$((SECONDS + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS))
|
||||
|
||||
while ((SECONDS < deadline)); do
|
||||
status=$(
|
||||
traefik_service_json |
|
||||
jq -r --arg server_url "$server_url" \
|
||||
'.serverStatus[$server_url] // "MISSING"'
|
||||
)
|
||||
|
||||
printf 'server=%s status=%s observed_at=%s\n' \
|
||||
"$server_url" "$status" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
>>"$destination"
|
||||
|
||||
if [[ "$status" == "DOWN" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Timed out waiting for Traefik to drain $server_url." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
local cleanup_status=0
|
||||
|
|
@ -376,8 +479,19 @@ docker inspect "$worker_target" |
|
|||
|
||||
mapfile -t original_web_ids < <(service_ids web)
|
||||
|
||||
traefik_service_json |
|
||||
jq -e --argjson expected "$LOAD_WEB_REPLICAS" '
|
||||
.status == "enabled" and
|
||||
(.loadBalancer.healthCheck.path == "/healthz/ready") and
|
||||
([.serverStatus[]] | length) == $expected and
|
||||
([.serverStatus[]] | all(. == "UP"))
|
||||
' >"$output_dir/traefik-before-replacements.json"
|
||||
|
||||
for container_id in "${original_web_ids[@]}"; do
|
||||
assert_scope "$container_id" web
|
||||
drain_web_from_traefik \
|
||||
"$container_id" \
|
||||
"$output_dir/web-drain-${container_id:0:12}.txt"
|
||||
{
|
||||
docker stop --timeout 30 "$container_id"
|
||||
docker rm "$container_id"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
||||
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
||||
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
|
||||
PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4"
|
||||
LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||
|
|
@ -106,7 +107,7 @@ if [[ -z "$internal_network_id" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker image inspect who-need-help:load-tools >/dev/null 2>&1; then
|
||||
if ! docker image inspect "$LOAD_TOOLS_IMAGE" >/dev/null 2>&1; then
|
||||
echo "The isolated load-tools image is missing. Run scripts/load-stack-up.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -649,7 +650,7 @@ run_fixture_tool() {
|
|||
--env "WNH_LOAD_FIXTURE_COUNT=$fixture_count" \
|
||||
--env "WNH_LOAD_FIXTURE_PATH=/output/fixtures.json" \
|
||||
--volume "$output_dir:/output" \
|
||||
who-need-help:load-tools \
|
||||
"$LOAD_TOOLS_IMAGE" \
|
||||
mix wnh.load_fixtures "$action"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
set -eu
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
||||
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Missing $ENV_FILE; no load-profile project was selected." >&2
|
||||
|
|
@ -46,9 +47,9 @@ for network_id in $(docker network ls -q \
|
|||
docker network rm "$network_id" >/dev/null
|
||||
done
|
||||
|
||||
if image_id=$(docker image inspect --format '{{.Id}}' who-need-help:load-tools 2>/dev/null); then
|
||||
if image_id=$(docker image inspect --format '{{.Id}}' "$LOAD_TOOLS_IMAGE" 2>/dev/null); then
|
||||
if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then
|
||||
docker image rm who-need-help:load-tools >/dev/null
|
||||
docker image rm "$LOAD_TOOLS_IMAGE" >/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
set -eu
|
||||
|
||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
||||
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
||||
REPLICAS=${1:-}
|
||||
|
||||
"$ROOT/scripts/ensure-local-load-env.sh"
|
||||
|
|
@ -67,9 +68,9 @@ cleanup_failed_start() {
|
|||
sleep 1
|
||||
done
|
||||
|
||||
if image_id=$(docker image inspect --format '{{.Id}}' who-need-help:load-tools 2>/dev/null); then
|
||||
if image_id=$(docker image inspect --format '{{.Id}}' "$LOAD_TOOLS_IMAGE" 2>/dev/null); then
|
||||
if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then
|
||||
docker image rm who-need-help:load-tools >/dev/null 2>&1 || true
|
||||
docker image rm "$LOAD_TOOLS_IMAGE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -85,7 +86,7 @@ cleanup_failed_start() {
|
|||
|
||||
trap cleanup_failed_start EXIT HUP INT TERM
|
||||
|
||||
docker build --target load_tools --tag who-need-help:load-tools .
|
||||
docker build --target load_tools --tag "$LOAD_TOOLS_IMAGE" .
|
||||
|
||||
compose up -d --build --wait \
|
||||
--scale "web=$LOAD_WEB_REPLICAS" \
|
||||
|
|
|
|||
|
|
@ -124,6 +124,22 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
|||
|> json_response(200)
|
||||
end
|
||||
|
||||
test "a draining web node fails readiness but remains live", %{conn: conn} do
|
||||
on_exit(&WhoNeedHelpWeb.DrainState.reset/0)
|
||||
assert :ok = WhoNeedHelpWeb.DrainState.begin_drain()
|
||||
|
||||
assert %{"status" => "draining"} =
|
||||
conn
|
||||
|> get(~p"/healthz/ready")
|
||||
|> json_response(503)
|
||||
|
||||
assert %{"status" => "ok"} =
|
||||
conn
|
||||
|> recycle()
|
||||
|> get(~p"/healthz/live")
|
||||
|> json_response(200)
|
||||
end
|
||||
|
||||
test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do
|
||||
conn = get(conn, ~p"/safety?locale=uk")
|
||||
assert html_response(conn, 200) =~ "Правила безпеки"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user