Prepare isolated environment release workflow
This commit is contained in:
parent
f18e76b201
commit
777bd779ef
13
README.md
13
README.md
|
|
@ -209,7 +209,7 @@ load project and then run:
|
||||||
```
|
```
|
||||||
|
|
||||||
The command generates MinIO and Restic secrets only in ignored mode-`0600`
|
The command generates MinIO and Restic secrets only in ignored mode-`0600`
|
||||||
`.env.load`, streams `pg_dump` directly into an encrypted Restic repository,
|
`output/runtime/load.env`, streams `pg_dump` directly into an encrypted Restic repository,
|
||||||
restores it into a new temporary database, checks corruption and interruption
|
restores it into a new temporary database, checks corruption and interruption
|
||||||
failure paths, removes those temporary buckets, and retains the successful
|
failure paths, removes those temporary buckets, and retains the successful
|
||||||
encrypted bucket in local MinIO. It never writes a plaintext dump to the host.
|
encrypted bucket in local MinIO. It never writes a plaintext dump to the host.
|
||||||
|
|
@ -460,8 +460,8 @@ two worker replicas:
|
||||||
./scripts/e2e-run.sh
|
./scripts/e2e-run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
On its first run it generates `.env.e2e` with independent random local secrets
|
On its first run it generates `output/runtime/e2e.env` with independent random
|
||||||
and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint;
|
local secrets and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint;
|
||||||
Playwright accepts only that one-run proxy's generated certificate. E2E-only
|
Playwright accepts only that one-run proxy's generated certificate. E2E-only
|
||||||
fixture and failure-injection routes are enabled by a compile-time flag that is
|
fixture and failure-injection routes are enabled by a compile-time flag that is
|
||||||
disabled in the ordinary production image. The suite registers users through
|
disabled in the ordinary production image. The suite registers users through
|
||||||
|
|
@ -499,8 +499,8 @@ probe or the complete minimum/current API 24/30/34/37 matrix:
|
||||||
./scripts/android-matrix-test.sh
|
./scripts/android-matrix-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
On first run it generates the ignored `.env.android-test` with a randomized
|
On first run it generates ignored `output/runtime/android-test.env` with a
|
||||||
device-loopback origin and mode `0600`. The suite covers denied and granted
|
randomized device-loopback origin and mode `0600`. The suite covers denied and granted
|
||||||
location permission, same-origin deep links, Activity recreation, foreground
|
location permission, same-origin deep links, Activity recreation, foreground
|
||||||
location upload while the Activity is backgrounded and destroyed, the
|
location upload while the Activity is backgrounded and destroyed, the
|
||||||
persistent notification Stop action, a disconnected Stop request with visible
|
persistent notification Stop action, a disconnected Stop request with visible
|
||||||
|
|
@ -628,7 +628,8 @@ Grafana datasource and dashboard, discovers each current web container as a
|
||||||
separate target, and exercises a firing/resolved alert by stopping and
|
separate target, and exercises a firing/resolved alert by stopping and
|
||||||
recovering exactly one verified load replica. It prints the loopback-only
|
recovering exactly one verified load replica. It prints the loopback-only
|
||||||
random ports and retains non-secret evidence below `output/observability/`.
|
random ports and retains non-secret evidence below `output/observability/`.
|
||||||
The generated Grafana password remains only in mode-`0600` `.env.load`.
|
The generated Grafana password remains only in mode-`0600`
|
||||||
|
`output/runtime/load.env`.
|
||||||
|
|
||||||
Stop only the monitoring services while leaving their local metric volumes and
|
Stop only the monitoring services while leaving their local metric volumes and
|
||||||
the load application running:
|
the load application running:
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ source in the Phoenix environment; never put that private JSON in the Android
|
||||||
build.
|
build.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh
|
./scripts/android-release-build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The build passes the private files with Docker BuildKit secret mounts, runs
|
The build passes the private files with Docker BuildKit secret mounts, runs
|
||||||
|
|
@ -109,11 +109,12 @@ from the existing `PHX_HOST`, `PHX_SCHEME`, and `PHX_URL_PORT`, then build:
|
||||||
sha256sum android/dist-staging/who-need-help-staging.apk
|
sha256sum android/dist-staging/who-need-help-staging.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
This variant uses Android's generic debug signing key so it can be installed
|
This variant uses the dedicated stable staging key below
|
||||||
for staging verification. It is not a production-signed artifact and must not
|
`~/.config/who_need_help/android-staging/`. It is not the production upload
|
||||||
be published as a release. The manifest accepts same-origin HTTPS deep links,
|
identity and must not be published as a production release. The manifest
|
||||||
but verified Android App Links additionally require the final signing
|
accepts same-origin HTTPS deep links, while verified Android App Links require
|
||||||
certificate fingerprint in the deployment's `/.well-known/assetlinks.json`.
|
this staging certificate fingerprint in the dev deployment's
|
||||||
|
`/.well-known/assetlinks.json`.
|
||||||
|
|
||||||
With the temporary public origin reachable, run the API 37 emulator smoke test:
|
With the temporary public origin reachable, run the API 37 emulator smoke test:
|
||||||
|
|
||||||
|
|
@ -151,10 +152,11 @@ repository root:
|
||||||
./scripts/android-matrix-test.sh
|
./scripts/android-matrix-test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The command requires `/dev/kvm`. It generates an ignored
|
The command requires `/dev/kvm`. It generates ignored
|
||||||
`.env.android-test` once with a randomized `http://127.0.0.1:PORT` origin and
|
`output/runtime/android-test.env` once with a randomized
|
||||||
mode `0600`; the application and its in-process fixture server both derive the
|
`http://127.0.0.1:PORT` origin and mode `0600`; the application and its
|
||||||
origin from that file. It then builds both APKs, boots a fresh selected emulator
|
in-process fixture server both derive the origin from that file. It then
|
||||||
|
builds both APKs, boots a fresh selected emulator
|
||||||
container without external networking, injects emulator coordinates, and runs
|
container without external networking, injects emulator coordinates, and runs
|
||||||
`AndroidJUnitRunner`. `WNH_ANDROID_TEST_API` selects one supported API, while
|
`AndroidJUnitRunner`. `WNH_ANDROID_TEST_API` selects one supported API, while
|
||||||
`WNH_ANDROID_TEST_API_MATRIX` controls the matrix command.
|
`WNH_ANDROID_TEST_API_MATRIX` controls the matrix command.
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,20 @@ connections, Docker volumes, public aliases, Google OAuth clients, email
|
||||||
delivery paths, and generated secrets. Oban queues are isolated by those
|
delivery paths, and generated secrets. Oban queues are isolated by those
|
||||||
different PostgreSQL databases. There is no Redis dependency.
|
different PostgreSQL databases. There is no Redis dependency.
|
||||||
|
|
||||||
|
Generated harness state is not a deployment environment. E2E, load, and
|
||||||
|
Android instrumentation scripts keep their random local inputs below the
|
||||||
|
ignored mode-`0700` `output/runtime/` directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
output/runtime/e2e.env
|
||||||
|
output/runtime/load.env
|
||||||
|
output/runtime/android-test.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Those files are generated automatically, never copied to a server, and do not
|
||||||
|
represent dev, test, or production. The one ignored `.env` at each checkout
|
||||||
|
root remains the only application/deployment configuration.
|
||||||
|
|
||||||
The shared Caddy edge is owned only by the production checkout and reads the
|
The shared Caddy edge is owned only by the production checkout and reads the
|
||||||
same production `.env`; it is not a third project directory or a second secret
|
same production `.env`; it is not a third project directory or a second secret
|
||||||
file. Both applications intentionally share only the external
|
file. Both applications intentionally share only the external
|
||||||
|
|
@ -116,6 +130,41 @@ to the comma-separated App Links value; the upload certificate alone does not
|
||||||
describe Play-delivered APKs. The production environment validator accepts
|
describe Play-delivered APKs. The production environment validator accepts
|
||||||
multiple SHA-256 fingerprints and rejects partial or malformed configuration.
|
multiple SHA-256 fingerprints and rejects partial or malformed configuration.
|
||||||
|
|
||||||
|
### Release capability inputs
|
||||||
|
|
||||||
|
Each environment owns distinct external-provider credentials. Seed a new
|
||||||
|
production `.env` with `scripts/init-production-env.sh` and the corresponding
|
||||||
|
`PRODUCTION_*` process variables; use `TEST_*` only when creating the separate
|
||||||
|
test checkout. The generated file uses the ordinary runtime names:
|
||||||
|
|
||||||
|
| Capability | Values kept in that checkout's `.env` |
|
||||||
|
| --- | --- |
|
||||||
|
| Google sign-in | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` |
|
||||||
|
| Browser push | three `WEB_PUSH_VAPID_*` values |
|
||||||
|
| Android Firebase client | four public `WNH_FIREBASE_*` values |
|
||||||
|
| Android delivery | `FCM_PROJECT_ID` and one private service-account source |
|
||||||
|
| Verified Android links | `ANDROID_APP_LINKS_PACKAGE_NAME`, `ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS` |
|
||||||
|
| Transactional email | `SMTP_*`, sender, and `SUPPORT_INBOX_ADDRESS` |
|
||||||
|
|
||||||
|
Do not reuse a Google client, VAPID private key, Firebase project/service
|
||||||
|
account, SMTP credential, or Android signing key between dev and production.
|
||||||
|
The public Firebase Android values are build configuration; the Base64 FCM
|
||||||
|
service-account JSON is a server secret and must never be passed into the
|
||||||
|
Android build.
|
||||||
|
|
||||||
|
Check an environment without printing its secret values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check-environment-readiness.sh .env
|
||||||
|
./scripts/check-environment-readiness.sh .env --require-release
|
||||||
|
```
|
||||||
|
|
||||||
|
The first command reports incomplete or local-only capabilities. The second is
|
||||||
|
a blocking release preflight and exits nonzero until Google sign-in, external
|
||||||
|
SMTP, browser Web Push, Android Firebase/FCM, App Links, support routing,
|
||||||
|
Android version/signing aliases, and the core application configuration are
|
||||||
|
all complete.
|
||||||
|
|
||||||
Create the test configuration inside the test checkout:
|
Create the test configuration inside the test checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -349,7 +398,7 @@ The isolated load project can run a complete encrypted Restic/MinIO drill:
|
||||||
The script refuses the staging Compose project and validates the project and
|
The script refuses the staging Compose project and validates the project and
|
||||||
service labels of every pre-existing container in its scope. On first use,
|
service labels of every pre-existing container in its scope. On first use,
|
||||||
`scripts/ensure-local-load-env.sh` generates independent random MinIO and
|
`scripts/ensure-local-load-env.sh` generates independent random MinIO and
|
||||||
Restic credentials in ignored `.env.load` and restricts that file to mode
|
Restic credentials in ignored `output/runtime/load.env` and restricts that file to mode
|
||||||
`0600`. MinIO publishes Docker-assigned ports only on `127.0.0.1`; the observed
|
`0600`. MinIO publishes Docker-assigned ports only on `127.0.0.1`; the observed
|
||||||
API and console URLs are printed after a successful run.
|
API and console URLs are printed after a successful run.
|
||||||
|
|
||||||
|
|
@ -493,7 +542,7 @@ against an isolated restored copy:
|
||||||
|
|
||||||
The rehearsal validates the checksum and archive catalog, reads the public
|
The rehearsal validates the checksum and archive catalog, reads the public
|
||||||
origin configuration from ignored `.env`, and uses only the independently
|
origin configuration from ignored `.env`, and uses only the independently
|
||||||
generated credentials in ignored mode-`0600` `.env.e2e`. It builds a uniquely
|
generated credentials in ignored mode-`0600` `output/runtime/e2e.env`. It builds a uniquely
|
||||||
tagged production release, creates a uniquely named Compose project and
|
tagged production release, creates a uniquely named Compose project and
|
||||||
database from `template0`, restores the archive, records application-table
|
database from `template0`, restores the archive, records application-table
|
||||||
counts, and then:
|
counts, and then:
|
||||||
|
|
@ -720,7 +769,7 @@ rewrite while preserving the target's own `instance` label.
|
||||||
Prometheus, Alertmanager, and Grafana are pinned by tag and digest. Their host
|
Prometheus, Alertmanager, and Grafana are pinned by tag and digest. Their host
|
||||||
ports default to Docker-assigned values bound only to `127.0.0.1`; the run
|
ports default to Docker-assigned values bound only to `127.0.0.1`; the run
|
||||||
prints the observed URLs. Grafana uses the random admin password generated in
|
prints the observed URLs. Grafana uses the random admin password generated in
|
||||||
ignored `.env.load`, disables anonymous signup, update checks, suggested plugin
|
ignored `output/runtime/load.env`, disables anonymous signup, update checks, suggested plugin
|
||||||
installation, and its unused built-in alert engine. The Prometheus datasource
|
installation, and its unused built-in alert engine. The Prometheus datasource
|
||||||
and ten-panel dashboard are provisioned from tracked files. The dashboard
|
and ten-panel dashboard are provisioned from tracked files. The dashboard
|
||||||
separates all discovered web and worker replicas and includes HTTP
|
separates all discovered web and worker replicas and includes HTTP
|
||||||
|
|
@ -747,6 +796,54 @@ destinations, production availability, and measured alert policies remain
|
||||||
deployment decisions. In Kubernetes, put the metrics token in
|
deployment decisions. In Kubernetes, put the metrics token in
|
||||||
`existingSecret`; configure the external scraper to send it as a Bearer token.
|
`existingSecret`; configure the external scraper to send it as a Bearer token.
|
||||||
|
|
||||||
|
## Production release without pushing the frozen repository
|
||||||
|
|
||||||
|
The post-submission workflow keeps the public Git repository and
|
||||||
|
`test.whoneedhelp.com` untouched. A clean local commit is packaged as a
|
||||||
|
verified Git bundle and transferred directly over SSH to only
|
||||||
|
`/srv/who_need_help-production`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/production-release.sh plan whoneedhelp
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `plan` action is read-only. It verifies the exact local and remote
|
||||||
|
commits, requires a fast-forward history, checks the production checkout,
|
||||||
|
Compose scope and healthy containers, checks public readiness, opens a
|
||||||
|
read-only PostgreSQL connection, and runs the complete environment capability
|
||||||
|
preflight. It neither uploads a bundle nor creates a backup.
|
||||||
|
|
||||||
|
After reviewing the exact commit printed by the plan, execution additionally
|
||||||
|
requires an explicit per-commit confirmation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WNH_PRODUCTION_RELEASE_CONFIRM=whoneedhelp.com:FULL_COMMIT \
|
||||||
|
./scripts/production-release.sh apply whoneedhelp
|
||||||
|
```
|
||||||
|
|
||||||
|
The apply path refuses tracked local or remote modifications. It then:
|
||||||
|
|
||||||
|
1. creates and verifies a full Git bundle for exactly that clean commit;
|
||||||
|
2. uploads only that bundle to the production checkout's ignored
|
||||||
|
`output/releases/`;
|
||||||
|
3. creates a custom-format PostgreSQL 18 backup without exposing the database
|
||||||
|
password in process arguments;
|
||||||
|
4. verifies its archive catalog and SHA-256, then copies and verifies the
|
||||||
|
backup again under local ignored `output/production-backups/`;
|
||||||
|
5. fast-forwards the production checkout without accessing or changing the
|
||||||
|
test checkout or public remote;
|
||||||
|
6. selects immutable per-commit image tags, applies migrations, starts the
|
||||||
|
application and edge, and verifies the public readiness and Android App
|
||||||
|
Links endpoints.
|
||||||
|
|
||||||
|
If application startup fails after the new image tags are selected, the script
|
||||||
|
restores the previous immutable application and Caddy image tags and attempts
|
||||||
|
to recover public readiness. It deliberately does not reverse Git source or
|
||||||
|
Ecto migrations automatically. The per-release rollback manifest and backup
|
||||||
|
paths are recorded below the production checkout's ignored `output/releases/`.
|
||||||
|
The copied backup is separate from the production host, but a long-term
|
||||||
|
encrypted off-site backup destination remains an operational requirement.
|
||||||
|
|
||||||
## Rollback boundary
|
## Rollback boundary
|
||||||
|
|
||||||
The release image is immutable and migrations run as a separate one-shot role.
|
The release image is immutable and migrations run as a separate one-shot role.
|
||||||
|
|
|
||||||
|
|
@ -104,19 +104,19 @@ wrapper, it deliberately keeps the isolated database volume between commands.
|
||||||
./scripts/load-stack-up.sh
|
./scripts/load-stack-up.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated `.env.load` is ignored, restricted to mode 600, and contains
|
The generated `output/runtime/load.env` is ignored, restricted to mode 600,
|
||||||
independent PostgreSQL and application secrets. Edit its `LOAD_*` inputs to
|
and contains independent PostgreSQL and application secrets. Edit its `LOAD_*` inputs to
|
||||||
define a specific experiment. Values in `.env.load.example` are reproducible
|
define a specific experiment. Values in `.env.load.example` are reproducible
|
||||||
measurement points, not recommendations.
|
measurement points, not recommendations.
|
||||||
|
|
||||||
For a one-run duration that must not rewrite `.env.load`, pass an explicit
|
For a one-run duration that must not rewrite `output/runtime/load.env`, pass an explicit
|
||||||
experiment override:
|
experiment override:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
LOAD_DURATION_OVERRIDE=10m ./scripts/load-run.sh local-soak-YYYYMMDD
|
LOAD_DURATION_OVERRIDE=10m ./scripts/load-run.sh local-soak-YYYYMMDD
|
||||||
```
|
```
|
||||||
|
|
||||||
The effective value and whether it came from `.env.load` or the override are
|
The effective value and whether it came from `output/runtime/load.env` or the override are
|
||||||
recorded in that run's `environment.txt`.
|
recorded in that run's `environment.txt`.
|
||||||
|
|
||||||
## Run and compare replica counts
|
## Run and compare replica counts
|
||||||
|
|
@ -124,7 +124,7 @@ recorded in that run's `environment.txt`.
|
||||||
```sh
|
```sh
|
||||||
./scripts/load-run.sh two-web
|
./scripts/load-run.sh two-web
|
||||||
./scripts/load-stack-up.sh 3
|
./scripts/load-stack-up.sh 3
|
||||||
# Set LOAD_WEB_REPLICAS=3 in .env.load so the recorded expected topology
|
# Set LOAD_WEB_REPLICAS=3 in output/runtime/load.env so the recorded expected topology
|
||||||
# matches the running topology, then:
|
# matches the running topology, then:
|
||||||
./scripts/load-run.sh three-web
|
./scripts/load-run.sh three-web
|
||||||
```
|
```
|
||||||
|
|
@ -162,7 +162,7 @@ contexts. The pair count equals the maximum simultaneous VU count across all
|
||||||
scenarios, because k6 may reuse its global VU pool between parallel scenarios;
|
scenarios, because k6 may reuse its global VU pool between parallel scenarios;
|
||||||
mapping each global VU identifier directly to its own pair prevents concurrent
|
mapping each global VU identifier directly to its own pair prevents concurrent
|
||||||
users from sharing a tracking assignment. The random fixture password exists
|
users from sharing a tracking assignment. The random fixture password exists
|
||||||
only in ignored mode-600 `.env.load` and is never written to the manifest or
|
only in ignored mode-600 `output/runtime/load.env` and is never written to the manifest or
|
||||||
console.
|
console.
|
||||||
|
|
||||||
On success and on trapped failure, cleanup deletes only the UUIDs recorded in
|
On success and on trapped failure, cleanup deletes only the UUIDs recorded in
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ANDROID_ENV="$ROOT/.env"
|
ANDROID_ENV="$ROOT/.env"
|
||||||
TEST_ENV="$ROOT/.env.android-test"
|
TEST_ENV=${WNH_ANDROID_TEST_ENV_FILE:-"$ROOT/output/runtime/android-test.env"}
|
||||||
run_id=$(date -u +%Y%m%d%H%M%S)-$$
|
run_id=$(date -u +%Y%m%d%H%M%S)-$$
|
||||||
android_api=${WNH_ANDROID_TEST_API:-37.0}
|
android_api=${WNH_ANDROID_TEST_API:-37.0}
|
||||||
# 1G is measured against the API 30/34/37 suite and is also exercised by the
|
# 1G is measured against the API 30/34/37 suite and is also exercised by the
|
||||||
|
|
@ -63,7 +63,7 @@ set -a
|
||||||
. "$TEST_ENV"
|
. "$TEST_ENV"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
: "${WNH_ANDROID_TEST_BASE_URL:?Set WNH_ANDROID_TEST_BASE_URL in .env.android-test}"
|
: "${WNH_ANDROID_TEST_BASE_URL:?Generate Android test runtime state with scripts/ensure-local-android-test-env.sh}"
|
||||||
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}"
|
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}"
|
||||||
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}"
|
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}"
|
||||||
|
|
||||||
|
|
|
||||||
245
scripts/backup-external-postgres.sh
Executable file
245
scripts/backup-external-postgres.sh
Executable file
|
|
@ -0,0 +1,245 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
env_file=${1:-"$ROOT/.env"}
|
||||||
|
target=${2:-}
|
||||||
|
expected_environment=${3:-}
|
||||||
|
|
||||||
|
if [[ "$env_file" != /* ]]; then
|
||||||
|
env_file="$ROOT/$env_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$env_file" ]]; then
|
||||||
|
echo "Environment file does not exist: $env_file" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
|
||||||
|
echo "Environment file must have mode 0600: $env_file" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_value() {
|
||||||
|
local key=$1
|
||||||
|
awk -v key="$key" '
|
||||||
|
index($0, key "=") == 1 {
|
||||||
|
print substr($0, length(key) + 2)
|
||||||
|
found = 1
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
END { if (!found) exit 1 }
|
||||||
|
' "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment_environment=$(read_value DEPLOYMENT_ENV)
|
||||||
|
compose_project=$(read_value COMPOSE_PROJECT_NAME)
|
||||||
|
database_mode=$(read_value DATABASE_MODE)
|
||||||
|
|
||||||
|
if [[ "$database_mode" != "external" ]]; then
|
||||||
|
echo "backup-external-postgres.sh requires DATABASE_MODE=external." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$expected_environment" &&
|
||||||
|
"$deployment_environment" != "$expected_environment" ]]; then
|
||||||
|
echo "Environment mismatch: expected $expected_environment." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
for command in awk pg_dump pg_restore psql python3 sha256sum stat; do
|
||||||
|
command -v "$command" >/dev/null 2>&1 || {
|
||||||
|
echo "Required command is unavailable: $command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
mapfile -d '' -t connection_parts < <(
|
||||||
|
python3 - "$env_file" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
|
values = {}
|
||||||
|
for line in Path(sys.argv[1]).read_text().splitlines():
|
||||||
|
if "=" in line and not line.startswith("#"):
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values.setdefault(key, value)
|
||||||
|
|
||||||
|
raw_url = values.get("DATABASE_URL", "")
|
||||||
|
if raw_url.startswith("ecto://"):
|
||||||
|
raw_url = "postgresql://" + raw_url[len("ecto://"):]
|
||||||
|
|
||||||
|
parsed = urlsplit(raw_url)
|
||||||
|
required = {
|
||||||
|
"host": parsed.hostname,
|
||||||
|
"database": parsed.path.lstrip("/"),
|
||||||
|
"username": parsed.username,
|
||||||
|
"password": parsed.password,
|
||||||
|
}
|
||||||
|
missing = [name for name, value in required.items() if not value]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("DATABASE_URL is missing: " + ", ".join(missing))
|
||||||
|
|
||||||
|
parts = (
|
||||||
|
parsed.hostname,
|
||||||
|
str(parsed.port or 5432),
|
||||||
|
unquote(parsed.path.lstrip("/")),
|
||||||
|
unquote(parsed.username),
|
||||||
|
unquote(parsed.password),
|
||||||
|
values.get("DATABASE_SOCKET_DIR", ""),
|
||||||
|
)
|
||||||
|
sys.stdout.buffer.write(b"\0".join(part.encode() for part in parts) + b"\0")
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ${#connection_parts[@]} -ne 6 ]]; then
|
||||||
|
echo "Could not parse the external PostgreSQL connection without exposing it." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
database_host=${connection_parts[0]}
|
||||||
|
database_port=${connection_parts[1]}
|
||||||
|
database_name=${connection_parts[2]}
|
||||||
|
database_user=${connection_parts[3]}
|
||||||
|
database_password=${connection_parts[4]}
|
||||||
|
database_socket_dir=${connection_parts[5]}
|
||||||
|
connection_host=${database_socket_dir:-$database_host}
|
||||||
|
|
||||||
|
escape_pgpass() {
|
||||||
|
local escaped=$1
|
||||||
|
escaped=${escaped//\\/\\\\}
|
||||||
|
escaped=${escaped//:/\\:}
|
||||||
|
printf '%s' "$escaped"
|
||||||
|
}
|
||||||
|
|
||||||
|
pgpass_file=$(mktemp "${TMPDIR:-/tmp}/who-need-help-pgpass.XXXXXX")
|
||||||
|
partial=
|
||||||
|
checksum_partial=
|
||||||
|
metadata_partial=
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$pgpass_file"
|
||||||
|
[[ -z "$partial" ]] || rm -f "$partial"
|
||||||
|
[[ -z "$checksum_partial" ]] || rm -f "$checksum_partial"
|
||||||
|
[[ -z "$metadata_partial" ]] || rm -f "$metadata_partial"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
chmod 600 "$pgpass_file"
|
||||||
|
printf '%s:%s:%s:%s:%s\n' \
|
||||||
|
"$(escape_pgpass "$database_host")" \
|
||||||
|
"$(escape_pgpass "$database_port")" \
|
||||||
|
"$(escape_pgpass "$database_name")" \
|
||||||
|
"$(escape_pgpass "$database_user")" \
|
||||||
|
"$(escape_pgpass "$database_password")" >"$pgpass_file"
|
||||||
|
|
||||||
|
psql_args=(
|
||||||
|
--host "$connection_host"
|
||||||
|
--port "$database_port"
|
||||||
|
--username "$database_user"
|
||||||
|
--dbname "$database_name"
|
||||||
|
--no-password
|
||||||
|
--no-psqlrc
|
||||||
|
--tuples-only
|
||||||
|
--no-align
|
||||||
|
--set ON_ERROR_STOP=1
|
||||||
|
)
|
||||||
|
|
||||||
|
server_version=$(
|
||||||
|
PGPASSFILE="$pgpass_file" PGOPTIONS="-c default_transaction_read_only=on" \
|
||||||
|
psql "${psql_args[@]}" --command="show server_version"
|
||||||
|
)
|
||||||
|
server_database=$(
|
||||||
|
PGPASSFILE="$pgpass_file" PGOPTIONS="-c default_transaction_read_only=on" \
|
||||||
|
psql "${psql_args[@]}" --command="select current_database()"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$server_database" != "$database_name" ]]; then
|
||||||
|
echo "PostgreSQL connected to an unexpected database." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Environment: %s\n' "$deployment_environment"
|
||||||
|
printf 'Compose project: %s\n' "$compose_project"
|
||||||
|
printf 'Database mode: external\n'
|
||||||
|
printf 'Database endpoint: %s:%s/%s\n' "$connection_host" "$database_port" "$database_name"
|
||||||
|
printf 'PostgreSQL server: %s\n' "$server_version"
|
||||||
|
printf 'PostgreSQL client: %s\n' "$(pg_dump --version)"
|
||||||
|
|
||||||
|
if [[ "$target" == "--check-only" ]]; then
|
||||||
|
echo "Read-only external PostgreSQL connection check passed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
echo "Usage: $0 ENV_FILE OUTPUT_DUMP [EXPECTED_ENVIRONMENT]" >&2
|
||||||
|
echo " $0 ENV_FILE --check-only [EXPECTED_ENVIRONMENT]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$target" != /* ]]; then
|
||||||
|
target="$ROOT/$target"
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_dir=$(dirname -- "$target")
|
||||||
|
target_name=$(basename -- "$target")
|
||||||
|
checksum="$target.sha256"
|
||||||
|
metadata="$target.metadata"
|
||||||
|
|
||||||
|
if [[ -e "$target" || -e "$checksum" || -e "$metadata" ]]; then
|
||||||
|
echo "Refusing to overwrite an existing backup, checksum, or metadata file." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$target_dir"
|
||||||
|
chmod 700 "$target_dir"
|
||||||
|
|
||||||
|
partial="$target.partial.$$"
|
||||||
|
checksum_partial="$checksum.partial.$$"
|
||||||
|
metadata_partial="$metadata.partial.$$"
|
||||||
|
|
||||||
|
printf 'Backup target: %s\n' "$target"
|
||||||
|
echo "The source database is read only; the command will create one custom-format dump plus checksum and non-secret metadata."
|
||||||
|
|
||||||
|
PGPASSFILE="$pgpass_file" pg_dump \
|
||||||
|
--host "$connection_host" \
|
||||||
|
--port "$database_port" \
|
||||||
|
--username "$database_user" \
|
||||||
|
--dbname "$database_name" \
|
||||||
|
--no-password \
|
||||||
|
--format custom \
|
||||||
|
--no-owner \
|
||||||
|
--no-acl \
|
||||||
|
--file "$partial"
|
||||||
|
|
||||||
|
[[ -s "$partial" ]] || {
|
||||||
|
echo "PostgreSQL produced an empty backup." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_restore --list "$partial" >/dev/null
|
||||||
|
hash=$(sha256sum "$partial" | awk '{print $1}')
|
||||||
|
printf '%s %s\n' "$hash" "$target_name" >"$checksum_partial"
|
||||||
|
{
|
||||||
|
printf 'deployment_environment=%s\n' "$deployment_environment"
|
||||||
|
printf 'compose_project=%s\n' "$compose_project"
|
||||||
|
printf 'database_name=%s\n' "$database_name"
|
||||||
|
printf 'server_version=%s\n' "$server_version"
|
||||||
|
printf 'client_version=%s\n' "$(pg_dump --version)"
|
||||||
|
printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
printf 'sha256=%s\n' "$hash"
|
||||||
|
} >"$metadata_partial"
|
||||||
|
|
||||||
|
chmod 600 "$partial" "$checksum_partial" "$metadata_partial"
|
||||||
|
sync -f "$partial" "$checksum_partial" "$metadata_partial"
|
||||||
|
mv "$partial" "$target"
|
||||||
|
mv "$checksum_partial" "$checksum"
|
||||||
|
mv "$metadata_partial" "$metadata"
|
||||||
|
partial=
|
||||||
|
checksum_partial=
|
||||||
|
metadata_partial=
|
||||||
|
|
||||||
|
echo "External PostgreSQL backup catalog and checksum passed verification."
|
||||||
|
printf 'Backup: %s\nChecksum: %s\nMetadata: %s\n' "$target" "$checksum" "$metadata"
|
||||||
|
|
@ -3,7 +3,7 @@ set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE="$ROOT/.env.load"
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LABEL=${1:-"backup-$(date -u +%Y%m%d%H%M%S)"}
|
LABEL=${1:-"backup-$(date -u +%Y%m%d%H%M%S)"}
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
|
@ -22,7 +22,7 @@ for name in LOAD_PROJECT POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD DATABASE_UR
|
||||||
BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \
|
BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \
|
||||||
BACKUP_INTERRUPTION_CHUNK_BYTES BACKUP_INTERRUPTION_INTERVAL_SECONDS; do
|
BACKUP_INTERRUPTION_CHUNK_BYTES BACKUP_INTERRUPTION_INTERVAL_SECONDS; do
|
||||||
if [[ -z "${!name:-}" ]]; then
|
if [[ -z "${!name:-}" ]]; then
|
||||||
echo "$name is missing from .env.load" >&2
|
echo "$name is missing from the generated load runtime environment" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
213
scripts/check-environment-readiness.sh
Executable file
213
scripts/check-environment-readiness.sh
Executable file
|
|
@ -0,0 +1,213 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
env_file=${1:-"$ROOT/.env"}
|
||||||
|
mode=${2:-}
|
||||||
|
|
||||||
|
if [[ "$env_file" != /* ]]; then
|
||||||
|
env_file="$ROOT/$env_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" != "" && "$mode" != "--require-release" ]]; then
|
||||||
|
echo "Usage: $0 [ENV_FILE] [--require-release]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$env_file" ]]; then
|
||||||
|
echo "Environment file does not exist: $env_file" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
|
||||||
|
echo "Environment file must have mode 0600: $env_file" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_value() {
|
||||||
|
local key=$1
|
||||||
|
|
||||||
|
awk -v key="$key" '
|
||||||
|
index($0, key "=") == 1 {
|
||||||
|
value = substr($0, length(key) + 2)
|
||||||
|
if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) {
|
||||||
|
value = substr(value, 2, length(value) - 2)
|
||||||
|
}
|
||||||
|
print value
|
||||||
|
found = 1
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
END { if (!found) exit 1 }
|
||||||
|
' "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
value() {
|
||||||
|
read_value "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
is_set() {
|
||||||
|
[[ -n "$(value "$1")" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
all_set() {
|
||||||
|
local key
|
||||||
|
for key in "$@"; do
|
||||||
|
is_set "$key" || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
all_empty() {
|
||||||
|
local key
|
||||||
|
for key in "$@"; do
|
||||||
|
is_set "$key" && return 1
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
contains_template_marker() {
|
||||||
|
local observed=$1
|
||||||
|
[[ "$observed" == *REPLACE* || "$observed" == *GENERATE* ||
|
||||||
|
"$observed" == *example.com* || "$observed" == *example.invalid* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
warnings=0
|
||||||
|
|
||||||
|
ready() {
|
||||||
|
printf 'READY %-24s %s\n' "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
local_only() {
|
||||||
|
printf 'LOCAL_ONLY %-24s %s\n' "$1" "$2"
|
||||||
|
warnings=$((warnings + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
missing() {
|
||||||
|
printf 'MISSING %-24s %s\n' "$1" "$2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid() {
|
||||||
|
printf 'INVALID %-24s %s\n' "$1" "$2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
partial() {
|
||||||
|
printf 'PARTIAL %-24s %s\n' "$1" "$2"
|
||||||
|
failures=$((failures + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment_env=$(value DEPLOYMENT_ENV)
|
||||||
|
phx_host=$(value PHX_HOST)
|
||||||
|
phx_scheme=$(value PHX_SCHEME)
|
||||||
|
phx_port=$(value PHX_URL_PORT)
|
||||||
|
base_url=$(value WNH_BASE_URL)
|
||||||
|
debug_base_url=$(value WNH_DEBUG_BASE_URL)
|
||||||
|
|
||||||
|
if all_set DEPLOYMENT_ENV PHX_HOST PHX_SCHEME PHX_URL_PORT WNH_BASE_URL WNH_DEBUG_BASE_URL &&
|
||||||
|
[[ "$base_url" == "$debug_base_url" ]] &&
|
||||||
|
[[ "$base_url" == "$phx_scheme://$phx_host" ||
|
||||||
|
"$base_url" == "$phx_scheme://$phx_host:$phx_port" ]] &&
|
||||||
|
! contains_template_marker "$base_url"; then
|
||||||
|
ready "public origin" "deployment=$deployment_env; one canonical Android/web origin"
|
||||||
|
else
|
||||||
|
invalid "public origin" "DEPLOYMENT_ENV/PHX_*/WNH_*_BASE_URL are incomplete or inconsistent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if all_set SECRET_KEY_BASE HANDOVER_SECRET RELEASE_COOKIE METRICS_TOKEN; then
|
||||||
|
ready "application secrets" "four required independent values are present"
|
||||||
|
else
|
||||||
|
missing "application secrets" "SECRET_KEY_BASE, HANDOVER_SECRET, RELEASE_COOKIE, METRICS_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
smtp_relay=$(value SMTP_RELAY)
|
||||||
|
email_delivery_provider=$(value EMAIL_DELIVERY_PROVIDER)
|
||||||
|
email_delivery_provider=${email_delivery_provider:-smtp}
|
||||||
|
if [[ "$email_delivery_provider" != "smtp" ]]; then
|
||||||
|
invalid "transactional email" "EMAIL_DELIVERY_PROVIDER must be smtp"
|
||||||
|
elif ! all_set SMTP_RELAY SMTP_PORT SMTP_AUTH SMTP_TLS SMTP_SSL EMAIL_FROM_ADDRESS; then
|
||||||
|
missing "transactional email" "SMTP transport and sender fields"
|
||||||
|
elif [[ "$smtp_relay" == "mailpit" ]]; then
|
||||||
|
local_only "transactional email" "Mailpit captures messages locally; it cannot deliver public email"
|
||||||
|
elif [[ "$(value SMTP_AUTH)" == "always" ]] && ! all_set SMTP_USERNAME SMTP_PASSWORD; then
|
||||||
|
partial "transactional email" "authenticated SMTP requires both username and password"
|
||||||
|
else
|
||||||
|
ready "transactional email" "external SMTP transport is configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_set SUPPORT_INBOX_ADDRESS; then
|
||||||
|
ready "support inbox" "operator destination is configured"
|
||||||
|
else
|
||||||
|
missing "support inbox" "SUPPORT_INBOX_ADDRESS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if all_empty GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then
|
||||||
|
missing "Google sign-in" "GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET"
|
||||||
|
elif all_set GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET; then
|
||||||
|
ready "Google sign-in" "client ID and secret are both configured"
|
||||||
|
else
|
||||||
|
partial "Google sign-in" "client ID and secret must be configured together"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if all_empty WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then
|
||||||
|
missing "browser Web Push" "VAPID public/private keys and subject"
|
||||||
|
elif all_set WEB_PUSH_VAPID_PUBLIC_KEY WEB_PUSH_VAPID_PRIVATE_KEY WEB_PUSH_VAPID_SUBJECT; then
|
||||||
|
case "$(value WEB_PUSH_VAPID_SUBJECT)" in
|
||||||
|
mailto:* | https://*) ready "browser Web Push" "complete VAPID configuration" ;;
|
||||||
|
*) invalid "browser Web Push" "WEB_PUSH_VAPID_SUBJECT must use mailto: or https://" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
partial "browser Web Push" "all three VAPID values are required together"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if all_empty WNH_FIREBASE_APPLICATION_ID WNH_FIREBASE_API_KEY \
|
||||||
|
WNH_FIREBASE_PROJECT_ID WNH_FIREBASE_GCM_SENDER_ID; then
|
||||||
|
missing "Android Firebase client" "four WNH_FIREBASE_* Android client values"
|
||||||
|
elif all_set WNH_FIREBASE_APPLICATION_ID WNH_FIREBASE_API_KEY \
|
||||||
|
WNH_FIREBASE_PROJECT_ID WNH_FIREBASE_GCM_SENDER_ID; then
|
||||||
|
ready "Android Firebase client" "complete client configuration"
|
||||||
|
else
|
||||||
|
partial "Android Firebase client" "all four WNH_FIREBASE_* values are required together"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fcm_file=$(value FCM_SERVICE_ACCOUNT_FILE)
|
||||||
|
fcm_base64=$(value FCM_SERVICE_ACCOUNT_JSON_BASE64)
|
||||||
|
if [[ -z "$(value FCM_PROJECT_ID)" && -z "$fcm_file" && -z "$fcm_base64" ]]; then
|
||||||
|
missing "Android FCM delivery" "FCM project ID and one service-account source"
|
||||||
|
elif [[ -z "$(value FCM_PROJECT_ID)" || (-n "$fcm_file" && -n "$fcm_base64") ||
|
||||||
|
(-z "$fcm_file" && -z "$fcm_base64") ]]; then
|
||||||
|
partial "Android FCM delivery" "project ID and exactly one credential source are required"
|
||||||
|
elif [[ -n "$fcm_file" ]]; then
|
||||||
|
if [[ "$fcm_file" == /* && -r "$fcm_file" ]]; then
|
||||||
|
ready "Android FCM delivery" "readable service-account file is configured"
|
||||||
|
else
|
||||||
|
invalid "Android FCM delivery" "FCM_SERVICE_ACCOUNT_FILE must be an absolute readable file"
|
||||||
|
fi
|
||||||
|
elif printf '%s' "$fcm_base64" | base64 --decode 2>/dev/null |
|
||||||
|
jq -e '.type == "service_account" and (.project_id | type == "string")' >/dev/null 2>&1; then
|
||||||
|
ready "Android FCM delivery" "valid Base64 service-account document is configured"
|
||||||
|
else
|
||||||
|
invalid "Android FCM delivery" "Base64 credential is not a service-account JSON document"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if all_empty ANDROID_APP_LINKS_PACKAGE_NAME ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS; then
|
||||||
|
missing "Android App Links" "package name and signing certificate fingerprint"
|
||||||
|
elif all_set ANDROID_APP_LINKS_PACKAGE_NAME ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS; then
|
||||||
|
ready "Android App Links" "package and signing fingerprints are configured"
|
||||||
|
else
|
||||||
|
partial "Android App Links" "package and signing fingerprints must be configured together"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME \
|
||||||
|
WNH_ANDROID_SIGNING_KEY_ALIAS WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS; then
|
||||||
|
ready "Android release inputs" "version and separate production/staging signing aliases are present"
|
||||||
|
else
|
||||||
|
missing "Android release inputs" "version code/name and both signing aliases"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \
|
||||||
|
"$failures" "$warnings"
|
||||||
|
|
||||||
|
if [[ "$mode" == "--require-release" && ($failures -ne 0 || $warnings -ne 0) ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -123,8 +123,7 @@ fi
|
||||||
source_commit=$(git rev-parse --verify HEAD)
|
source_commit=$(git rev-parse --verify HEAD)
|
||||||
git archive --format=tar "$source_commit" | tar -xf - -C "$workspace"
|
git archive --format=tar "$source_commit" | tar -xf - -C "$workspace"
|
||||||
|
|
||||||
if [[ -e "$workspace/.git" || -e "$workspace/output" || -e "$workspace/.env.load" ||
|
if [[ -e "$workspace/.git" || -e "$workspace/output" ]]; then
|
||||||
-e "$workspace/.env.e2e" ]]; then
|
|
||||||
echo "The tracked archive unexpectedly contains local state." >&2
|
echo "The tracked archive unexpectedly contains local state." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
"$ROOT/scripts/ensure-local-e2e-env.sh"
|
"$ROOT/scripts/ensure-local-e2e-env.sh"
|
||||||
|
E2E_ENV=${WNH_E2E_ENV_FILE:-"$ROOT/output/runtime/e2e.env"}
|
||||||
|
|
||||||
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
||||||
project="who_need_help_e2e_$run_id"
|
project="who_need_help_e2e_$run_id"
|
||||||
|
|
@ -44,7 +45,7 @@ export MAP_TILE_URL="/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
|
||||||
compose() {
|
compose() {
|
||||||
docker compose \
|
docker compose \
|
||||||
--project-name "$project" \
|
--project-name "$project" \
|
||||||
--env-file "$ROOT/.env.e2e" \
|
--env-file "$E2E_ENV" \
|
||||||
--file "$ROOT/compose.yaml" \
|
--file "$ROOT/compose.yaml" \
|
||||||
--file "$ROOT/compose.e2e.yaml" \
|
--file "$ROOT/compose.e2e.yaml" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,14 @@ case "$project" in
|
||||||
esac
|
esac
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
E2E_ENV=${WNH_E2E_ENV_FILE:-"$ROOT/output/runtime/e2e.env"}
|
||||||
run_id=${project#who_need_help_e2e_}
|
run_id=${project#who_need_help_e2e_}
|
||||||
|
|
||||||
|
if [ ! -f "$E2E_ENV" ]; then
|
||||||
|
echo "Missing generated E2E runtime state: $E2E_ENV" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Compose still resolves required interpolation values for `down`. These values
|
# Compose still resolves required interpolation values for `down`. These values
|
||||||
# mirror the exact per-run tags. The project label passed above remains the
|
# mirror the exact per-run tags. The project label passed above remains the
|
||||||
# Compose resource scope.
|
# Compose resource scope.
|
||||||
|
|
@ -38,7 +44,7 @@ export MAP_TILE_URL="/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
|
||||||
|
|
||||||
docker compose \
|
docker compose \
|
||||||
--project-name "$project" \
|
--project-name "$project" \
|
||||||
--env-file "$ROOT/.env.e2e" \
|
--env-file "$E2E_ENV" \
|
||||||
--file "$ROOT/compose.yaml" \
|
--file "$ROOT/compose.yaml" \
|
||||||
--file "$ROOT/compose.e2e.yaml" \
|
--file "$ROOT/compose.e2e.yaml" \
|
||||||
down --remove-orphans
|
down --remove-orphans
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
target="$ROOT/.env.android-test"
|
runtime_dir=${WNH_RUNTIME_ENV_DIR:-"$ROOT/output/runtime"}
|
||||||
|
target=${WNH_ANDROID_TEST_ENV_FILE:-"$runtime_dir/android-test.env"}
|
||||||
|
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
chmod 700 "$runtime_dir"
|
||||||
|
|
||||||
if [ -f "$target" ]; then
|
if [ -f "$target" ]; then
|
||||||
echo ".env.android-test already exists; no setting was changed."
|
chmod 600 "$target"
|
||||||
|
echo "The generated Android test runtime environment already exists; no setting was changed."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -16,4 +21,4 @@ umask 077
|
||||||
} > "$target"
|
} > "$target"
|
||||||
|
|
||||||
chmod 600 "$target"
|
chmod 600 "$target"
|
||||||
echo "Generated .env.android-test with an isolated device-loopback origin and mode 0600."
|
echo "Generated isolated Android test runtime state in output/runtime/android-test.env with mode 0600."
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ set -eu
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
target="$ROOT/.env.e2e"
|
runtime_dir=${WNH_RUNTIME_ENV_DIR:-"$ROOT/output/runtime"}
|
||||||
|
target=${WNH_E2E_ENV_FILE:-"$runtime_dir/e2e.env"}
|
||||||
|
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
chmod 700 "$runtime_dir"
|
||||||
|
|
||||||
if [ -f "$target" ]; then
|
if [ -f "$target" ]; then
|
||||||
chmod 600 "$target"
|
chmod 600 "$target"
|
||||||
|
|
@ -36,7 +40,7 @@ if [ -f "$target" ]; then
|
||||||
if [ "$updated" = true ]; then
|
if [ "$updated" = true ]; then
|
||||||
echo "Updated non-secret E2E transport inputs; existing secrets were preserved."
|
echo "Updated non-secret E2E transport inputs; existing secrets were preserved."
|
||||||
else
|
else
|
||||||
echo ".env.e2e already exists; no secret or experiment input was changed."
|
echo "The generated E2E runtime environment already exists; no secret or experiment input was changed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
@ -105,4 +109,4 @@ E2E_OUTPUT_DIR=$ROOT/output/e2e/generated-per-run
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 600 "$target"
|
chmod 600 "$target"
|
||||||
echo "Generated .env.e2e with independent local secrets and mode 0600."
|
echo "Generated isolated E2E runtime state in output/runtime/e2e.env with mode 0600."
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
TEMPLATE=${WNH_LOAD_ENV_TEMPLATE:-"$ROOT/.env.load.example"}
|
TEMPLATE=${WNH_LOAD_ENV_TEMPLATE:-"$ROOT/.env.load.example"}
|
||||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
runtime_dir=${WNH_RUNTIME_ENV_DIR:-"$ROOT/output/runtime"}
|
||||||
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$runtime_dir/load.env"}
|
||||||
|
|
||||||
|
mkdir -p "$runtime_dir"
|
||||||
|
chmod 700 "$runtime_dir"
|
||||||
|
|
||||||
for command in openssl perl; do
|
for command in openssl perl; do
|
||||||
if ! command -v "$command" >/dev/null 2>&1; then
|
if ! command -v "$command" >/dev/null 2>&1; then
|
||||||
|
|
@ -156,7 +160,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
||||||
[ "$needs_backup_interruption_chunks" = false ] &&
|
[ "$needs_backup_interruption_chunks" = false ] &&
|
||||||
[ "$needs_backup_interruption_chunk_bytes" = false ] &&
|
[ "$needs_backup_interruption_chunk_bytes" = false ] &&
|
||||||
[ "$needs_backup_interruption_interval" = false ]; then
|
[ "$needs_backup_interruption_interval" = false ]; then
|
||||||
echo ".env.load already exists; no secret or experiment input was changed."
|
echo "The generated load runtime environment already exists; no secret or experiment input was changed."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -356,7 +360,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
|
||||||
needs_backup_bucket_prefix needs_backup_timeout \
|
needs_backup_bucket_prefix needs_backup_timeout \
|
||||||
needs_backup_interruption_chunks needs_backup_interruption_chunk_bytes \
|
needs_backup_interruption_chunks needs_backup_interruption_chunk_bytes \
|
||||||
needs_backup_interruption_interval missing_template_keys template_upgrade_keys
|
needs_backup_interruption_interval missing_template_keys template_upgrade_keys
|
||||||
echo "Added missing deployment/queue/load/resilience/observability/backup inputs to ignored .env.load."
|
echo "Added missing deployment/queue/load/resilience/observability/backup inputs to the generated load runtime environment."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -404,7 +408,7 @@ DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \
|
||||||
' "$TEMPLATE" >"$temporary"
|
' "$TEMPLATE" >"$temporary"
|
||||||
|
|
||||||
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
|
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
|
||||||
echo "A secret marker was not replaced; refusing to publish .env.load." >&2
|
echo "A secret marker was not replaced; refusing to publish the generated load runtime environment." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -416,4 +420,4 @@ unset postgres_password secret_key_base handover_secret release_cookie metrics_t
|
||||||
backup_minio_root_user backup_minio_root_password backup_restic_password \
|
backup_minio_root_user backup_minio_root_password backup_restic_password \
|
||||||
database_url
|
database_url
|
||||||
|
|
||||||
echo "Generated independent load-profile secrets in ignored .env.load."
|
echo "Generated independent load-profile runtime state in output/runtime/load.env with mode 0600."
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,15 @@ public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production
|
||||||
codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-}
|
codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-}
|
||||||
google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-}
|
google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-}
|
google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
web_push_vapid_public_key=${PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
|
web_push_vapid_private_key=${PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
||||||
|
web_push_vapid_subject=${PRODUCTION_WEB_PUSH_VAPID_SUBJECT:-}
|
||||||
|
firebase_application_id=${PRODUCTION_WNH_FIREBASE_APPLICATION_ID:-}
|
||||||
|
firebase_api_key=${PRODUCTION_WNH_FIREBASE_API_KEY:-}
|
||||||
|
firebase_project_id=${PRODUCTION_WNH_FIREBASE_PROJECT_ID:-}
|
||||||
|
firebase_sender_id=${PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID:-}
|
||||||
|
fcm_project_id=${PRODUCTION_FCM_PROJECT_ID:-}
|
||||||
|
fcm_service_account_json_base64=${PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64:-}
|
||||||
android_app_links_package_name=${PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME:-}
|
android_app_links_package_name=${PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME:-}
|
||||||
android_app_links_fingerprints=${PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}
|
android_app_links_fingerprints=${PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}
|
||||||
test_domain=${PRODUCTION_TEST_DOMAIN:-"test.$domain"}
|
test_domain=${PRODUCTION_TEST_DOMAIN:-"test.$domain"}
|
||||||
|
|
@ -77,6 +86,34 @@ if { [ -n "$android_app_links_package_name" ] || [ -n "$android_app_links_finger
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
firebase_values="$firebase_application_id $firebase_api_key $firebase_project_id $firebase_sender_id"
|
||||||
|
if printf '%s\n' "$firebase_values" | grep -q '[^[:space:]]'; then
|
||||||
|
for value in "$firebase_application_id" "$firebase_api_key" \
|
||||||
|
"$firebase_project_id" "$firebase_sender_id"; do
|
||||||
|
[ -n "$value" ] || {
|
||||||
|
echo "All four production WNH_FIREBASE_* values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
vapid_values="$web_push_vapid_public_key $web_push_vapid_private_key $web_push_vapid_subject"
|
||||||
|
if printf '%s\n' "$vapid_values" | grep -q '[^[:space:]]'; then
|
||||||
|
for value in "$web_push_vapid_public_key" "$web_push_vapid_private_key" \
|
||||||
|
"$web_push_vapid_subject"; do
|
||||||
|
[ -n "$value" ] || {
|
||||||
|
echo "All three production WEB_PUSH_VAPID_* values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if { [ -n "$fcm_project_id" ] || [ -n "$fcm_service_account_json_base64" ]; } &&
|
||||||
|
{ [ -z "$fcm_project_id" ] || [ -z "$fcm_service_account_json_base64" ]; }; then
|
||||||
|
echo "Production FCM project ID and Base64 service account must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "$compose_project_name" in
|
case "$compose_project_name" in
|
||||||
*[!a-zA-Z0-9_-]* | '')
|
*[!a-zA-Z0-9_-]* | '')
|
||||||
echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2
|
echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2
|
||||||
|
|
@ -187,6 +224,15 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
|
||||||
GIT_SHA_VALUE=$git_sha \
|
GIT_SHA_VALUE=$git_sha \
|
||||||
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \
|
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \
|
||||||
|
WEB_PUSH_VAPID_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \
|
||||||
|
WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \
|
||||||
|
WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \
|
||||||
|
FIREBASE_APPLICATION_ID_VALUE=$firebase_application_id \
|
||||||
|
FIREBASE_API_KEY_VALUE=$firebase_api_key \
|
||||||
|
FIREBASE_PROJECT_ID_VALUE=$firebase_project_id \
|
||||||
|
FIREBASE_SENDER_ID_VALUE=$firebase_sender_id \
|
||||||
|
FCM_PROJECT_ID_VALUE=$fcm_project_id \
|
||||||
|
FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE=$fcm_service_account_json_base64 \
|
||||||
ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \
|
ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \
|
||||||
ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \
|
ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \
|
||||||
EDGE_COMPOSE_PROJECT_NAME_VALUE=$edge_compose_project_name \
|
EDGE_COMPOSE_PROJECT_NAME_VALUE=$edge_compose_project_name \
|
||||||
|
|
@ -244,6 +290,16 @@ TEST_UPSTREAM_VALUE=$test_upstream \
|
||||||
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
|
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"]
|
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"]
|
||||||
|
replacement["WEB_PUSH_VAPID_PUBLIC_KEY"] = ENVIRON["WEB_PUSH_VAPID_PUBLIC_KEY_VALUE"]
|
||||||
|
replacement["WEB_PUSH_VAPID_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"]
|
||||||
|
replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_APPLICATION_ID"] = ENVIRON["FIREBASE_APPLICATION_ID_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_API_KEY"] = ENVIRON["FIREBASE_API_KEY_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_PROJECT_ID"] = ENVIRON["FIREBASE_PROJECT_ID_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_GCM_SENDER_ID"] = ENVIRON["FIREBASE_SENDER_ID_VALUE"]
|
||||||
|
replacement["FCM_PROJECT_ID"] = ENVIRON["FCM_PROJECT_ID_VALUE"]
|
||||||
|
replacement["FCM_SERVICE_ACCOUNT_FILE"] = ""
|
||||||
|
replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"]
|
||||||
replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"]
|
replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"]
|
||||||
replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"]
|
replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"]
|
||||||
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
|
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
|
||||||
|
|
@ -267,6 +323,7 @@ trap - EXIT HUP INT TERM
|
||||||
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
||||||
unset smtp_password
|
unset smtp_password
|
||||||
unset google_oauth_client_secret
|
unset google_oauth_client_secret
|
||||||
|
unset web_push_vapid_private_key firebase_api_key fcm_service_account_json_base64
|
||||||
unset android_app_links_fingerprints
|
unset android_app_links_fingerprints
|
||||||
|
|
||||||
echo "Generated independent deployment secrets without printing them."
|
echo "Generated independent deployment secrets without printing them."
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,15 @@ mailpit_port=${TEST_MAILPIT_PORT:-8027}
|
||||||
codex_session_id=${TEST_CODEX_SESSION_ID:-}
|
codex_session_id=${TEST_CODEX_SESSION_ID:-}
|
||||||
google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-}
|
google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-}
|
google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
web_push_vapid_public_key=${TEST_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
|
web_push_vapid_private_key=${TEST_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
||||||
|
web_push_vapid_subject=${TEST_WEB_PUSH_VAPID_SUBJECT:-}
|
||||||
|
firebase_application_id=${TEST_WNH_FIREBASE_APPLICATION_ID:-}
|
||||||
|
firebase_api_key=${TEST_WNH_FIREBASE_API_KEY:-}
|
||||||
|
firebase_project_id=${TEST_WNH_FIREBASE_PROJECT_ID:-}
|
||||||
|
firebase_sender_id=${TEST_WNH_FIREBASE_GCM_SENDER_ID:-}
|
||||||
|
fcm_project_id=${TEST_FCM_PROJECT_ID:-}
|
||||||
|
fcm_service_account_json_base64=${TEST_FCM_SERVICE_ACCOUNT_JSON_BASE64:-}
|
||||||
android_app_links_package_name=${TEST_ANDROID_APP_LINKS_PACKAGE_NAME:-}
|
android_app_links_package_name=${TEST_ANDROID_APP_LINKS_PACKAGE_NAME:-}
|
||||||
android_app_links_fingerprints=${TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}
|
android_app_links_fingerprints=${TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}
|
||||||
support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-}
|
support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-}
|
||||||
|
|
@ -88,6 +97,34 @@ if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprint
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
firebase_values="$firebase_application_id $firebase_api_key $firebase_project_id $firebase_sender_id"
|
||||||
|
if grep -q '[^[:space:]]' <<<"$firebase_values"; then
|
||||||
|
for value in "$firebase_application_id" "$firebase_api_key" \
|
||||||
|
"$firebase_project_id" "$firebase_sender_id"; do
|
||||||
|
[[ -n "$value" ]] || {
|
||||||
|
echo "All four test WNH_FIREBASE_* values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
vapid_values="$web_push_vapid_public_key $web_push_vapid_private_key $web_push_vapid_subject"
|
||||||
|
if grep -q '[^[:space:]]' <<<"$vapid_values"; then
|
||||||
|
for value in "$web_push_vapid_public_key" "$web_push_vapid_private_key" \
|
||||||
|
"$web_push_vapid_subject"; do
|
||||||
|
[[ -n "$value" ]] || {
|
||||||
|
echo "All three test WEB_PUSH_VAPID_* values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ (-n "$fcm_project_id" || -n "$fcm_service_account_json_base64") &&
|
||||||
|
(-z "$fcm_project_id" || -z "$fcm_service_account_json_base64") ]]; then
|
||||||
|
echo "Test FCM project ID and Base64 service account must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do
|
for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do
|
||||||
value=${pair#*:}
|
value=${pair#*:}
|
||||||
if [[ ! "$value" =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then
|
if [[ ! "$value" =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then
|
||||||
|
|
@ -132,6 +169,15 @@ RELEASE_COOKIE_VALUE=$release_cookie \
|
||||||
METRICS_TOKEN_VALUE=$metrics_token \
|
METRICS_TOKEN_VALUE=$metrics_token \
|
||||||
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \
|
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \
|
||||||
|
WEB_PUSH_VAPID_PUBLIC_KEY_VALUE=$web_push_vapid_public_key \
|
||||||
|
WEB_PUSH_VAPID_PRIVATE_KEY_VALUE=$web_push_vapid_private_key \
|
||||||
|
WEB_PUSH_VAPID_SUBJECT_VALUE=$web_push_vapid_subject \
|
||||||
|
FIREBASE_APPLICATION_ID_VALUE=$firebase_application_id \
|
||||||
|
FIREBASE_API_KEY_VALUE=$firebase_api_key \
|
||||||
|
FIREBASE_PROJECT_ID_VALUE=$firebase_project_id \
|
||||||
|
FIREBASE_SENDER_ID_VALUE=$firebase_sender_id \
|
||||||
|
FCM_PROJECT_ID_VALUE=$fcm_project_id \
|
||||||
|
FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE=$fcm_service_account_json_base64 \
|
||||||
ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \
|
ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \
|
||||||
ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \
|
ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \
|
||||||
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
|
SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
|
||||||
|
|
@ -185,6 +231,16 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
|
||||||
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
|
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
|
||||||
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"]
|
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"]
|
||||||
|
replacement["WEB_PUSH_VAPID_PUBLIC_KEY"] = ENVIRON["WEB_PUSH_VAPID_PUBLIC_KEY_VALUE"]
|
||||||
|
replacement["WEB_PUSH_VAPID_PRIVATE_KEY"] = ENVIRON["WEB_PUSH_VAPID_PRIVATE_KEY_VALUE"]
|
||||||
|
replacement["WEB_PUSH_VAPID_SUBJECT"] = ENVIRON["WEB_PUSH_VAPID_SUBJECT_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_APPLICATION_ID"] = ENVIRON["FIREBASE_APPLICATION_ID_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_API_KEY"] = ENVIRON["FIREBASE_API_KEY_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_PROJECT_ID"] = ENVIRON["FIREBASE_PROJECT_ID_VALUE"]
|
||||||
|
replacement["WNH_FIREBASE_GCM_SENDER_ID"] = ENVIRON["FIREBASE_SENDER_ID_VALUE"]
|
||||||
|
replacement["FCM_PROJECT_ID"] = ENVIRON["FCM_PROJECT_ID_VALUE"]
|
||||||
|
replacement["FCM_SERVICE_ACCOUNT_FILE"] = ""
|
||||||
|
replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"]
|
||||||
replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"]
|
replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"]
|
||||||
replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"]
|
replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"]
|
||||||
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
|
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
|
||||||
|
|
@ -202,6 +258,7 @@ trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
unset postgres_password secret_key_base handover_secret release_cookie metrics_token
|
||||||
unset google_oauth_client_secret
|
unset google_oauth_client_secret
|
||||||
|
unset web_push_vapid_private_key firebase_api_key fcm_service_account_json_base64
|
||||||
unset android_app_links_fingerprints
|
unset android_app_links_fingerprints
|
||||||
|
|
||||||
"$ROOT/scripts/compose.sh" "$target" config --quiet
|
"$ROOT/scripts/compose.sh" "$target" config --quiet
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
BASE_ENV=${WNH_LOAD_BASE_ENV_FILE:-"$ROOT/.env.load"}
|
BASE_ENV=${WNH_LOAD_BASE_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LABEL=${1:-"cycle-$(date -u +%Y%m%dT%H%M%SZ)"}
|
LABEL=${1:-"cycle-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||||
MODE=${2:-load}
|
MODE=${2:-load}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"}
|
LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
|
@ -21,7 +21,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
|
||||||
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
|
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
|
||||||
TRAEFIK_API_INSECURE HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
|
TRAEFIK_API_INSECURE HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
|
||||||
if [[ -z "${!name:-}" ]]; then
|
if [[ -z "${!name:-}" ]]; then
|
||||||
echo "$name is missing from .env.load" >&2
|
echo "$name is missing from the generated load runtime environment" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
||||||
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
|
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
|
||||||
PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4"
|
PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4"
|
||||||
|
|
@ -22,7 +22,7 @@ set +a
|
||||||
WEB_POOL_SIZE=${WEB_POOL_SIZE:-${POOL_SIZE:-}}
|
WEB_POOL_SIZE=${WEB_POOL_SIZE:-${POOL_SIZE:-}}
|
||||||
WORKER_POOL_SIZE=${WORKER_POOL_SIZE:-${POOL_SIZE:-}}
|
WORKER_POOL_SIZE=${WORKER_POOL_SIZE:-${POOL_SIZE:-}}
|
||||||
|
|
||||||
duration_source=".env.load"
|
duration_source="output/runtime/load.env"
|
||||||
if [[ -n "$duration_override" ]]; then
|
if [[ -n "$duration_override" ]]; then
|
||||||
LOAD_DURATION=$duration_override
|
LOAD_DURATION=$duration_override
|
||||||
duration_source="LOAD_DURATION_OVERRIDE"
|
duration_source="LOAD_DURATION_OVERRIDE"
|
||||||
|
|
@ -35,7 +35,7 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
|
||||||
LOAD_AUTH_THINK_SECONDS LOAD_FIXTURE_PASSWORD POSTGRES_DB HTTP_PORT \
|
LOAD_AUTH_THINK_SECONDS LOAD_FIXTURE_PASSWORD POSTGRES_DB HTTP_PORT \
|
||||||
WEB_POOL_SIZE WORKER_POOL_SIZE; do
|
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 the generated load runtime environment" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
|
@ -15,7 +15,7 @@ set -a
|
||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from the generated load runtime environment}"
|
||||||
|
|
||||||
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||||
echo "Refusing to stop the staging Compose project." >&2
|
echo "Refusing to stop the staging Compose project." >&2
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
|
||||||
REPLICAS=${1:-}
|
REPLICAS=${1:-}
|
||||||
|
|
||||||
|
|
@ -13,10 +13,10 @@ set -a
|
||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from the generated load runtime environment}"
|
||||||
: "${LOAD_HOST:?LOAD_HOST is missing from .env.load}"
|
: "${LOAD_HOST:?LOAD_HOST is missing from the generated load runtime environment}"
|
||||||
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from .env.load}"
|
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from the generated load runtime environment}"
|
||||||
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from .env.load}"
|
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from the generated load runtime environment}"
|
||||||
|
|
||||||
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||||
echo "The load profile must not use the staging Compose project." >&2
|
echo "The load profile must not use the staging Compose project." >&2
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE="$ROOT/.env.load"
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
|
|
||||||
if [[ $# -ne 3 ]]; then
|
if [[ $# -ne 3 ]]; then
|
||||||
echo "Usage: $0 LABEL JOB_COUNT TIMEOUT_SECONDS" >&2
|
echo "Usage: $0 LABEL JOB_COUNT TIMEOUT_SECONDS" >&2
|
||||||
|
|
@ -40,7 +40,7 @@ set +a
|
||||||
|
|
||||||
for name in LOAD_PROJECT LOAD_WORKER_REPLICAS POSTGRES_DB; do
|
for name in LOAD_PROJECT LOAD_WORKER_REPLICAS POSTGRES_DB; do
|
||||||
if [[ -z "${!name:-}" ]]; then
|
if [[ -z "${!name:-}" ]]; then
|
||||||
echo "$name is missing from .env.load." >&2
|
echo "$name is missing from the generated load runtime environment." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE="$ROOT/.env.load"
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
LABEL=${1:-"observability-$(date -u +%Y%m%dT%H%M%SZ)"}
|
LABEL=${1:-"observability-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
|
@ -21,7 +21,7 @@ for name in LOAD_PROJECT LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS POSTGRES_DB METR
|
||||||
OBSERVABILITY_EVALUATION_INTERVAL OBSERVABILITY_TIMEOUT_SECONDS \
|
OBSERVABILITY_EVALUATION_INTERVAL OBSERVABILITY_TIMEOUT_SECONDS \
|
||||||
OBSERVABILITY_GRAFANA_ADMIN_USER OBSERVABILITY_GRAFANA_ADMIN_PASSWORD; do
|
OBSERVABILITY_GRAFANA_ADMIN_USER OBSERVABILITY_GRAFANA_ADMIN_PASSWORD; do
|
||||||
if [[ -z "${!name:-}" ]]; then
|
if [[ -z "${!name:-}" ]]; then
|
||||||
echo "$name is missing from .env.load" >&2
|
echo "$name is missing from the generated load runtime environment" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
@ -658,5 +658,5 @@ trap - EXIT HUP INT TERM
|
||||||
printf 'Observability evidence: %s\n' "$output_dir"
|
printf 'Observability evidence: %s\n' "$output_dir"
|
||||||
printf 'Prometheus: %s\nAlertmanager: %s\nGrafana: %s/d/wnh-overview/overview\n' \
|
printf 'Prometheus: %s\nAlertmanager: %s\nGrafana: %s/d/wnh-overview/overview\n' \
|
||||||
"$prometheus_url" "$alertmanager_url" "$grafana_url"
|
"$prometheus_url" "$alertmanager_url" "$grafana_url"
|
||||||
printf 'Grafana user: %s; its random password remains only in ignored .env.load.\n' \
|
printf 'Grafana user: %s; its random password remains only in ignored output/runtime/load.env.\n' \
|
||||||
"$OBSERVABILITY_GRAFANA_ADMIN_USER"
|
"$OBSERVABILITY_GRAFANA_ADMIN_USER"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE="$ROOT/.env.load"
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
echo "Missing $ENV_FILE; no observability project was selected." >&2
|
echo "Missing $ENV_FILE; no observability project was selected." >&2
|
||||||
|
|
@ -14,7 +14,7 @@ set -a
|
||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from the generated load runtime environment}"
|
||||||
|
|
||||||
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
|
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
|
||||||
echo "Refusing to stop services in the staging Compose project." >&2
|
echo "Refusing to stop services in the staging Compose project." >&2
|
||||||
|
|
|
||||||
53
scripts/prepare-production-release.sh
Executable file
53
scripts/prepare-production-release.sh
Executable file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|
||||||
|
if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]]; then
|
||||||
|
echo "Refusing to package a release from a dirty tracked checkout." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
commit=$(git -C "$ROOT" rev-parse --verify HEAD)
|
||||||
|
short_commit=${commit:0:12}
|
||||||
|
release_dir="$ROOT/output/releases/$commit"
|
||||||
|
bundle="$release_dir/who_need_help-$commit.bundle"
|
||||||
|
checksum="$bundle.sha256"
|
||||||
|
manifest="$release_dir/manifest.txt"
|
||||||
|
|
||||||
|
mkdir -p "$release_dir"
|
||||||
|
chmod 700 "$ROOT/output" "$ROOT/output/releases" "$release_dir"
|
||||||
|
|
||||||
|
if [[ -e "$bundle" || -e "$checksum" || -e "$manifest" ]]; then
|
||||||
|
echo "Release package already exists; verifying it instead of overwriting it."
|
||||||
|
else
|
||||||
|
git -C "$ROOT" bundle create "$bundle" HEAD
|
||||||
|
chmod 600 "$bundle"
|
||||||
|
hash=$(sha256sum "$bundle" | awk '{print $1}')
|
||||||
|
printf '%s %s\n' "$hash" "$(basename -- "$bundle")" >"$checksum"
|
||||||
|
{
|
||||||
|
printf 'commit=%s\n' "$commit"
|
||||||
|
printf 'short_commit=%s\n' "$short_commit"
|
||||||
|
printf 'created_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
printf 'bundle_sha256=%s\n' "$hash"
|
||||||
|
} >"$manifest"
|
||||||
|
chmod 600 "$checksum" "$manifest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$release_dir"
|
||||||
|
sha256sum --check "$(basename -- "$checksum")" >/dev/null
|
||||||
|
)
|
||||||
|
git -C "$ROOT" bundle verify "$bundle" >/dev/null
|
||||||
|
|
||||||
|
bundle_head=$(git -C "$ROOT" bundle list-heads "$bundle" | awk '$2 == "HEAD" {print $1}')
|
||||||
|
if [[ "$bundle_head" != "$commit" ]]; then
|
||||||
|
echo "Release bundle HEAD does not match the current commit." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Release commit: %s\n' "$commit"
|
||||||
|
printf 'Bundle: %s\n' "$bundle"
|
||||||
|
printf 'Checksum: %s\n' "$checksum"
|
||||||
|
printf 'Manifest: %s\n' "$manifest"
|
||||||
276
scripts/production-release-remote.sh
Executable file
276
scripts/production-release-remote.sh
Executable file
|
|
@ -0,0 +1,276 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
action=${1:-}
|
||||||
|
root=${2:-/srv/who_need_help-production}
|
||||||
|
expected_domain=${3:-whoneedhelp.com}
|
||||||
|
bundle=${4:-}
|
||||||
|
target_commit=${5:-}
|
||||||
|
backup=${6:-}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 plan /srv/who_need_help-production whoneedhelp.com" >&2
|
||||||
|
echo " $0 apply /srv/who_need_help-production whoneedhelp.com BUNDLE COMMIT BACKUP" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
plan | apply) ;;
|
||||||
|
*) usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
root=$(realpath --canonicalize-existing "$root")
|
||||||
|
if [[ "$root" != "/srv/who_need_help-production" ]]; then
|
||||||
|
echo "Refusing a production release outside /srv/who_need_help-production." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
env_file="$root/.env"
|
||||||
|
if [[ ! -f "$env_file" || "$(stat -c '%a' "$env_file")" != 600 ]]; then
|
||||||
|
echo "Production .env is missing or does not have mode 0600." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_value() {
|
||||||
|
local key=$1
|
||||||
|
awk -v key="$key" '
|
||||||
|
index($0, key "=") == 1 {
|
||||||
|
print substr($0, length(key) + 2)
|
||||||
|
found = 1
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
END { if (!found) exit 1 }
|
||||||
|
' "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment_environment=$(read_value DEPLOYMENT_ENV)
|
||||||
|
compose_project=$(read_value COMPOSE_PROJECT_NAME)
|
||||||
|
database_mode=$(read_value DATABASE_MODE)
|
||||||
|
app_topology=$(read_value APP_TOPOLOGY)
|
||||||
|
phx_host=$(read_value PHX_HOST)
|
||||||
|
public_origin=$(read_value WNH_BASE_URL)
|
||||||
|
branch=$(git -C "$root" symbolic-ref --quiet --short HEAD || true)
|
||||||
|
current_commit=$(git -C "$root" rev-parse --verify HEAD)
|
||||||
|
|
||||||
|
[[ "$deployment_environment" == "production" ]] || {
|
||||||
|
echo "DEPLOYMENT_ENV is not production." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ "$compose_project" == "who_need_help_production" ]] || {
|
||||||
|
echo "Unexpected production Compose project." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ "$database_mode" == "external" ]] || {
|
||||||
|
echo "The verified single-server production workflow expects DATABASE_MODE=external." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ "$phx_host" == "$expected_domain" &&
|
||||||
|
"$public_origin" == "https://$expected_domain" ]] || {
|
||||||
|
echo "Production origin does not match the expected domain." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ "$branch" == "main" || -z "$branch" ]] || {
|
||||||
|
echo "Production checkout must be on main or detached at the deployed commit." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[[ -z "$(git -C "$root" status --porcelain --untracked-files=no)" ]] || {
|
||||||
|
echo "Production checkout has tracked modifications." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain" >/dev/null
|
||||||
|
"$root/scripts/compose.sh" "$env_file" config --quiet
|
||||||
|
|
||||||
|
case "$app_topology" in
|
||||||
|
compact) expected_services=(app) ;;
|
||||||
|
split) expected_services=(web worker) ;;
|
||||||
|
*) echo "Unexpected APP_TOPOLOGY." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for service in "${expected_services[@]}"; do
|
||||||
|
mapfile -t containers < <("$root/scripts/compose.sh" "$env_file" ps -q "$service")
|
||||||
|
[[ ${#containers[@]} -gt 0 ]] || {
|
||||||
|
echo "Production service is not running: $service" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for container in "${containers[@]}"; do
|
||||||
|
state=$(docker inspect --format '{{.State.Status}}' "$container")
|
||||||
|
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")
|
||||||
|
[[ "$state" == "running" && "$health" == "healthy" ]] || {
|
||||||
|
echo "Production container is not healthy: $service" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --max-time 15 \
|
||||||
|
"https://$expected_domain/healthz/ready" >/dev/null
|
||||||
|
|
||||||
|
printf 'Production checkout: %s\n' "$root"
|
||||||
|
printf 'Current commit: %s\n' "$current_commit"
|
||||||
|
printf 'Branch: %s\n' "${branch:-detached}"
|
||||||
|
printf 'Compose project: %s\n' "$compose_project"
|
||||||
|
printf 'Topology: %s\n' "$app_topology"
|
||||||
|
printf 'Database mode: %s\n' "$database_mode"
|
||||||
|
printf 'Public readiness: passed\n'
|
||||||
|
df -h "$root" /var/lib/docker 2>/dev/null || df -h "$root"
|
||||||
|
|
||||||
|
if [[ "$action" == "plan" ]]; then
|
||||||
|
echo "Read-only production release scope check passed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$bundle" || -z "$target_commit" || -z "$backup" ]]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
expected_confirmation="$expected_domain:$target_commit"
|
||||||
|
if [[ "${WNH_PRODUCTION_RELEASE_CONFIRM:-}" != "$expected_confirmation" ]]; then
|
||||||
|
echo "Set WNH_PRODUCTION_RELEASE_CONFIRM=$expected_confirmation for the approved release." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
for required_file in "$bundle" "$bundle.sha256" "$backup" "$backup.sha256" "$backup.metadata"; do
|
||||||
|
[[ -f "$required_file" ]] || {
|
||||||
|
echo "Required release evidence is missing: $required_file" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$(dirname -- "$bundle")"
|
||||||
|
sha256sum --check "$(basename -- "$bundle.sha256")" >/dev/null
|
||||||
|
)
|
||||||
|
(
|
||||||
|
cd "$(dirname -- "$backup")"
|
||||||
|
sha256sum --check "$(basename -- "$backup.sha256")" >/dev/null
|
||||||
|
)
|
||||||
|
pg_restore --list "$backup" >/dev/null
|
||||||
|
git -C "$root" bundle verify "$bundle" >/dev/null
|
||||||
|
|
||||||
|
bundle_head=$(git -C "$root" bundle list-heads "$bundle" | awk '$2 == "HEAD" {print $1}')
|
||||||
|
[[ "$bundle_head" == "$target_commit" ]] || {
|
||||||
|
echo "Bundle HEAD does not match the approved target commit." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
release_id="$(date -u +%Y%m%dT%H%M%SZ)-${target_commit:0:12}"
|
||||||
|
release_dir="$root/output/releases/$release_id"
|
||||||
|
mkdir -p "$release_dir"
|
||||||
|
chmod 700 "$root/output" "$root/output/releases" "$release_dir"
|
||||||
|
rollback_manifest="$release_dir/rollback-manifest.txt"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'previous_commit=%s\n' "$current_commit"
|
||||||
|
printf 'target_commit=%s\n' "$target_commit"
|
||||||
|
printf 'APP_IMAGE=%s\n' "$(read_value APP_IMAGE)"
|
||||||
|
printf 'SOCKET_PROXY_IMAGE=%s\n' "$(read_value SOCKET_PROXY_IMAGE)"
|
||||||
|
printf 'POSTGIS_IMAGE=%s\n' "$(read_value POSTGIS_IMAGE)"
|
||||||
|
printf 'CADDY_IMAGE=%s\n' "$(read_value CADDY_IMAGE)"
|
||||||
|
printf 'database_backup=%s\n' "$backup"
|
||||||
|
printf 'started_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
printf 'status=started\n'
|
||||||
|
} >"$rollback_manifest"
|
||||||
|
chmod 600 "$rollback_manifest"
|
||||||
|
|
||||||
|
revision_changed=false
|
||||||
|
|
||||||
|
restore_image_revision() {
|
||||||
|
local temporary
|
||||||
|
temporary=$(mktemp "$root/.env.release-rollback.XXXXXX")
|
||||||
|
chmod 600 "$temporary"
|
||||||
|
|
||||||
|
APP_IMAGE_VALUE=$(awk -F= '$1 == "APP_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \
|
||||||
|
SOCKET_PROXY_IMAGE_VALUE=$(awk -F= '$1 == "SOCKET_PROXY_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \
|
||||||
|
POSTGIS_IMAGE_VALUE=$(awk -F= '$1 == "POSTGIS_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \
|
||||||
|
CADDY_IMAGE_VALUE=$(awk -F= '$1 == "CADDY_IMAGE" {print substr($0,index($0,"=")+1)}' "$rollback_manifest") \
|
||||||
|
awk '
|
||||||
|
BEGIN {
|
||||||
|
replacement["APP_IMAGE"] = ENVIRON["APP_IMAGE_VALUE"]
|
||||||
|
replacement["SOCKET_PROXY_IMAGE"] = ENVIRON["SOCKET_PROXY_IMAGE_VALUE"]
|
||||||
|
replacement["POSTGIS_IMAGE"] = ENVIRON["POSTGIS_IMAGE_VALUE"]
|
||||||
|
replacement["CADDY_IMAGE"] = ENVIRON["CADDY_IMAGE_VALUE"]
|
||||||
|
}
|
||||||
|
{
|
||||||
|
separator = index($0, "=")
|
||||||
|
key = separator > 1 ? substr($0, 1, separator - 1) : ""
|
||||||
|
print (key in replacement) ? key "=" replacement[key] : $0
|
||||||
|
}
|
||||||
|
' "$env_file" >"$temporary"
|
||||||
|
|
||||||
|
mv "$temporary" "$env_file"
|
||||||
|
chmod 600 "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback_runtime() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
if [[ "$status" -ne 0 && "$revision_changed" == true ]]; then
|
||||||
|
echo "Release failed after selecting new images; restoring the previous immutable image tags." >&2
|
||||||
|
restore_image_revision
|
||||||
|
"$root/scripts/compose.sh" "$env_file" \
|
||||||
|
up -d --no-build --wait --remove-orphans || true
|
||||||
|
|
||||||
|
edge_project=$(read_value EDGE_COMPOSE_PROJECT_NAME)
|
||||||
|
docker compose \
|
||||||
|
--project-name "$edge_project" \
|
||||||
|
--project-directory "$root" \
|
||||||
|
--env-file "$env_file" \
|
||||||
|
--file "$root/compose.edge.yaml" \
|
||||||
|
up -d --no-build --wait --remove-orphans || true
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --max-time 15 \
|
||||||
|
"https://$expected_domain/healthz/ready" >/dev/null || true
|
||||||
|
{
|
||||||
|
printf 'status=runtime-rolled-back\n'
|
||||||
|
printf 'failed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
printf 'database_note=migrations_may_have_applied_and_were_not_reversed\n'
|
||||||
|
} >>"$rollback_manifest"
|
||||||
|
echo "The Git checkout and any applied migrations were intentionally not reversed automatically." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap rollback_runtime EXIT HUP INT TERM
|
||||||
|
|
||||||
|
release_ref="refs/wnh/releases/$target_commit"
|
||||||
|
git -C "$root" fetch "$bundle" "HEAD:$release_ref"
|
||||||
|
[[ "$(git -C "$root" rev-parse "$release_ref^{commit}")" == "$target_commit" ]] || {
|
||||||
|
echo "Fetched release ref does not match the approved commit." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git -C "$root" merge-base --is-ancestor "$current_commit" "$target_commit" || {
|
||||||
|
echo "Production updates must be a fast-forward from the deployed commit." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if [[ "$branch" == "main" ]]; then
|
||||||
|
git -C "$root" merge --ff-only "$release_ref"
|
||||||
|
else
|
||||||
|
git -C "$root" checkout --detach "$release_ref"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$root/scripts/set-deployment-revision.sh" "$env_file"
|
||||||
|
revision_changed=true
|
||||||
|
"$root/scripts/validate-production-env.sh" "$env_file" "$expected_domain"
|
||||||
|
"$root/scripts/check-environment-readiness.sh" "$env_file" --require-release
|
||||||
|
|
||||||
|
"$root/scripts/deploy-up.sh" "$env_file"
|
||||||
|
"$root/scripts/edge-up.sh" "$env_file"
|
||||||
|
curl --fail --silent --show-error --max-time 30 \
|
||||||
|
"https://$expected_domain/healthz/ready" >/dev/null
|
||||||
|
curl --fail --silent --show-error --max-time 30 \
|
||||||
|
"https://$expected_domain/.well-known/assetlinks.json" >/dev/null
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'status=success\n'
|
||||||
|
printf 'completed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
} >>"$rollback_manifest"
|
||||||
|
revision_changed=false
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
printf 'Production release completed: %s\n' "$target_commit"
|
||||||
|
printf 'Rollback/runtime evidence: %s\n' "$rollback_manifest"
|
||||||
|
printf 'Database backup: %s\n' "$backup"
|
||||||
124
scripts/production-release.sh
Executable file
124
scripts/production-release.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
action=${1:-plan}
|
||||||
|
ssh_target=${2:-whoneedhelp}
|
||||||
|
remote_root=${WNH_PRODUCTION_REMOTE_ROOT:-/srv/who_need_help-production}
|
||||||
|
expected_domain=${WNH_PRODUCTION_DOMAIN:-whoneedhelp.com}
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
plan | apply) ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [plan|apply] [SSH_TARGET]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for command in git pg_restore scp sha256sum ssh; do
|
||||||
|
command -v "$command" >/dev/null 2>&1 || {
|
||||||
|
echo "Required command is unavailable: $command" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
local_commit=$(git -C "$ROOT" rev-parse --verify HEAD)
|
||||||
|
remote_commit=$(
|
||||||
|
ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"git -C '$remote_root' rev-parse --verify HEAD"
|
||||||
|
)
|
||||||
|
|
||||||
|
printf 'Local candidate commit: %s\n' "$local_commit"
|
||||||
|
printf 'Current production commit: %s\n' "$remote_commit"
|
||||||
|
|
||||||
|
if git -C "$ROOT" cat-file -e "$remote_commit^{commit}" 2>/dev/null; then
|
||||||
|
git -C "$ROOT" merge-base --is-ancestor "$remote_commit" "$local_commit" || {
|
||||||
|
echo "The local candidate is not a fast-forward from the production commit." >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
printf 'Pending commits: %s\n' \
|
||||||
|
"$(git -C "$ROOT" rev-list --count "$remote_commit..$local_commit")"
|
||||||
|
else
|
||||||
|
echo "The production commit is not present in the local object database." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
plan_failed=0
|
||||||
|
|
||||||
|
if ! ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"bash -s -- plan '$remote_root' '$expected_domain'" \
|
||||||
|
<"$ROOT/scripts/production-release-remote.sh"; then
|
||||||
|
plan_failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"bash -s -- '$remote_root/.env' --check-only production" \
|
||||||
|
<"$ROOT/scripts/backup-external-postgres.sh"; then
|
||||||
|
plan_failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"bash -s -- '$remote_root/.env' --require-release" \
|
||||||
|
<"$ROOT/scripts/check-environment-readiness.sh"; then
|
||||||
|
plan_failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$plan_failed" -ne 0 ]]; then
|
||||||
|
echo "Production release plan has blocking checks; no remote state was changed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$action" == "plan" ]]; then
|
||||||
|
echo "Production release plan passed; no remote state was changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]]; then
|
||||||
|
echo "Refusing to release a dirty tracked checkout." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
confirmation="$expected_domain:$local_commit"
|
||||||
|
if [[ "${WNH_PRODUCTION_RELEASE_CONFIRM:-}" != "$confirmation" ]]; then
|
||||||
|
echo "Release execution requires explicit approval in this exact process:" >&2
|
||||||
|
echo "WNH_PRODUCTION_RELEASE_CONFIRM=$confirmation $0 apply $ssh_target" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$ROOT/scripts/prepare-production-release.sh"
|
||||||
|
release_dir="$ROOT/output/releases/$local_commit"
|
||||||
|
bundle="$release_dir/who_need_help-$local_commit.bundle"
|
||||||
|
remote_release_dir="$remote_root/output/releases/incoming"
|
||||||
|
remote_bundle="$remote_release_dir/$(basename -- "$bundle")"
|
||||||
|
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
remote_backup="$remote_root/output/backups/production/pre-$timestamp-${local_commit:0:12}.dump"
|
||||||
|
|
||||||
|
ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"install -d -m 700 '$remote_release_dir'"
|
||||||
|
scp -p "$bundle" "$bundle.sha256" "$ssh_target:$remote_release_dir/"
|
||||||
|
|
||||||
|
ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"bash -s -- '$remote_root/.env' '$remote_backup' production" \
|
||||||
|
<"$ROOT/scripts/backup-external-postgres.sh"
|
||||||
|
|
||||||
|
local_backup_dir="$ROOT/output/production-backups/$timestamp-${local_commit:0:12}"
|
||||||
|
mkdir -p "$local_backup_dir"
|
||||||
|
chmod 700 "$ROOT/output" "$ROOT/output/production-backups" "$local_backup_dir"
|
||||||
|
scp -p \
|
||||||
|
"$ssh_target:$remote_backup" \
|
||||||
|
"$ssh_target:$remote_backup.sha256" \
|
||||||
|
"$ssh_target:$remote_backup.metadata" \
|
||||||
|
"$local_backup_dir/"
|
||||||
|
(
|
||||||
|
cd "$local_backup_dir"
|
||||||
|
sha256sum --check "$(basename -- "$remote_backup.sha256")" >/dev/null
|
||||||
|
)
|
||||||
|
pg_restore --list "$local_backup_dir/$(basename -- "$remote_backup")" >/dev/null
|
||||||
|
echo "Copied and independently verified the pre-release backup outside the production server."
|
||||||
|
|
||||||
|
quoted_confirmation=$(printf '%q' "$confirmation")
|
||||||
|
ssh -o BatchMode=yes "$ssh_target" \
|
||||||
|
"WNH_PRODUCTION_RELEASE_CONFIRM=$quoted_confirmation bash -s -- apply '$remote_root' '$expected_domain' '$remote_bundle' '$local_commit' '$remote_backup'" \
|
||||||
|
<"$ROOT/scripts/production-release-remote.sh"
|
||||||
|
|
||||||
|
echo "Production release and public health verification completed."
|
||||||
|
|
@ -116,6 +116,10 @@ WNH_LOAD_ENV_FILE="$legacy_load_env" \
|
||||||
test "$(sha256sum "$legacy_load_env" | awk '{print $1}')" = "$legacy_load_hash"
|
test "$(sha256sum "$legacy_load_env" | awk '{print $1}')" = "$legacy_load_hash"
|
||||||
|
|
||||||
echo "Checking independent test and production environment initialization"
|
echo "Checking independent test and production environment initialization"
|
||||||
|
quality_fcm_base64=$(
|
||||||
|
printf '%s' '{"type":"service_account","project_id":"quality-production"}' |
|
||||||
|
base64 -w 0
|
||||||
|
)
|
||||||
test_env="$scan_dir/test.env"
|
test_env="$scan_dir/test.env"
|
||||||
if ./scripts/init-test-env.sh test.help.test \
|
if ./scripts/init-test-env.sh test.help.test \
|
||||||
"$scan_dir/test.missing-codex.env" >/dev/null 2>&1; then
|
"$scan_dir/test.missing-codex.env" >/dev/null 2>&1; then
|
||||||
|
|
@ -186,11 +190,22 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
|
||||||
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \
|
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \
|
||||||
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \
|
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \
|
||||||
|
PRODUCTION_WEB_PUSH_VAPID_PUBLIC_KEY=quality-vapid-public \
|
||||||
|
PRODUCTION_WEB_PUSH_VAPID_PRIVATE_KEY=quality-vapid-private \
|
||||||
|
PRODUCTION_WEB_PUSH_VAPID_SUBJECT=mailto:contact@help.test \
|
||||||
|
PRODUCTION_WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality \
|
||||||
|
PRODUCTION_WNH_FIREBASE_API_KEY=quality-public-client-key \
|
||||||
|
PRODUCTION_WNH_FIREBASE_PROJECT_ID=quality-production \
|
||||||
|
PRODUCTION_WNH_FIREBASE_GCM_SENDER_ID=123456789 \
|
||||||
|
PRODUCTION_FCM_PROJECT_ID=quality-production \
|
||||||
|
PRODUCTION_FCM_SERVICE_ACCOUNT_JSON_BASE64="$quality_fcm_base64" \
|
||||||
PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile \
|
PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile \
|
||||||
PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \
|
PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \
|
||||||
|
PRODUCTION_SUPPORT_INBOX_ADDRESS=support@help.test \
|
||||||
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
./scripts/init-production-env.sh help.test "$production_env" >/dev/null
|
||||||
test "$(stat -c '%a' "$production_env")" = 600
|
test "$(stat -c '%a' "$production_env")" = 600
|
||||||
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
|
||||||
|
./scripts/check-environment-readiness.sh "$production_env" --require-release >/dev/null
|
||||||
grep -Fx 'COMPOSE_PROJECT_NAME=who_need_help_production' "$production_env" >/dev/null
|
grep -Fx 'COMPOSE_PROJECT_NAME=who_need_help_production' "$production_env" >/dev/null
|
||||||
grep -E '^APP_IMAGE=who-need-help:production-[0-9a-f]{12}$' "$production_env" >/dev/null
|
grep -E '^APP_IMAGE=who-need-help:production-[0-9a-f]{12}$' "$production_env" >/dev/null
|
||||||
grep -Fx 'EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge' "$production_env" >/dev/null
|
grep -Fx 'EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge' "$production_env" >/dev/null
|
||||||
|
|
@ -333,6 +348,13 @@ if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1;
|
||||||
echo "Production environment initializer overwrote an existing file." >&2
|
echo "Production environment initializer overwrote an existing file." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
|
PRODUCTION_WNH_FIREBASE_PROJECT_ID=partial-firebase \
|
||||||
|
./scripts/init-production-env.sh \
|
||||||
|
help.test "$scan_dir/.env.production.partial-firebase" >/dev/null 2>&1; then
|
||||||
|
echo "Production initializer accepted partial Firebase Android configuration." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
incomplete_production_env="$scan_dir/.env.production.incomplete"
|
incomplete_production_env="$scan_dir/.env.production.incomplete"
|
||||||
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
|
||||||
./scripts/init-production-env.sh help.test "$incomplete_production_env" >/dev/null
|
./scripts/init-production-env.sh help.test "$incomplete_production_env" >/dev/null
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
ENV_FILE="$ROOT/.env.load"
|
ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/output/runtime/load.env"}
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
|
echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ fi
|
||||||
"$ROOT/scripts/ensure-local-e2e-env.sh" >/dev/null
|
"$ROOT/scripts/ensure-local-e2e-env.sh" >/dev/null
|
||||||
|
|
||||||
PUBLIC_ENV_FILE="$ROOT/.env"
|
PUBLIC_ENV_FILE="$ROOT/.env"
|
||||||
ENV_FILE="$ROOT/.env.e2e"
|
ENV_FILE=${WNH_E2E_ENV_FILE:-"$ROOT/output/runtime/e2e.env"}
|
||||||
|
|
||||||
if [ ! -f "$PUBLIC_ENV_FILE" ]; then
|
if [ ! -f "$PUBLIC_ENV_FILE" ]; then
|
||||||
echo "Missing $PUBLIC_ENV_FILE." >&2
|
echo "Missing $PUBLIC_ENV_FILE." >&2
|
||||||
|
|
@ -65,8 +65,8 @@ set -a
|
||||||
. "$ENV_FILE"
|
. "$ENV_FILE"
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
: "${POSTGRES_USER:?POSTGRES_USER is missing from .env.e2e}"
|
: "${POSTGRES_USER:?POSTGRES_USER is missing from the generated E2E runtime environment}"
|
||||||
: "${DATABASE_URL:?DATABASE_URL is missing from .env.e2e}"
|
: "${DATABASE_URL:?DATABASE_URL is missing from the generated E2E runtime environment}"
|
||||||
|
|
||||||
dump_dir=$(CDPATH='' cd -- "$(dirname -- "$dump")" && pwd)
|
dump_dir=$(CDPATH='' cd -- "$(dirname -- "$dump")" && pwd)
|
||||||
dump_name=$(basename -- "$dump")
|
dump_name=$(basename -- "$dump")
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,16 @@ email_from_address=$(require_value EMAIL_FROM_ADDRESS)
|
||||||
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
|
||||||
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
|
||||||
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
|
||||||
|
web_push_vapid_public_key=$(optional_value WEB_PUSH_VAPID_PUBLIC_KEY)
|
||||||
|
web_push_vapid_private_key=$(optional_value WEB_PUSH_VAPID_PRIVATE_KEY)
|
||||||
|
web_push_vapid_subject=$(optional_value WEB_PUSH_VAPID_SUBJECT)
|
||||||
|
firebase_application_id=$(optional_value WNH_FIREBASE_APPLICATION_ID)
|
||||||
|
firebase_api_key=$(optional_value WNH_FIREBASE_API_KEY)
|
||||||
|
firebase_project_id=$(optional_value WNH_FIREBASE_PROJECT_ID)
|
||||||
|
firebase_sender_id=$(optional_value WNH_FIREBASE_GCM_SENDER_ID)
|
||||||
|
fcm_project_id=$(optional_value FCM_PROJECT_ID)
|
||||||
|
fcm_service_account_file=$(optional_value FCM_SERVICE_ACCOUNT_FILE)
|
||||||
|
fcm_service_account_json_base64=$(optional_value FCM_SERVICE_ACCOUNT_JSON_BASE64)
|
||||||
android_app_links_package_name=$(optional_value ANDROID_APP_LINKS_PACKAGE_NAME)
|
android_app_links_package_name=$(optional_value ANDROID_APP_LINKS_PACKAGE_NAME)
|
||||||
android_app_links_fingerprints=$(optional_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
|
android_app_links_fingerprints=$(optional_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
|
||||||
codex_session_id=$(require_value CODEX_SESSION_ID)
|
codex_session_id=$(require_value CODEX_SESSION_ID)
|
||||||
|
|
@ -318,6 +328,77 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then
|
||||||
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
firebase_values=(
|
||||||
|
"$firebase_application_id"
|
||||||
|
"$firebase_api_key"
|
||||||
|
"$firebase_project_id"
|
||||||
|
"$firebase_sender_id"
|
||||||
|
)
|
||||||
|
firebase_nonempty=0
|
||||||
|
for candidate in "${firebase_values[@]}"; do
|
||||||
|
[[ -z "$candidate" ]] || firebase_nonempty=$((firebase_nonempty + 1))
|
||||||
|
done
|
||||||
|
if ((firebase_nonempty != 0 && firebase_nonempty != ${#firebase_values[@]})); then
|
||||||
|
echo "All four WNH_FIREBASE_* Android client values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
vapid_values=(
|
||||||
|
"$web_push_vapid_public_key"
|
||||||
|
"$web_push_vapid_private_key"
|
||||||
|
"$web_push_vapid_subject"
|
||||||
|
)
|
||||||
|
vapid_nonempty=0
|
||||||
|
for candidate in "${vapid_values[@]}"; do
|
||||||
|
[[ -z "$candidate" ]] || vapid_nonempty=$((vapid_nonempty + 1))
|
||||||
|
done
|
||||||
|
if ((vapid_nonempty != 0 && vapid_nonempty != ${#vapid_values[@]})); then
|
||||||
|
echo "All three WEB_PUSH_VAPID_* values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ((vapid_nonempty == ${#vapid_values[@]})) &&
|
||||||
|
[[ ! "$web_push_vapid_subject" =~ ^(mailto:|https://) ]]; then
|
||||||
|
echo "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$fcm_service_account_file" && -n "$fcm_service_account_json_base64" ]]; then
|
||||||
|
echo "Set only one FCM service-account credential source." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$fcm_project_id" || -n "$fcm_service_account_file" ||
|
||||||
|
-n "$fcm_service_account_json_base64" ]]; then
|
||||||
|
[[ -n "$fcm_project_id" ]] || {
|
||||||
|
echo "FCM_PROJECT_ID is required with FCM credentials." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ -n "$fcm_service_account_file" || -n "$fcm_service_account_json_base64" ]] || {
|
||||||
|
echo "One FCM service-account credential source is required with FCM_PROJECT_ID." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$fcm_service_account_file" ]]; then
|
||||||
|
[[ "$fcm_service_account_file" == /* && -r "$fcm_service_account_file" ]] || {
|
||||||
|
echo "FCM_SERVICE_ACCOUNT_FILE must be an absolute readable file." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
for command in base64 jq; do
|
||||||
|
command -v "$command" >/dev/null 2>&1 || {
|
||||||
|
echo "Required command is unavailable for FCM validation: $command" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
printf '%s' "$fcm_service_account_json_base64" |
|
||||||
|
base64 --decode 2>/dev/null |
|
||||||
|
jq -e '.type == "service_account" and (.project_id | type == "string")' \
|
||||||
|
>/dev/null 2>&1 || {
|
||||||
|
echo "FCM_SERVICE_ACCOUNT_JSON_BASE64 is not a service-account JSON document." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then
|
if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then
|
||||||
[[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || {
|
[[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || {
|
||||||
echo "Android App Links package and fingerprints must either both be set or both be empty." >&2
|
echo "Android App Links package and fingerprints must either both be set or both be empty." >&2
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,58 @@ if [[ -n "$google_id" || -n "$google_secret" ]]; then
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
firebase_values=(
|
||||||
|
"$(read_value WNH_FIREBASE_APPLICATION_ID 2>/dev/null || true)"
|
||||||
|
"$(read_value WNH_FIREBASE_API_KEY 2>/dev/null || true)"
|
||||||
|
"$(read_value WNH_FIREBASE_PROJECT_ID 2>/dev/null || true)"
|
||||||
|
"$(read_value WNH_FIREBASE_GCM_SENDER_ID 2>/dev/null || true)"
|
||||||
|
)
|
||||||
|
firebase_nonempty=0
|
||||||
|
for candidate in "${firebase_values[@]}"; do
|
||||||
|
[[ -z "$candidate" ]] || firebase_nonempty=$((firebase_nonempty + 1))
|
||||||
|
done
|
||||||
|
if ((firebase_nonempty != 0 && firebase_nonempty != ${#firebase_values[@]})); then
|
||||||
|
echo "All four test WNH_FIREBASE_* Android client values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
vapid_values=(
|
||||||
|
"$(read_value WEB_PUSH_VAPID_PUBLIC_KEY 2>/dev/null || true)"
|
||||||
|
"$(read_value WEB_PUSH_VAPID_PRIVATE_KEY 2>/dev/null || true)"
|
||||||
|
"$(read_value WEB_PUSH_VAPID_SUBJECT 2>/dev/null || true)"
|
||||||
|
)
|
||||||
|
vapid_nonempty=0
|
||||||
|
for candidate in "${vapid_values[@]}"; do
|
||||||
|
[[ -z "$candidate" ]] || vapid_nonempty=$((vapid_nonempty + 1))
|
||||||
|
done
|
||||||
|
if ((vapid_nonempty != 0 && vapid_nonempty != ${#vapid_values[@]})); then
|
||||||
|
echo "All three test WEB_PUSH_VAPID_* values must be configured together." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ((vapid_nonempty == ${#vapid_values[@]})) &&
|
||||||
|
[[ ! "${vapid_values[2]}" =~ ^(mailto:|https://) ]]; then
|
||||||
|
echo "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fcm_project_id=$(read_value FCM_PROJECT_ID 2>/dev/null || true)
|
||||||
|
fcm_service_account_file=$(read_value FCM_SERVICE_ACCOUNT_FILE 2>/dev/null || true)
|
||||||
|
fcm_service_account_json_base64=$(
|
||||||
|
read_value FCM_SERVICE_ACCOUNT_JSON_BASE64 2>/dev/null || true
|
||||||
|
)
|
||||||
|
if [[ -n "$fcm_service_account_file" && -n "$fcm_service_account_json_base64" ]]; then
|
||||||
|
echo "Set only one test FCM service-account credential source." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$fcm_project_id" || -n "$fcm_service_account_file" ||
|
||||||
|
-n "$fcm_service_account_json_base64" ]]; then
|
||||||
|
[[ -n "$fcm_project_id" &&
|
||||||
|
(-n "$fcm_service_account_file" || -n "$fcm_service_account_json_base64") ]] || {
|
||||||
|
echo "Test FCM project ID and exactly one credential source are required together." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
android_package=$(read_value ANDROID_APP_LINKS_PACKAGE_NAME 2>/dev/null || true)
|
android_package=$(read_value ANDROID_APP_LINKS_PACKAGE_NAME 2>/dev/null || true)
|
||||||
android_fingerprints=$(read_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS 2>/dev/null || true)
|
android_fingerprints=$(read_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS 2>/dev/null || true)
|
||||||
if [[ -n "$android_package" || -n "$android_fingerprints" ]]; then
|
if [[ -n "$android_package" || -n "$android_fingerprints" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,16 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
assert {:ok, replacement_assignment} = Help.accept_request(replacement_scope, request.id)
|
assert {:ok, replacement_assignment} = Help.accept_request(replacement_scope, request.id)
|
||||||
assert replacement_assignment.id != assignment.id
|
assert replacement_assignment.id != assignment.id
|
||||||
|
|
||||||
assert [old_assignment, active_assignment] =
|
assert Repo.aggregate(
|
||||||
Assignment
|
from(current in Assignment, where: current.request_id == ^request.id),
|
||||||
|> where([current], current.request_id == ^request.id)
|
:count
|
||||||
|> order_by([current], asc: current.inserted_at)
|
) == 2
|
||||||
|> Repo.all()
|
|
||||||
|
old_assignment = Repo.get!(Assignment, assignment.id)
|
||||||
|
active_assignment = Repo.get!(Assignment, replacement_assignment.id)
|
||||||
|
|
||||||
refute old_assignment.active
|
refute old_assignment.active
|
||||||
|
assert old_assignment.status == :cancelled
|
||||||
assert active_assignment.active
|
assert active_assignment.active
|
||||||
assert active_assignment.helper_id == replacement.id
|
assert active_assignment.helper_id == replacement.id
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user