feat: add verified encrypted S3 backups
This commit is contained in:
parent
76a261b9ec
commit
1b60685454
|
|
@ -67,3 +67,16 @@ OBSERVABILITY_EVALUATION_INTERVAL=1s
|
||||||
OBSERVABILITY_TIMEOUT_SECONDS=90
|
OBSERVABILITY_TIMEOUT_SECONDS=90
|
||||||
OBSERVABILITY_GRAFANA_ADMIN_USER=local-admin
|
OBSERVABILITY_GRAFANA_ADMIN_USER=local-admin
|
||||||
OBSERVABILITY_GRAFANA_ADMIN_PASSWORD=GENERATE_OBSERVABILITY_GRAFANA_ADMIN_PASSWORD
|
OBSERVABILITY_GRAFANA_ADMIN_PASSWORD=GENERATE_OBSERVABILITY_GRAFANA_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
# The encrypted backup drill keeps its MinIO API and console on dynamically
|
||||||
|
# allocated loopback ports. Secrets are generated only in ignored .env.load.
|
||||||
|
BACKUP_MINIO_API_PORT=0
|
||||||
|
BACKUP_MINIO_CONSOLE_PORT=0
|
||||||
|
BACKUP_MINIO_ROOT_USER=GENERATE_BACKUP_MINIO_ROOT_USER
|
||||||
|
BACKUP_MINIO_ROOT_PASSWORD=GENERATE_BACKUP_MINIO_ROOT_PASSWORD
|
||||||
|
BACKUP_RESTIC_PASSWORD=GENERATE_BACKUP_RESTIC_PASSWORD
|
||||||
|
BACKUP_BUCKET_PREFIX=wnh-backup
|
||||||
|
BACKUP_TIMEOUT_SECONDS=120
|
||||||
|
BACKUP_INTERRUPTION_CHUNKS=300
|
||||||
|
BACKUP_INTERRUPTION_CHUNK_BYTES=1048576
|
||||||
|
BACKUP_INTERRUPTION_INTERVAL_SECONDS=0.1
|
||||||
|
|
|
||||||
20
Dockerfile.backup
Normal file
20
Dockerfile.backup
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go install github.com/restic/restic/cmd/restic@v0.19.1
|
||||||
|
|
||||||
|
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates=20260611-r0 \
|
||||||
|
libcrypto3=3.5.7-r0 \
|
||||||
|
libssl3=3.5.7-r0 \
|
||||||
|
musl=1.2.5-r23 \
|
||||||
|
musl-utils=1.2.5-r23 \
|
||||||
|
postgresql18-client=18.4-r0 \
|
||||||
|
zlib=1.3.2-r0
|
||||||
|
|
||||||
|
COPY --from=restic /go/bin/restic /usr/local/bin/restic
|
||||||
|
|
||||||
|
ENTRYPOINT []
|
||||||
|
USER 10001:10001
|
||||||
|
CMD ["restic", "version"]
|
||||||
81
Dockerfile.minio
Normal file
81
Dockerfile.minio
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS minio_builder
|
||||||
|
|
||||||
|
ADD --checksum=sha256:45521908307306e925c98d629e1c17d78c8b72b6ee242b1bfb1409f7d8ee5841 \
|
||||||
|
https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \
|
||||||
|
/tmp/minio.tar.gz
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN tar --extract --gzip --file /tmp/minio.tar.gz \
|
||||||
|
--directory . --strip-components=1 \
|
||||||
|
&& go get \
|
||||||
|
github.com/apache/thrift@v0.23.0 \
|
||||||
|
github.com/buger/jsonparser@v1.1.2 \
|
||||||
|
github.com/prometheus/prometheus@v0.311.3 \
|
||||||
|
golang.org/x/crypto@v0.52.0 \
|
||||||
|
golang.org/x/net@v0.55.0 \
|
||||||
|
google.golang.org/grpc@v1.81.1 \
|
||||||
|
&& CGO_ENABLED=0 go build \
|
||||||
|
-mod=mod \
|
||||||
|
-trimpath \
|
||||||
|
-tags kqueue \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X github.com/minio/minio/cmd.Version=2025-10-15T17:29:55Z \
|
||||||
|
-X github.com/minio/minio/cmd.CopyrightYear=2025 \
|
||||||
|
-X github.com/minio/minio/cmd.ReleaseTag=RELEASE.2025-10-15T17-29-55Z \
|
||||||
|
-X github.com/minio/minio/cmd.CommitID=9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a \
|
||||||
|
-X github.com/minio/minio/cmd.ShortCommitID=9e49d5e7a648" \
|
||||||
|
-o /out/minio .
|
||||||
|
|
||||||
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS mc_builder
|
||||||
|
|
||||||
|
ADD --checksum=sha256:95cd293c7119f16921a6dc515a1fb74a2227f19fd994b9c8b770a154e802ac44 \
|
||||||
|
https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \
|
||||||
|
/tmp/mc.tar.gz
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN tar --extract --gzip --file /tmp/mc.tar.gz \
|
||||||
|
--directory . --strip-components=1 \
|
||||||
|
&& go get \
|
||||||
|
github.com/prometheus/prometheus@v0.311.3 \
|
||||||
|
golang.org/x/crypto@v0.52.0 \
|
||||||
|
golang.org/x/net@v0.55.0 \
|
||||||
|
google.golang.org/grpc@v1.81.1 \
|
||||||
|
&& go mod tidy \
|
||||||
|
&& CGO_ENABLED=0 go build \
|
||||||
|
-trimpath \
|
||||||
|
-tags kqueue \
|
||||||
|
-ldflags="-s -w \
|
||||||
|
-X github.com/minio/mc/cmd.Version=2025-08-13T08:35:41Z \
|
||||||
|
-X github.com/minio/mc/cmd.CopyrightYear=2025 \
|
||||||
|
-X github.com/minio/mc/cmd.ReleaseTag=RELEASE.2025-08-13T08-35-41Z \
|
||||||
|
-X github.com/minio/mc/cmd.CommitID=7394ce0dd2a80935aded936b09fa12cbb3cb8096 \
|
||||||
|
-X github.com/minio/mc/cmd.ShortCommitID=7394ce0dd2a8" \
|
||||||
|
-o /out/mc .
|
||||||
|
|
||||||
|
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates=20260611-r0 \
|
||||||
|
libcrypto3=3.5.7-r0 \
|
||||||
|
libssl3=3.5.7-r0 \
|
||||||
|
musl=1.2.5-r23 \
|
||||||
|
musl-utils=1.2.5-r23 \
|
||||||
|
zlib=1.3.2-r0
|
||||||
|
|
||||||
|
USER 10001:10001
|
||||||
|
|
||||||
|
FROM runtime AS server
|
||||||
|
|
||||||
|
COPY --from=minio_builder /out/minio /usr/local/bin/minio
|
||||||
|
|
||||||
|
ENTRYPOINT ["minio"]
|
||||||
|
CMD ["--version"]
|
||||||
|
|
||||||
|
FROM runtime AS client
|
||||||
|
|
||||||
|
COPY --from=mc_builder /out/mc /usr/local/bin/mc
|
||||||
|
|
||||||
|
ENTRYPOINT ["mc"]
|
||||||
|
CMD ["--version"]
|
||||||
23
README.md
23
README.md
|
|
@ -92,6 +92,20 @@ backup_path=$(printf '%s\n' "$backup_output" | sed -n 's/^Backup: //p')
|
||||||
./scripts/restore-drill-compose.sh "$backup_path"
|
./scripts/restore-drill-compose.sh "$backup_path"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For the reproducible encrypted S3-compatible drill, first start the isolated
|
||||||
|
load project and then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/load-stack-up.sh
|
||||||
|
./scripts/backup-s3-drill.sh local-encrypted-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
The command generates MinIO and Restic secrets only in ignored mode-`0600`
|
||||||
|
`.env.load`, streams `pg_dump` directly into an encrypted Restic repository,
|
||||||
|
restores it into a new temporary database, checks corruption and interruption
|
||||||
|
failure paths, removes those temporary buckets, and retains the successful
|
||||||
|
encrypted bucket in local MinIO. It never writes a plaintext dump to the host.
|
||||||
|
|
||||||
The commands, boundaries, and unclaimed production properties are documented
|
The commands, boundaries, and unclaimed production properties are documented
|
||||||
in [the operations runbook](docs/operations.md).
|
in [the operations runbook](docs/operations.md).
|
||||||
|
|
||||||
|
|
@ -151,10 +165,11 @@ an isolated PostgreSQL volume:
|
||||||
It checks shell scripts, Dockerfiles, the GitHub Actions workflow, every Compose
|
It checks shell scripts, Dockerfiles, the GitHub Actions workflow, every Compose
|
||||||
profile, the rendered Helm chart, tracked-source secrets and infrastructure
|
profile, the rendered Helm chart, tracked-source secrets and infrastructure
|
||||||
misconfigurations, Elixir formatting/compilation/xref/Credo/Sobelow/Dialyzer,
|
misconfigurations, Elixir formatting/compilation/xref/Credo/Sobelow/Dialyzer,
|
||||||
retired Hex packages, locked npm dependencies, all Phoenix tests, and the
|
retired Hex packages, locked npm dependencies, all Phoenix tests, the pinned
|
||||||
production release image. Its generated database credentials are random and
|
backup-tool, MinIO server/client, and production release images. Its generated
|
||||||
exist only for that run. The exact database volume, networks, temporary source
|
database credentials are random and exist only for that run. The exact database
|
||||||
snapshot, and one-run images are removed automatically.
|
volume, networks, temporary source snapshot, and one-run images are removed
|
||||||
|
automatically.
|
||||||
|
|
||||||
The cursor-pagination database benchmark also creates a one-run Compose
|
The cursor-pagination database benchmark also creates a one-run Compose
|
||||||
project, random database credentials, and a separate PostgreSQL volume:
|
project, random database credentials, and a separate PostgreSQL volume:
|
||||||
|
|
|
||||||
118
compose.backup.yaml
Normal file
118
compose.backup.yaml
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
services:
|
||||||
|
minio-permissions:
|
||||||
|
image: who-need-help:minio-local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.minio
|
||||||
|
target: server
|
||||||
|
entrypoint: ["/bin/sh", "-euc"]
|
||||||
|
command:
|
||||||
|
- chown -R 10001:10001 /data
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
user: "0:0"
|
||||||
|
read_only: true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
cap_add: [CHOWN]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks: [internal]
|
||||||
|
restart: "no"
|
||||||
|
profiles: [backup]
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: who-need-help:minio-local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.minio
|
||||||
|
target: server
|
||||||
|
command: ["server", "/data", "--console-address", ":9001"]
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${BACKUP_MINIO_ROOT_USER:?Set BACKUP_MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${BACKUP_MINIO_ROOT_PASSWORD:?Set BACKUP_MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
- target: 9000
|
||||||
|
published: "${BACKUP_MINIO_API_PORT:-0}"
|
||||||
|
host_ip: 127.0.0.1
|
||||||
|
protocol: tcp
|
||||||
|
- target: 9001
|
||||||
|
published: "${BACKUP_MINIO_CONSOLE_PORT:-0}"
|
||||||
|
host_ip: 127.0.0.1
|
||||||
|
protocol: tcp
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:9000/minio/health/ready"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
depends_on:
|
||||||
|
minio-permissions:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
user: "10001:10001"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks: [internal, backup_host]
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [backup]
|
||||||
|
|
||||||
|
minio-client:
|
||||||
|
image: who-need-help:mc-local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.minio
|
||||||
|
target: client
|
||||||
|
entrypoint: ["mc"]
|
||||||
|
command: ["--version"]
|
||||||
|
environment:
|
||||||
|
MC_CONFIG_DIR: /tmp/mc
|
||||||
|
MC_HOST_local: http://${BACKUP_MINIO_ROOT_USER:?Set BACKUP_MINIO_ROOT_USER}:${BACKUP_MINIO_ROOT_PASSWORD:?Set BACKUP_MINIO_ROOT_PASSWORD}@minio:9000
|
||||||
|
volumes:
|
||||||
|
- ${BACKUP_RUNTIME_DIR:?Set BACKUP_RUNTIME_DIR for the backup profile}:/work
|
||||||
|
user: "${BACKUP_HOST_UID:?Set BACKUP_HOST_UID}:${BACKUP_HOST_GID:?Set BACKUP_HOST_GID}"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks: [internal]
|
||||||
|
profiles: [backup]
|
||||||
|
|
||||||
|
backup-tools:
|
||||||
|
image: who-need-help:backup-tools
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backup
|
||||||
|
environment:
|
||||||
|
PGHOST: db
|
||||||
|
PGPORT: "5432"
|
||||||
|
PGUSER: ${POSTGRES_USER:?Set POSTGRES_USER}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
|
||||||
|
PGDATABASE: ${POSTGRES_DB:?Set POSTGRES_DB}
|
||||||
|
AWS_ACCESS_KEY_ID: ${BACKUP_MINIO_ROOT_USER:?Set BACKUP_MINIO_ROOT_USER}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${BACKUP_MINIO_ROOT_PASSWORD:?Set BACKUP_MINIO_ROOT_PASSWORD}
|
||||||
|
RESTIC_PASSWORD: ${BACKUP_RESTIC_PASSWORD:?Set BACKUP_RESTIC_PASSWORD}
|
||||||
|
RESTIC_CACHE_DIR: /tmp/restic-cache
|
||||||
|
BACKUP_INTERRUPTION_CHUNKS: ${BACKUP_INTERRUPTION_CHUNKS:?Set BACKUP_INTERRUPTION_CHUNKS}
|
||||||
|
BACKUP_INTERRUPTION_CHUNK_BYTES: ${BACKUP_INTERRUPTION_CHUNK_BYTES:?Set BACKUP_INTERRUPTION_CHUNK_BYTES}
|
||||||
|
BACKUP_INTERRUPTION_INTERVAL_SECONDS: ${BACKUP_INTERRUPTION_INTERVAL_SECONDS:?Set BACKUP_INTERRUPTION_INTERVAL_SECONDS}
|
||||||
|
user: "10001:10001"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
networks: [internal]
|
||||||
|
profiles: [backup]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backup_host:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data:
|
||||||
|
|
@ -42,6 +42,10 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
|
||||||
| Alertmanager | 0.33.1 |
|
| Alertmanager | 0.33.1 |
|
||||||
| Grafana | 13.1.0 |
|
| Grafana | 13.1.0 |
|
||||||
| Python alert-boundary runtime | 3.14.6 / Alpine 3.23 |
|
| Python alert-boundary runtime | 3.14.6 / Alpine 3.23 |
|
||||||
|
| Restic | 0.19.1, rebuilt with Go 1.26.5 |
|
||||||
|
| MinIO server | RELEASE.2025-10-15T17-29-55Z, rebuilt with Go 1.26.5 |
|
||||||
|
| MinIO client | RELEASE.2025-08-13T08-35-41Z, rebuilt with Go 1.26.5 |
|
||||||
|
| Backup runtime | Alpine 3.23.3 / PostgreSQL client 18.4-r0 |
|
||||||
| Debian builder/runner snapshot | trixie-20260713-slim |
|
| Debian builder/runner snapshot | trixie-20260713-slim |
|
||||||
|
|
||||||
Every external Compose/kind service image and every Dockerfile base image is
|
Every external Compose/kind service image and every Dockerfile base image is
|
||||||
|
|
@ -49,6 +53,15 @@ locked to both an exact tag and an OCI digest. The observed local Docker tooling
|
||||||
was Docker Engine/CLI 29.6.2, Compose 5.3.1, and Buildx 0.35.0; their official
|
was Docker Engine/CLI 29.6.2, Compose 5.3.1, and Buildx 0.35.0; their official
|
||||||
release feeds reported those same versions as current during verification.
|
release feeds reported those same versions as current during verification.
|
||||||
|
|
||||||
|
`Dockerfile.minio` fetches checksum-pinned upstream source commits for MinIO
|
||||||
|
server and client and records every dependency override used to rebuild them.
|
||||||
|
The current overrides move Apache Thrift, jsonparser, Prometheus, Go crypto/net,
|
||||||
|
and gRPC dependencies to the versions checked by the image security gate.
|
||||||
|
Both projects are AGPLv3; anyone distributing or operating modified builds must
|
||||||
|
review and satisfy the applicable license obligations. This repository keeps
|
||||||
|
the exact upstream source identifiers and the complete modification/build
|
||||||
|
recipe; this statement is operational documentation, not legal advice.
|
||||||
|
|
||||||
## Android and local Kubernetes
|
## Android and local Kubernetes
|
||||||
|
|
||||||
| Component | Locked version |
|
| Component | Locked version |
|
||||||
|
|
@ -106,6 +119,10 @@ Credo, Dialyxir, and Sobelow are locked in `mix.lock`.
|
||||||
- [Alertmanager releases](https://github.com/prometheus/alertmanager/releases)
|
- [Alertmanager releases](https://github.com/prometheus/alertmanager/releases)
|
||||||
- [Grafana releases](https://github.com/grafana/grafana/releases)
|
- [Grafana releases](https://github.com/grafana/grafana/releases)
|
||||||
- [Python releases](https://www.python.org/downloads/)
|
- [Python releases](https://www.python.org/downloads/)
|
||||||
|
- [Go release history](https://go.dev/doc/devel/release)
|
||||||
|
- [Restic releases](https://github.com/restic/restic/releases)
|
||||||
|
- [MinIO releases](https://github.com/minio/minio/releases)
|
||||||
|
- [MinIO client releases](https://github.com/minio/mc/releases)
|
||||||
- [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes)
|
- [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes)
|
||||||
- [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk)
|
- [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk)
|
||||||
- [Build instrumented tests](https://developer.android.com/training/testing/instrumented-tests)
|
- [Build instrumented tests](https://developer.android.com/training/testing/instrumented-tests)
|
||||||
|
|
@ -136,4 +153,5 @@ docker run --rm who-need-help:node-deps npm outdated --json
|
||||||
./scripts/android-build.sh
|
./scripts/android-build.sh
|
||||||
./scripts/android-instrumentation-test.sh
|
./scripts/android-instrumentation-test.sh
|
||||||
./scripts/observability-run.sh local-observability
|
./scripts/observability-run.sh local-observability
|
||||||
|
./scripts/backup-s3-drill.sh local-encrypted-backup
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ item below unless the evidence column explicitly describes a local mock.
|
||||||
| Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset |
|
| Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset |
|
||||||
| Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data |
|
| Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data |
|
||||||
| Observability | Completed locally: protected per-process metrics feed a pinned Prometheus/Grafana/Alertmanager profile | All 3 direct web targets are up before/after the drill; a verified replica stop delivers firing and resolved webhooks; Grafana datasource/dashboard and an empty DB-count diff are retained |
|
| Observability | Completed locally: protected per-process metrics feed a pinned Prometheus/Grafana/Alertmanager profile | All 3 direct web targets are up before/after the drill; a verified replica stop delivers firing and resolved webhooks; Grafana datasource/dashboard and an empty DB-count diff are retained |
|
||||||
| Backup | Validated local custom-format dump and restore drill exist | An encrypted artifact is uploaded to local S3-compatible MinIO and restored into a fresh database; corruption and interrupted-upload checks fail closed |
|
| Backup | Completed locally: a plaintext-free Restic stream is retained in pinned local MinIO | The encrypted repository passes full-data checking and fresh-database restore; corrupted configuration and an interrupted upload fail closed, leave no snapshot, and their temporary buckets are removed |
|
||||||
| External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout |
|
| External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout |
|
||||||
| Final regression | 163 Phoenix tests plus reproducible browser and Android device suites | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits |
|
| Final regression | 163 Phoenix tests plus reproducible browser and Android device suites | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits |
|
||||||
|
|
||||||
|
|
@ -94,5 +94,12 @@ The goal remains open while any row lacks reproducible local evidence.
|
||||||
dashboard, and routes the `up == 0` rule through Alertmanager. The canonical
|
dashboard, and routes the `up == 0` rule through Alertmanager. The canonical
|
||||||
drill observed both firing and resolved webhooks for the exact stopped
|
drill observed both firing and resolved webhooks for the exact stopped
|
||||||
replica, restored all three targets, and left database counts unchanged.
|
replica, restored all three targets, and left database counts unchanged.
|
||||||
|
- The encrypted backup profile streams PostgreSQL custom format through Restic
|
||||||
|
directly into local MinIO without a host plaintext archive. The canonical
|
||||||
|
drill restored 23 public tables with all 10 migrations and PostGIS 3.6.4,
|
||||||
|
rejected an intentionally corrupted repository, stopped a scoped upload
|
||||||
|
after an encrypted object reached MinIO, published no interrupted snapshot,
|
||||||
|
pruned the unreferenced packs, removed both temporary buckets and the
|
||||||
|
temporary restore database, and left source table counts unchanged.
|
||||||
- The remaining rows above are still pending; this document is not a
|
- The remaining rows above are still pending; this document is not a
|
||||||
completion claim for the entire hardening goal.
|
completion claim for the entire hardening goal.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,62 @@ globals such as roles and tablespaces are not part of `pg_dump`; deployment
|
||||||
credentials and database roles must be provisioned separately from secrets.
|
credentials and database roles must be provisioned separately from secrets.
|
||||||
Local backup files on the same workstation are not an off-site backup.
|
Local backup files on the same workstation are not an off-site backup.
|
||||||
|
|
||||||
|
## Encrypted local S3-compatible backup drill
|
||||||
|
|
||||||
|
The isolated load project can run a complete encrypted Restic/MinIO drill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/load-stack-up.sh
|
||||||
|
./scripts/backup-s3-drill.sh local-encrypted-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
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,
|
||||||
|
`scripts/ensure-local-load-env.sh` generates independent random MinIO and
|
||||||
|
Restic credentials in ignored `.env.load` and restricts that file to mode
|
||||||
|
`0600`. MinIO publishes Docker-assigned ports only on `127.0.0.1`; the observed
|
||||||
|
API and console URLs are printed after a successful run.
|
||||||
|
|
||||||
|
The backup tool combines the matching PostgreSQL 18 client with pinned Restic
|
||||||
|
rebuilt on Go 1.26.5. MinIO server and client are also rebuilt as non-root
|
||||||
|
Alpine images from checksum-pinned upstream source commits with the exact
|
||||||
|
dependency updates recorded in `Dockerfile.minio`. The quality gate verifies
|
||||||
|
their reported release, commit, Go runtime, configured user, and current
|
||||||
|
HIGH/CRITICAL vulnerability scan. `restic backup --stdin-from-command` runs a
|
||||||
|
custom-format `pg_dump`, checks the producer exit status, encrypts the data,
|
||||||
|
and uploads it directly to MinIO. No plaintext database dump is written to the
|
||||||
|
host. The drill then:
|
||||||
|
|
||||||
|
1. runs `restic check --read-data`;
|
||||||
|
2. streams `restic dump` into `pg_restore --list`;
|
||||||
|
3. restores into a uniquely named database created from `template0`;
|
||||||
|
4. checks tables, current Ecto migrations, PostGIS, and release migration
|
||||||
|
readiness before removing that exact database;
|
||||||
|
5. clones and corrupts an isolated repository and requires both check and dump
|
||||||
|
to fail;
|
||||||
|
6. stops an exact scoped in-progress backup container only after encrypted
|
||||||
|
objects reach MinIO, requires zero published snapshots, prunes unreferenced
|
||||||
|
packs, and rechecks the repository;
|
||||||
|
7. removes and verifies removal of the corruption/interruption buckets and
|
||||||
|
requires source table counts to remain unchanged.
|
||||||
|
|
||||||
|
The successful encrypted bucket is deliberately retained in the named local
|
||||||
|
MinIO volume. Non-secret evidence is written under ignored
|
||||||
|
`output/backups-s3/<run-label>/`; the runtime scratch directory is under
|
||||||
|
ignored `tmp/backup-s3/`. Use a unique lowercase run label of at most 32
|
||||||
|
characters. The command refuses to replace an existing retained bucket.
|
||||||
|
|
||||||
|
MinIO server and client are AGPLv3. `Dockerfile.minio` identifies the exact
|
||||||
|
upstream source commits and contains the dependency changes and complete build
|
||||||
|
commands used here. Before distributing or publicly operating modified images,
|
||||||
|
review the license and make the corresponding source available as required;
|
||||||
|
this runbook does not provide legal advice.
|
||||||
|
|
||||||
|
This verifies encryption, local S3 protocol use, restore mechanics, and two
|
||||||
|
failure paths on the observed workstation. A MinIO volume on that same
|
||||||
|
workstation is not an off-site backup and does not establish production RPO,
|
||||||
|
RTO, retention, capacity, key custody, object locking, or database HA.
|
||||||
|
|
||||||
## Isolated restore drill
|
## Isolated restore drill
|
||||||
|
|
||||||
Run a real restore into a uniquely named temporary database:
|
Run a real restore into a uniquely named temporary database:
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ results from product limits and unknown production properties.
|
||||||
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
|
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
|
||||||
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The isolated Compose profile passed BEAM crashes and sequential replacement with 3 web/2 worker replicas, all five nodes joined, PubSub passed, and 743/743 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined and PubSub passed. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
|
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The isolated Compose profile passed BEAM crashes and sequential replacement with 3 web/2 worker replicas, all five nodes joined, PubSub passed, and 743/743 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined and PubSub passed. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
|
||||||
| Local observability | Implemented and protocol-verified | Pinned Prometheus scraped all 3 direct load web targets with a file Bearer credential; Grafana provisioned a healthy datasource and four-panel dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. |
|
| Local observability | Implemented and protocol-verified | Pinned Prometheus scraped all 3 direct load web targets with a file Bearer credential; Grafana provisioned a healthy datasource and four-panel dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. |
|
||||||
|
| Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. | The retained MinIO volume is on the same workstation; this is not off-site storage, database HA, or a production RPO/RTO/retention claim. |
|
||||||
|
|
||||||
## Reproducible checks
|
## Reproducible checks
|
||||||
|
|
||||||
|
|
@ -31,12 +32,15 @@ results from product limits and unknown production properties.
|
||||||
- `mix compile --force --warnings-as-errors` and
|
- `mix compile --force --warnings-as-errors` and
|
||||||
`mix format --check-formatted`: passed against the same final source.
|
`mix format --check-formatted`: passed against the same final source.
|
||||||
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
|
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
|
||||||
threshold, actionlint 1.7.12, all five Compose renders, Helm lint, Trivy
|
threshold, actionlint 1.7.12, all six Compose renders, Helm lint, Trivy
|
||||||
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
|
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
|
||||||
strict/private checks, Hex audit, 163 Phoenix tests, both npm audits, and the
|
strict/private checks, Hex audit, 163 Phoenix tests, both npm audits, and the
|
||||||
production-image vulnerability scan. The rendered Helm manifest and Debian
|
backup/MinIO/mc/release-image vulnerability scans. The rendered Helm manifest
|
||||||
13.6 release image each reported zero HIGH/CRITICAL findings under the
|
reported zero HIGH/CRITICAL misconfigurations; the Alpine backup, MinIO, and
|
||||||
configured gates.
|
mc images and the Debian 13.6 release image each reported zero HIGH/CRITICAL
|
||||||
|
vulnerabilities under the configured gates. The backup binary reported
|
||||||
|
Restic 0.19.1 compiled with Go 1.26.5; MinIO and mc reported their pinned
|
||||||
|
commits and Go 1.26.5.
|
||||||
- Dialyzer passed with three path- and warning-specific documented filters and
|
- Dialyzer passed with three path- and warning-specific documented filters and
|
||||||
zero unused filters. Two findings are opaque-type warnings at Ecto
|
zero unused filters. Two findings are opaque-type warnings at Ecto
|
||||||
`Multi`/`MapSet` call sites; the third is the generated Gettext backend's
|
`Multi`/`MapSet` call sites; the third is the generated Gettext backend's
|
||||||
|
|
@ -90,6 +94,19 @@ results from product limits and unknown production properties.
|
||||||
running, and the checked user/request/message counts were identical before
|
running, and the checked user/request/message counts were identical before
|
||||||
and after. A subsequent full drill returned all monitoring services to
|
and after. A subsequent full drill returned all monitoring services to
|
||||||
healthy.
|
healthy.
|
||||||
|
- The encrypted S3 canonical drill restored 23 public application tables, all
|
||||||
|
10 current migrations, 14 categories, and PostGIS 3.6.4 into a fresh
|
||||||
|
temporary database. `restic check --read-data` passed for the retained
|
||||||
|
repository. Flipping the first byte of an isolated cloned repository made
|
||||||
|
both `check` and `dump` exit nonzero. Stopping the exact backup container
|
||||||
|
after uploaded encrypted objects produced exit 130 and zero snapshots;
|
||||||
|
Restic identified and pruned 32.809 MiB of unreferenced packs, then passed a
|
||||||
|
second full-data check. Only the retained canonical bucket remained, the
|
||||||
|
restore database and interruption container were absent, no configured
|
||||||
|
secret appeared in retained evidence, and the source database count diff was
|
||||||
|
empty. The obsolete successful-run bucket was then removed; only the final
|
||||||
|
canonical bucket remains. Evidence is retained at
|
||||||
|
`output/backups-s3/backup-canonical-20260719d`.
|
||||||
- The committed browser suite passed its 1/1 bootstrap and all 8/8 Chromium
|
- The committed browser suite passed its 1/1 bootstrap and all 8/8 Chromium
|
||||||
specs against a fresh PostGIS volume with two web and two worker replicas on
|
specs against a fresh PostGIS volume with two web and two worker replicas on
|
||||||
2026-07-19. The retained successful-run artifact directory is
|
2026-07-19. The retained successful-run artifact directory is
|
||||||
|
|
|
||||||
631
scripts/backup-s3-drill.sh
Executable file
631
scripts/backup-s3-drill.sh
Executable file
|
|
@ -0,0 +1,631 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
ENV_FILE="$ROOT/.env.load"
|
||||||
|
LABEL=${1:-"backup-$(date -u +%Y%m%d%H%M%S)"}
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
for name in LOAD_PROJECT POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD DATABASE_URL \
|
||||||
|
BACKUP_MINIO_API_PORT BACKUP_MINIO_CONSOLE_PORT BACKUP_MINIO_ROOT_USER \
|
||||||
|
BACKUP_MINIO_ROOT_PASSWORD BACKUP_RESTIC_PASSWORD BACKUP_BUCKET_PREFIX \
|
||||||
|
BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \
|
||||||
|
BACKUP_INTERRUPTION_CHUNK_BYTES BACKUP_INTERRUPTION_INTERVAL_SECONDS; do
|
||||||
|
if [[ -z "${!name:-}" ]]; then
|
||||||
|
echo "$name is missing from .env.load" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
|
||||||
|
echo "The encrypted backup drill must not use the staging Compose project." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$LABEL" =~ ^[a-z0-9][a-z0-9-]{0,31}$ ]]; then
|
||||||
|
echo "Run label must be 1-32 lowercase letters, numbers, or dashes." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$BACKUP_BUCKET_PREFIX" =~ ^[a-z0-9][a-z0-9-]{1,19}$ ]]; then
|
||||||
|
echo "BACKUP_BUCKET_PREFIX must be 2-20 lowercase letters, numbers, or dashes." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for name in BACKUP_MINIO_API_PORT BACKUP_MINIO_CONSOLE_PORT; do
|
||||||
|
if [[ ! "${!name}" =~ ^[0-9]+$ ]] || ((10#${!name} > 65535)); then
|
||||||
|
echo "$name must be a TCP port number from 0 through 65535." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for name in BACKUP_TIMEOUT_SECONDS BACKUP_INTERRUPTION_CHUNKS \
|
||||||
|
BACKUP_INTERRUPTION_CHUNK_BYTES; do
|
||||||
|
if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "$name must be a positive integer." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! awk -v value="$BACKUP_INTERRUPTION_INTERVAL_SECONDS" \
|
||||||
|
'BEGIN {exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value > 0)}'; then
|
||||||
|
echo "BACKUP_INTERRUPTION_INTERVAL_SECONDS must be greater than zero." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for command in awk dd diff docker grep head jq od sleep tr unlink; do
|
||||||
|
if ! command -v "$command" >/dev/null 2>&1; then
|
||||||
|
echo "Required command is unavailable: $command" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
bucket="$BACKUP_BUCKET_PREFIX-$LABEL"
|
||||||
|
corrupt_bucket="$bucket-corrupt"
|
||||||
|
interrupt_bucket="$bucket-interrupt"
|
||||||
|
|
||||||
|
for candidate in "$bucket" "$corrupt_bucket" "$interrupt_bucket"; do
|
||||||
|
if ((${#candidate} > 63)); then
|
||||||
|
echo "Derived S3 bucket name exceeds 63 characters: $candidate" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
runtime_dir="$ROOT/tmp/backup-s3/$LOAD_PROJECT/$LABEL"
|
||||||
|
output_dir="$ROOT/output/backups-s3/$LABEL"
|
||||||
|
mkdir -p "$runtime_dir" "$output_dir"
|
||||||
|
chmod 700 "$ROOT/tmp" "$ROOT/tmp/backup-s3" \
|
||||||
|
"$ROOT/tmp/backup-s3/$LOAD_PROJECT" "$runtime_dir" \
|
||||||
|
"$ROOT/output" "$ROOT/output/backups-s3" "$output_dir"
|
||||||
|
|
||||||
|
export BACKUP_RUNTIME_DIR="$runtime_dir"
|
||||||
|
export BACKUP_HOST_UID
|
||||||
|
export BACKUP_HOST_GID
|
||||||
|
BACKUP_HOST_UID=$(id -u)
|
||||||
|
BACKUP_HOST_GID=$(id -g)
|
||||||
|
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--env-file "$ENV_FILE"
|
||||||
|
-p "$LOAD_PROJECT"
|
||||||
|
-f compose.yaml
|
||||||
|
-f compose.load.yaml
|
||||||
|
-f compose.backup.yaml
|
||||||
|
--profile backup
|
||||||
|
)
|
||||||
|
|
||||||
|
service_ids() {
|
||||||
|
"${compose[@]}" ps --all -q "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_scope() {
|
||||||
|
local container_id=$1
|
||||||
|
local expected_service=$2
|
||||||
|
local observed_project observed_service
|
||||||
|
|
||||||
|
observed_project=$(
|
||||||
|
docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' \
|
||||||
|
"$container_id"
|
||||||
|
)
|
||||||
|
observed_service=$(
|
||||||
|
docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}}' \
|
||||||
|
"$container_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$observed_project" != "$LOAD_PROJECT" ||
|
||||||
|
"$observed_service" != "$expected_service" ]]; then
|
||||||
|
echo "Container scope mismatch for $container_id." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mc_run() {
|
||||||
|
"${compose[@]}" run --rm --no-deps -T minio-client "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
restic_run_repo() {
|
||||||
|
local repository=$1
|
||||||
|
shift
|
||||||
|
"${compose[@]}" run --rm --no-deps -T \
|
||||||
|
--env "RESTIC_REPOSITORY=$repository" \
|
||||||
|
backup-tools restic "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
database_snapshot() {
|
||||||
|
# Variables are intentionally expanded inside the isolated PostGIS container.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
"${compose[@]}" exec -T db sh -c \
|
||||||
|
'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \
|
||||||
|
--username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' >"$1" <<'SQL'
|
||||||
|
BEGIN READ ONLY;
|
||||||
|
SELECT 'users' AS table_name, count(*) AS row_count FROM users
|
||||||
|
UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens
|
||||||
|
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
|
||||||
|
UNION ALL SELECT 'messages', count(*) FROM messages
|
||||||
|
UNION ALL SELECT 'categories', count(*) FROM categories
|
||||||
|
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
|
||||||
|
UNION ALL SELECT 'activities', count(*) FROM activities
|
||||||
|
UNION ALL SELECT 'reports', count(*) FROM reports
|
||||||
|
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
|
||||||
|
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
|
||||||
|
UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions
|
||||||
|
UNION ALL SELECT 'schema_migrations', count(*) FROM schema_migrations
|
||||||
|
ORDER BY table_name;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_minio() {
|
||||||
|
local minio_id=$1
|
||||||
|
local deadline=$((SECONDS + BACKUP_TIMEOUT_SECONDS))
|
||||||
|
|
||||||
|
while ((SECONDS < deadline)); do
|
||||||
|
local state health
|
||||||
|
state=$(docker inspect --format '{{.State.Status}}' "$minio_id")
|
||||||
|
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' \
|
||||||
|
"$minio_id")
|
||||||
|
|
||||||
|
if [[ "$state" == "running" && "$health" == "healthy" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for the scoped MinIO container." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_bucket() {
|
||||||
|
local candidate=$1
|
||||||
|
mc_run rb --force "local/$candidate" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_bucket_and_verify() {
|
||||||
|
local candidate=$1
|
||||||
|
|
||||||
|
mc_run rb --force "local/$candidate" >/dev/null
|
||||||
|
|
||||||
|
if mc_run stat "local/$candidate" >/dev/null 2>&1; then
|
||||||
|
echo "Temporary MinIO bucket was not removed: $candidate" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_interrupt_container() {
|
||||||
|
if [[ -n "${interrupt_container_id:-}" ]] &&
|
||||||
|
docker inspect "$interrupt_container_id" >/dev/null 2>&1; then
|
||||||
|
assert_scope "$interrupt_container_id" backup-tools
|
||||||
|
docker stop --time 1 "$interrupt_container_id" >/dev/null 2>&1 || true
|
||||||
|
docker rm "$interrupt_container_id" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
drop_drill_database() {
|
||||||
|
if [[ "${drill_database_created:-false}" == "true" ]]; then
|
||||||
|
"${compose[@]}" exec -T db \
|
||||||
|
dropdb --username "$POSTGRES_USER" --if-exists "$drill_db" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
drop_drill_database
|
||||||
|
remove_interrupt_container
|
||||||
|
|
||||||
|
if [[ "${corrupt_bucket_created:-false}" == "true" ]]; then
|
||||||
|
remove_bucket "$corrupt_bucket"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${interrupt_bucket_created:-false}" == "true" ]]; then
|
||||||
|
remove_bucket "$interrupt_bucket"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
for service in minio-permissions minio minio-client backup-tools; do
|
||||||
|
while IFS= read -r existing_id; do
|
||||||
|
[[ -n "$existing_id" ]] && assert_scope "$existing_id" "$service"
|
||||||
|
done < <(service_ids "$service")
|
||||||
|
done
|
||||||
|
|
||||||
|
database_snapshot "$output_dir/database-before.txt"
|
||||||
|
|
||||||
|
"${compose[@]}" build backup-tools minio minio-client \
|
||||||
|
>"$output_dir/backup-images-build.txt"
|
||||||
|
"${compose[@]}" up -d --wait minio >"$output_dir/minio-up.txt"
|
||||||
|
minio_id=$(service_ids minio | head -n 1)
|
||||||
|
assert_scope "$minio_id" minio
|
||||||
|
wait_for_minio "$minio_id"
|
||||||
|
|
||||||
|
if mc_run stat "local/$bucket" >/dev/null 2>&1; then
|
||||||
|
echo "Refusing to overwrite retained canonical bucket: $bucket" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mc_run mb "local/$bucket" >"$output_dir/canonical-bucket-create.txt"
|
||||||
|
canonical_repository="s3:http://minio:9000/$bucket"
|
||||||
|
restic_run_repo "$canonical_repository" init >"$output_dir/restic-init.txt"
|
||||||
|
|
||||||
|
restic_run_repo "$canonical_repository" \
|
||||||
|
backup \
|
||||||
|
--json \
|
||||||
|
--host who-need-help-local \
|
||||||
|
--tag "$LABEL" \
|
||||||
|
--stdin-filename database.dump \
|
||||||
|
--stdin-from-command \
|
||||||
|
-- \
|
||||||
|
pg_dump \
|
||||||
|
--host db \
|
||||||
|
--username "$POSTGRES_USER" \
|
||||||
|
--dbname "$POSTGRES_DB" \
|
||||||
|
--format custom \
|
||||||
|
--no-owner \
|
||||||
|
>"$output_dir/backup.jsonl"
|
||||||
|
|
||||||
|
restic_run_repo "$canonical_repository" snapshots --json --tag "$LABEL" \
|
||||||
|
>"$output_dir/snapshots.json"
|
||||||
|
|
||||||
|
if ! jq -e --arg label "$LABEL" '
|
||||||
|
length == 1 and
|
||||||
|
.[0].tags == [$label] and
|
||||||
|
(.[0].id | length) == 64
|
||||||
|
' "$output_dir/snapshots.json" >/dev/null; then
|
||||||
|
echo "The canonical encrypted backup did not create exactly one tagged snapshot." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
snapshot_id=$(jq -r '.[0].id' "$output_dir/snapshots.json")
|
||||||
|
|
||||||
|
restic_run_repo "$canonical_repository" check --read-data \
|
||||||
|
>"$output_dir/canonical-check.txt" 2>&1
|
||||||
|
restic_run_repo "$canonical_repository" ls --json "$snapshot_id" \
|
||||||
|
>"$output_dir/snapshot-files.jsonl"
|
||||||
|
|
||||||
|
if ! jq -s -e '
|
||||||
|
any(
|
||||||
|
.[];
|
||||||
|
.struct_type == "node" and
|
||||||
|
.path == "/database.dump" and
|
||||||
|
.type == "file" and
|
||||||
|
.size > 0
|
||||||
|
)
|
||||||
|
' "$output_dir/snapshot-files.jsonl" >/dev/null; then
|
||||||
|
echo "The encrypted snapshot does not contain a non-empty database.dump." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mc_run ls --recursive --json "local/$bucket" \
|
||||||
|
>"$output_dir/minio-objects.jsonl"
|
||||||
|
|
||||||
|
if ! jq -s -e '
|
||||||
|
length > 0 and
|
||||||
|
any(.[]; (.key // "") | startswith("data/"))
|
||||||
|
' "$output_dir/minio-objects.jsonl" >/dev/null; then
|
||||||
|
echo "MinIO did not retain encrypted Restic data objects." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pack_key=$(
|
||||||
|
jq -sr '
|
||||||
|
[.[] | select((.key // "") | startswith("data/")) | .key]
|
||||||
|
| sort
|
||||||
|
| first
|
||||||
|
' "$output_dir/minio-objects.jsonl"
|
||||||
|
)
|
||||||
|
|
||||||
|
mc_run cat "local/$bucket/$pack_key" |
|
||||||
|
dd of="$runtime_dir/encrypted-prefix.bin" bs=1 count=16 status=none
|
||||||
|
od -An -tx1 -v "$runtime_dir/encrypted-prefix.bin" |
|
||||||
|
tr -d ' \n' >"$output_dir/encrypted-prefix.hex"
|
||||||
|
|
||||||
|
if grep -aFq 'PGDMP' "$runtime_dir/encrypted-prefix.bin"; then
|
||||||
|
echo "A Restic data object exposed the PostgreSQL custom-format header." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
restic_run_repo "$canonical_repository" dump "$snapshot_id" /database.dump |
|
||||||
|
"${compose[@]}" exec -T db pg_restore --list \
|
||||||
|
>"$output_dir/pg-restore-list.txt"
|
||||||
|
|
||||||
|
drill_db="wnh_s3_restore_${LABEL//-/_}_$$"
|
||||||
|
"${compose[@]}" exec -T db \
|
||||||
|
createdb \
|
||||||
|
--username "$POSTGRES_USER" \
|
||||||
|
--template template0 \
|
||||||
|
"$drill_db"
|
||||||
|
drill_database_created=true
|
||||||
|
|
||||||
|
restic_run_repo "$canonical_repository" dump "$snapshot_id" /database.dump |
|
||||||
|
"${compose[@]}" exec -T db \
|
||||||
|
pg_restore \
|
||||||
|
--username "$POSTGRES_USER" \
|
||||||
|
--dbname "$drill_db" \
|
||||||
|
--exit-on-error \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges
|
||||||
|
|
||||||
|
"${compose[@]}" exec -T db \
|
||||||
|
psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \
|
||||||
|
--username "$POSTGRES_USER" --dbname "$drill_db" \
|
||||||
|
>"$output_dir/restored-database.json" <<'SQL'
|
||||||
|
SELECT json_build_object(
|
||||||
|
'public_table_count',
|
||||||
|
(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename <> 'spatial_ref_sys'
|
||||||
|
),
|
||||||
|
'migration_count', (SELECT count(*) FROM schema_migrations),
|
||||||
|
'user_count', (SELECT count(*) FROM users),
|
||||||
|
'request_count', (SELECT count(*) FROM help_requests),
|
||||||
|
'message_count', (SELECT count(*) FROM messages),
|
||||||
|
'category_count', (SELECT count(*) FROM categories),
|
||||||
|
'postgis_version', PostGIS_Lib_Version()
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
if ! jq -e '
|
||||||
|
.public_table_count > 0 and
|
||||||
|
.migration_count > 0 and
|
||||||
|
.category_count > 0 and
|
||||||
|
(.postgis_version | type == "string" and length > 0)
|
||||||
|
' "$output_dir/restored-database.json" >/dev/null; then
|
||||||
|
echo "The fresh restore database failed structural validation." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$DATABASE_URL" in
|
||||||
|
*\?*)
|
||||||
|
database_base=${DATABASE_URL%%\?*}
|
||||||
|
database_query="?${DATABASE_URL#*\?}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
database_base=$DATABASE_URL
|
||||||
|
database_query=
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
database_prefix=${database_base%/*}
|
||||||
|
|
||||||
|
if [[ "$database_prefix" == "$database_base" ]]; then
|
||||||
|
echo "Could not derive the temporary drill DATABASE_URL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
drill_database_url="$database_prefix/$drill_db$database_query"
|
||||||
|
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env "DATABASE_URL=$drill_database_url" \
|
||||||
|
migrate /app/bin/migrate \
|
||||||
|
>"$output_dir/restore-migrate.txt"
|
||||||
|
|
||||||
|
"${compose[@]}" run --rm --no-deps \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env "DATABASE_URL=$drill_database_url" \
|
||||||
|
migrate /app/bin/who_need_help eval 'WhoNeedHelp.Release.await_migrations()' \
|
||||||
|
>"$output_dir/restore-migration-readiness.txt"
|
||||||
|
|
||||||
|
"${compose[@]}" exec -T db \
|
||||||
|
dropdb --username "$POSTGRES_USER" "$drill_db"
|
||||||
|
drill_database_created=false
|
||||||
|
|
||||||
|
remaining=$(
|
||||||
|
"${compose[@]}" exec -T db \
|
||||||
|
psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \
|
||||||
|
--username "$POSTGRES_USER" --dbname postgres \
|
||||||
|
--command "SELECT count(*) FROM pg_database WHERE datname = '$drill_db';"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$remaining" != "0" ]]; then
|
||||||
|
echo "Restore drill database was not removed: $drill_db" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mc_run mb "local/$corrupt_bucket" >"$output_dir/corrupt-bucket-create.txt"
|
||||||
|
corrupt_bucket_created=true
|
||||||
|
mc_run mirror "local/$bucket" "local/$corrupt_bucket" \
|
||||||
|
>"$output_dir/corrupt-mirror.txt"
|
||||||
|
mc_run cp "local/$corrupt_bucket/config" /work/corrupt-config \
|
||||||
|
>"$output_dir/corrupt-download.txt"
|
||||||
|
|
||||||
|
first_byte=$(od -An -tu1 -N1 "$runtime_dir/corrupt-config" | tr -d ' ')
|
||||||
|
|
||||||
|
if [[ "$first_byte" == "0" ]]; then
|
||||||
|
printf '\001' | dd of="$runtime_dir/corrupt-config" bs=1 count=1 conv=notrunc status=none
|
||||||
|
else
|
||||||
|
printf '\000' | dd of="$runtime_dir/corrupt-config" bs=1 count=1 conv=notrunc status=none
|
||||||
|
fi
|
||||||
|
|
||||||
|
mc_run cp /work/corrupt-config "local/$corrupt_bucket/config" \
|
||||||
|
>"$output_dir/corrupt-upload.txt"
|
||||||
|
corrupt_repository="s3:http://minio:9000/$corrupt_bucket"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
restic_run_repo "$corrupt_repository" check --read-data \
|
||||||
|
>"$output_dir/corrupt-check.txt" 2>&1
|
||||||
|
corrupt_check_status=$?
|
||||||
|
restic_run_repo "$corrupt_repository" dump "$snapshot_id" /database.dump \
|
||||||
|
>"/dev/null" 2>"$output_dir/corrupt-restore.txt"
|
||||||
|
corrupt_restore_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$corrupt_check_status" -eq 0 || "$corrupt_restore_status" -eq 0 ]]; then
|
||||||
|
echo "The corrupted encrypted repository did not fail closed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
remove_bucket_and_verify "$corrupt_bucket"
|
||||||
|
corrupt_bucket_created=false
|
||||||
|
unlink "$runtime_dir/corrupt-config"
|
||||||
|
|
||||||
|
mc_run mb "local/$interrupt_bucket" >"$output_dir/interrupt-bucket-create.txt"
|
||||||
|
interrupt_bucket_created=true
|
||||||
|
interrupt_repository="s3:http://minio:9000/$interrupt_bucket"
|
||||||
|
restic_run_repo "$interrupt_repository" init >"$output_dir/interrupt-init.txt"
|
||||||
|
|
||||||
|
interrupt_container="$LOAD_PROJECT-backup-interrupt-$LABEL"
|
||||||
|
|
||||||
|
if docker inspect "$interrupt_container" >/dev/null 2>&1; then
|
||||||
|
echo "Refusing to replace an existing interruption container: $interrupt_container" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The producer variables must expand inside the isolated backup-tools container.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
interrupt_container_id=$(
|
||||||
|
"${compose[@]}" run -d \
|
||||||
|
--name "$interrupt_container" \
|
||||||
|
--no-deps \
|
||||||
|
--env "RESTIC_REPOSITORY=$interrupt_repository" \
|
||||||
|
backup-tools \
|
||||||
|
restic backup \
|
||||||
|
--json \
|
||||||
|
--host who-need-help-local \
|
||||||
|
--tag "$LABEL-interrupted" \
|
||||||
|
--stdin-filename interrupted.bin \
|
||||||
|
--stdin-from-command \
|
||||||
|
-- \
|
||||||
|
/bin/sh -euc \
|
||||||
|
'i=0
|
||||||
|
while [ "$i" -lt "$BACKUP_INTERRUPTION_CHUNKS" ]; do
|
||||||
|
head -c "$BACKUP_INTERRUPTION_CHUNK_BYTES" /dev/urandom
|
||||||
|
sleep "$BACKUP_INTERRUPTION_INTERVAL_SECONDS"
|
||||||
|
i=$((i + 1))
|
||||||
|
done'
|
||||||
|
)
|
||||||
|
assert_scope "$interrupt_container_id" backup-tools
|
||||||
|
|
||||||
|
upload_deadline=$((SECONDS + BACKUP_TIMEOUT_SECONDS))
|
||||||
|
uploaded_data_objects=0
|
||||||
|
|
||||||
|
while ((SECONDS < upload_deadline)); do
|
||||||
|
mc_run ls --recursive --json "local/$interrupt_bucket/data" \
|
||||||
|
>"$output_dir/interrupt-objects-current.jsonl" 2>/dev/null || true
|
||||||
|
uploaded_data_objects=$(
|
||||||
|
jq -s '[.[] | select(.type == "file")] | length' \
|
||||||
|
"$output_dir/interrupt-objects-current.jsonl"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((uploaded_data_objects > 0)); then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(docker inspect --format '{{.State.Running}}' "$interrupt_container_id")" != "true" ]]; then
|
||||||
|
echo "Interruption producer exited before an uploaded data object was observed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((uploaded_data_objects == 0)); then
|
||||||
|
echo "Timed out waiting for the interruption upload to reach MinIO." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker stop --time 1 "$interrupt_container_id" >"$output_dir/interrupt-stop.txt"
|
||||||
|
docker inspect "$interrupt_container_id" |
|
||||||
|
jq '.[0] | {
|
||||||
|
id: .Id,
|
||||||
|
project: .Config.Labels["com.docker.compose.project"],
|
||||||
|
service: .Config.Labels["com.docker.compose.service"],
|
||||||
|
exit_code: .State.ExitCode,
|
||||||
|
state: .State.Status
|
||||||
|
}' >"$output_dir/interrupt-container.json"
|
||||||
|
docker logs "$interrupt_container_id" >"$output_dir/interrupt-backup.log" 2>&1
|
||||||
|
|
||||||
|
if ! jq -e '.exit_code != 0 and .state == "exited"' \
|
||||||
|
"$output_dir/interrupt-container.json" >/dev/null; then
|
||||||
|
echo "The interrupted upload container did not exit unsuccessfully." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
restic_run_repo "$interrupt_repository" snapshots \
|
||||||
|
--json --tag "$LABEL-interrupted" \
|
||||||
|
>"$output_dir/interrupt-snapshots.json"
|
||||||
|
|
||||||
|
if ! jq -e 'length == 0' "$output_dir/interrupt-snapshots.json" >/dev/null; then
|
||||||
|
echo "An interrupted upload published a restorable snapshot." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mc_run ls --incomplete --recursive --json "local/$interrupt_bucket" \
|
||||||
|
>"$output_dir/interrupt-incomplete-uploads.jsonl" 2>/dev/null || true
|
||||||
|
restic_run_repo "$interrupt_repository" check --read-data \
|
||||||
|
>"$output_dir/interrupt-check-before-prune.txt" 2>&1
|
||||||
|
restic_run_repo "$interrupt_repository" prune \
|
||||||
|
>"$output_dir/interrupt-prune.txt" 2>&1
|
||||||
|
restic_run_repo "$interrupt_repository" check --read-data \
|
||||||
|
>"$output_dir/interrupt-check-after-prune.txt" 2>&1
|
||||||
|
|
||||||
|
docker rm "$interrupt_container_id" >"$output_dir/interrupt-remove.txt"
|
||||||
|
interrupt_container_id=
|
||||||
|
remove_bucket_and_verify "$interrupt_bucket"
|
||||||
|
interrupt_bucket_created=false
|
||||||
|
|
||||||
|
database_snapshot "$output_dir/database-after.txt"
|
||||||
|
|
||||||
|
if ! diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \
|
||||||
|
>"$output_dir/database-diff.txt"; then
|
||||||
|
echo "The encrypted backup drill changed source database counts." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
minio_api=$("${compose[@]}" port minio 9000 | head -n 1)
|
||||||
|
minio_console=$("${compose[@]}" port minio 9001 | head -n 1)
|
||||||
|
|
||||||
|
if [[ ! "$minio_api" =~ ^127[.]0[.]0[.]1:[1-9][0-9]*$ ||
|
||||||
|
! "$minio_console" =~ ^127[.]0[.]0[.]1:[1-9][0-9]*$ ]]; then
|
||||||
|
echo "MinIO ports are not bound to observed loopback addresses." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg run_id "$LABEL" \
|
||||||
|
--arg bucket "$bucket" \
|
||||||
|
--arg snapshot_id "$snapshot_id" \
|
||||||
|
--arg minio_api "http://$minio_api" \
|
||||||
|
--arg minio_console "http://$minio_console" \
|
||||||
|
--argjson corrupt_check_exit "$corrupt_check_status" \
|
||||||
|
--argjson corrupt_restore_exit "$corrupt_restore_status" \
|
||||||
|
--argjson interrupted_uploaded_objects "$uploaded_data_objects" \
|
||||||
|
'{
|
||||||
|
run_id: $run_id,
|
||||||
|
retained_bucket: $bucket,
|
||||||
|
snapshot_id: $snapshot_id,
|
||||||
|
encrypted_repository_check: "passed",
|
||||||
|
fresh_database_restore: "passed",
|
||||||
|
source_database_count_diff_bytes: 0,
|
||||||
|
corrupted_check_exit: $corrupt_check_exit,
|
||||||
|
corrupted_restore_exit: $corrupt_restore_exit,
|
||||||
|
interrupted_uploaded_objects: $interrupted_uploaded_objects,
|
||||||
|
interrupted_snapshot_count: 0,
|
||||||
|
interrupted_repository_check_after_prune: "passed",
|
||||||
|
minio_api: $minio_api,
|
||||||
|
minio_console: $minio_console
|
||||||
|
}' >"$output_dir/summary.json"
|
||||||
|
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
printf 'Encrypted S3 backup evidence: %s\n' "$output_dir"
|
||||||
|
printf 'Retained MinIO bucket: %s\nSnapshot: %s\n' "$bucket" "$snapshot_id"
|
||||||
|
printf 'MinIO API: http://%s\nMinIO console: http://%s\n' \
|
||||||
|
"$minio_api" "$minio_console"
|
||||||
|
|
@ -28,6 +28,16 @@ if [ -f "$ENV_FILE" ]; then
|
||||||
needs_observability_timeout=true
|
needs_observability_timeout=true
|
||||||
needs_observability_grafana_user=true
|
needs_observability_grafana_user=true
|
||||||
needs_observability_grafana_password=true
|
needs_observability_grafana_password=true
|
||||||
|
needs_backup_minio_api_port=true
|
||||||
|
needs_backup_minio_console_port=true
|
||||||
|
needs_backup_minio_root_user=true
|
||||||
|
needs_backup_minio_root_password=true
|
||||||
|
needs_backup_restic_password=true
|
||||||
|
needs_backup_bucket_prefix=true
|
||||||
|
needs_backup_timeout=true
|
||||||
|
needs_backup_interruption_chunks=true
|
||||||
|
needs_backup_interruption_chunk_bytes=true
|
||||||
|
needs_backup_interruption_interval=true
|
||||||
|
|
||||||
grep -q '^LOAD_FIXTURE_PASSWORD=' "$ENV_FILE" && needs_fixture_password=false
|
grep -q '^LOAD_FIXTURE_PASSWORD=' "$ENV_FILE" && needs_fixture_password=false
|
||||||
grep -q '^LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS=' "$ENV_FILE" &&
|
grep -q '^LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS=' "$ENV_FILE" &&
|
||||||
|
|
@ -53,6 +63,26 @@ if [ -f "$ENV_FILE" ]; then
|
||||||
needs_observability_grafana_user=false
|
needs_observability_grafana_user=false
|
||||||
grep -q '^OBSERVABILITY_GRAFANA_ADMIN_PASSWORD=' "$ENV_FILE" &&
|
grep -q '^OBSERVABILITY_GRAFANA_ADMIN_PASSWORD=' "$ENV_FILE" &&
|
||||||
needs_observability_grafana_password=false
|
needs_observability_grafana_password=false
|
||||||
|
grep -q '^BACKUP_MINIO_API_PORT=' "$ENV_FILE" &&
|
||||||
|
needs_backup_minio_api_port=false
|
||||||
|
grep -q '^BACKUP_MINIO_CONSOLE_PORT=' "$ENV_FILE" &&
|
||||||
|
needs_backup_minio_console_port=false
|
||||||
|
grep -q '^BACKUP_MINIO_ROOT_USER=' "$ENV_FILE" &&
|
||||||
|
needs_backup_minio_root_user=false
|
||||||
|
grep -q '^BACKUP_MINIO_ROOT_PASSWORD=' "$ENV_FILE" &&
|
||||||
|
needs_backup_minio_root_password=false
|
||||||
|
grep -q '^BACKUP_RESTIC_PASSWORD=' "$ENV_FILE" &&
|
||||||
|
needs_backup_restic_password=false
|
||||||
|
grep -q '^BACKUP_BUCKET_PREFIX=' "$ENV_FILE" &&
|
||||||
|
needs_backup_bucket_prefix=false
|
||||||
|
grep -q '^BACKUP_TIMEOUT_SECONDS=' "$ENV_FILE" &&
|
||||||
|
needs_backup_timeout=false
|
||||||
|
grep -q '^BACKUP_INTERRUPTION_CHUNKS=' "$ENV_FILE" &&
|
||||||
|
needs_backup_interruption_chunks=false
|
||||||
|
grep -q '^BACKUP_INTERRUPTION_CHUNK_BYTES=' "$ENV_FILE" &&
|
||||||
|
needs_backup_interruption_chunk_bytes=false
|
||||||
|
grep -q '^BACKUP_INTERRUPTION_INTERVAL_SECONDS=' "$ENV_FILE" &&
|
||||||
|
needs_backup_interruption_interval=false
|
||||||
|
|
||||||
if [ "$needs_fixture_password" = false ] &&
|
if [ "$needs_fixture_password" = false ] &&
|
||||||
[ "$needs_resilience_timeout" = false ] &&
|
[ "$needs_resilience_timeout" = false ] &&
|
||||||
|
|
@ -66,7 +96,17 @@ if [ -f "$ENV_FILE" ]; then
|
||||||
[ "$needs_observability_evaluation_interval" = false ] &&
|
[ "$needs_observability_evaluation_interval" = false ] &&
|
||||||
[ "$needs_observability_timeout" = false ] &&
|
[ "$needs_observability_timeout" = false ] &&
|
||||||
[ "$needs_observability_grafana_user" = false ] &&
|
[ "$needs_observability_grafana_user" = false ] &&
|
||||||
[ "$needs_observability_grafana_password" = false ]; then
|
[ "$needs_observability_grafana_password" = false ] &&
|
||||||
|
[ "$needs_backup_minio_api_port" = false ] &&
|
||||||
|
[ "$needs_backup_minio_console_port" = false ] &&
|
||||||
|
[ "$needs_backup_minio_root_user" = false ] &&
|
||||||
|
[ "$needs_backup_minio_root_password" = false ] &&
|
||||||
|
[ "$needs_backup_restic_password" = false ] &&
|
||||||
|
[ "$needs_backup_bucket_prefix" = false ] &&
|
||||||
|
[ "$needs_backup_timeout" = false ] &&
|
||||||
|
[ "$needs_backup_interruption_chunks" = false ] &&
|
||||||
|
[ "$needs_backup_interruption_chunk_bytes" = false ] &&
|
||||||
|
[ "$needs_backup_interruption_interval" = false ]; then
|
||||||
echo ".env.load already exists; no secret or experiment input was changed."
|
echo ".env.load already exists; no secret or experiment input was changed."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -74,6 +114,9 @@ if [ -f "$ENV_FILE" ]; then
|
||||||
umask 077
|
umask 077
|
||||||
load_fixture_password=
|
load_fixture_password=
|
||||||
observability_grafana_admin_password=
|
observability_grafana_admin_password=
|
||||||
|
backup_minio_root_user=
|
||||||
|
backup_minio_root_password=
|
||||||
|
backup_restic_password=
|
||||||
|
|
||||||
if [ "$needs_fixture_password" = true ]; then
|
if [ "$needs_fixture_password" = true ]; then
|
||||||
load_fixture_password=$(openssl rand -hex 24)
|
load_fixture_password=$(openssl rand -hex 24)
|
||||||
|
|
@ -83,6 +126,18 @@ if [ -f "$ENV_FILE" ]; then
|
||||||
observability_grafana_admin_password=$(openssl rand -hex 32)
|
observability_grafana_admin_password=$(openssl rand -hex 32)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_root_user" = true ]; then
|
||||||
|
backup_minio_root_user="wnh$(openssl rand -hex 12)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_root_password" = true ]; then
|
||||||
|
backup_minio_root_password=$(openssl rand -hex 32)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_restic_password" = true ]; then
|
||||||
|
backup_restic_password=$(openssl rand -hex 32)
|
||||||
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
if [ "$needs_fixture_password" = true ]; then
|
if [ "$needs_fixture_password" = true ]; then
|
||||||
printf '\n# Added by the authenticated-load profile upgrade.\n'
|
printf '\n# Added by the authenticated-load profile upgrade.\n'
|
||||||
|
|
@ -156,17 +211,76 @@ if [ -f "$ENV_FILE" ]; then
|
||||||
printf 'OBSERVABILITY_GRAFANA_ADMIN_PASSWORD=%s\n' \
|
printf 'OBSERVABILITY_GRAFANA_ADMIN_PASSWORD=%s\n' \
|
||||||
"$observability_grafana_admin_password"
|
"$observability_grafana_admin_password"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_api_port" = true ] ||
|
||||||
|
[ "$needs_backup_minio_console_port" = true ] ||
|
||||||
|
[ "$needs_backup_minio_root_user" = true ] ||
|
||||||
|
[ "$needs_backup_minio_root_password" = true ] ||
|
||||||
|
[ "$needs_backup_restic_password" = true ] ||
|
||||||
|
[ "$needs_backup_bucket_prefix" = true ] ||
|
||||||
|
[ "$needs_backup_timeout" = true ] ||
|
||||||
|
[ "$needs_backup_interruption_chunks" = true ] ||
|
||||||
|
[ "$needs_backup_interruption_chunk_bytes" = true ] ||
|
||||||
|
[ "$needs_backup_interruption_interval" = true ]; then
|
||||||
|
printf '\n# Added by the encrypted local backup-profile upgrade.\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_api_port" = true ]; then
|
||||||
|
printf 'BACKUP_MINIO_API_PORT=0\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_console_port" = true ]; then
|
||||||
|
printf 'BACKUP_MINIO_CONSOLE_PORT=0\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_root_user" = true ]; then
|
||||||
|
printf 'BACKUP_MINIO_ROOT_USER=%s\n' "$backup_minio_root_user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_minio_root_password" = true ]; then
|
||||||
|
printf 'BACKUP_MINIO_ROOT_PASSWORD=%s\n' "$backup_minio_root_password"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_restic_password" = true ]; then
|
||||||
|
printf 'BACKUP_RESTIC_PASSWORD=%s\n' "$backup_restic_password"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_bucket_prefix" = true ]; then
|
||||||
|
printf 'BACKUP_BUCKET_PREFIX=wnh-backup\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_timeout" = true ]; then
|
||||||
|
printf 'BACKUP_TIMEOUT_SECONDS=120\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_interruption_chunks" = true ]; then
|
||||||
|
printf 'BACKUP_INTERRUPTION_CHUNKS=300\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_interruption_chunk_bytes" = true ]; then
|
||||||
|
printf 'BACKUP_INTERRUPTION_CHUNK_BYTES=1048576\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$needs_backup_interruption_interval" = true ]; then
|
||||||
|
printf 'BACKUP_INTERRUPTION_INTERVAL_SECONDS=0.1\n'
|
||||||
|
fi
|
||||||
} >>"$ENV_FILE"
|
} >>"$ENV_FILE"
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
unset load_fixture_password observability_grafana_admin_password \
|
unset load_fixture_password observability_grafana_admin_password \
|
||||||
|
backup_minio_root_user backup_minio_root_password backup_restic_password \
|
||||||
needs_fixture_password needs_resilience_timeout \
|
needs_fixture_password needs_resilience_timeout \
|
||||||
needs_resilience_interval needs_resilience_request_timeout \
|
needs_resilience_interval needs_resilience_request_timeout \
|
||||||
needs_traefik_retry_attempts needs_observability_prometheus_port \
|
needs_traefik_retry_attempts needs_observability_prometheus_port \
|
||||||
needs_observability_alertmanager_port needs_observability_grafana_port \
|
needs_observability_alertmanager_port needs_observability_grafana_port \
|
||||||
needs_observability_scrape_interval needs_observability_evaluation_interval \
|
needs_observability_scrape_interval needs_observability_evaluation_interval \
|
||||||
needs_observability_timeout needs_observability_grafana_user \
|
needs_observability_timeout needs_observability_grafana_user \
|
||||||
needs_observability_grafana_password
|
needs_observability_grafana_password needs_backup_minio_api_port \
|
||||||
echo "Added missing load/resilience/observability inputs to ignored .env.load."
|
needs_backup_minio_console_port needs_backup_minio_root_user \
|
||||||
|
needs_backup_minio_root_password needs_backup_restic_password \
|
||||||
|
needs_backup_bucket_prefix needs_backup_timeout \
|
||||||
|
needs_backup_interruption_chunks needs_backup_interruption_chunk_bytes \
|
||||||
|
needs_backup_interruption_interval
|
||||||
|
echo "Added missing load/resilience/observability/backup inputs to ignored .env.load."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -183,6 +297,9 @@ release_cookie=$(openssl rand -hex 64)
|
||||||
metrics_token=$(openssl rand -hex 32)
|
metrics_token=$(openssl rand -hex 32)
|
||||||
load_fixture_password=$(openssl rand -hex 24)
|
load_fixture_password=$(openssl rand -hex 24)
|
||||||
observability_grafana_admin_password=$(openssl rand -hex 32)
|
observability_grafana_admin_password=$(openssl rand -hex 32)
|
||||||
|
backup_minio_root_user="wnh$(openssl rand -hex 12)"
|
||||||
|
backup_minio_root_password=$(openssl rand -hex 32)
|
||||||
|
backup_restic_password=$(openssl rand -hex 32)
|
||||||
database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load"
|
database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load"
|
||||||
temporary=$(mktemp "${ENV_FILE}.XXXXXX")
|
temporary=$(mktemp "${ENV_FILE}.XXXXXX")
|
||||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||||
|
|
@ -195,6 +312,9 @@ RELEASE_COOKIE_VALUE=$release_cookie \
|
||||||
METRICS_TOKEN_VALUE=$metrics_token \
|
METRICS_TOKEN_VALUE=$metrics_token \
|
||||||
LOAD_FIXTURE_PASSWORD_VALUE=$load_fixture_password \
|
LOAD_FIXTURE_PASSWORD_VALUE=$load_fixture_password \
|
||||||
OBSERVABILITY_GRAFANA_ADMIN_PASSWORD_VALUE=$observability_grafana_admin_password \
|
OBSERVABILITY_GRAFANA_ADMIN_PASSWORD_VALUE=$observability_grafana_admin_password \
|
||||||
|
BACKUP_MINIO_ROOT_USER_VALUE=$backup_minio_root_user \
|
||||||
|
BACKUP_MINIO_ROOT_PASSWORD_VALUE=$backup_minio_root_password \
|
||||||
|
BACKUP_RESTIC_PASSWORD_VALUE=$backup_restic_password \
|
||||||
perl -0pe '
|
perl -0pe '
|
||||||
s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g;
|
s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g;
|
||||||
s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g;
|
s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g;
|
||||||
|
|
@ -204,6 +324,9 @@ OBSERVABILITY_GRAFANA_ADMIN_PASSWORD_VALUE=$observability_grafana_admin_password
|
||||||
s/GENERATE_METRICS_TOKEN/$ENV{METRICS_TOKEN_VALUE}/g;
|
s/GENERATE_METRICS_TOKEN/$ENV{METRICS_TOKEN_VALUE}/g;
|
||||||
s/GENERATE_LOAD_FIXTURE_PASSWORD/$ENV{LOAD_FIXTURE_PASSWORD_VALUE}/g;
|
s/GENERATE_LOAD_FIXTURE_PASSWORD/$ENV{LOAD_FIXTURE_PASSWORD_VALUE}/g;
|
||||||
s/GENERATE_OBSERVABILITY_GRAFANA_ADMIN_PASSWORD/$ENV{OBSERVABILITY_GRAFANA_ADMIN_PASSWORD_VALUE}/g;
|
s/GENERATE_OBSERVABILITY_GRAFANA_ADMIN_PASSWORD/$ENV{OBSERVABILITY_GRAFANA_ADMIN_PASSWORD_VALUE}/g;
|
||||||
|
s/GENERATE_BACKUP_MINIO_ROOT_USER/$ENV{BACKUP_MINIO_ROOT_USER_VALUE}/g;
|
||||||
|
s/GENERATE_BACKUP_MINIO_ROOT_PASSWORD/$ENV{BACKUP_MINIO_ROOT_PASSWORD_VALUE}/g;
|
||||||
|
s/GENERATE_BACKUP_RESTIC_PASSWORD/$ENV{BACKUP_RESTIC_PASSWORD_VALUE}/g;
|
||||||
' "$TEMPLATE" >"$temporary"
|
' "$TEMPLATE" >"$temporary"
|
||||||
|
|
||||||
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
|
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
|
||||||
|
|
@ -215,6 +338,8 @@ chmod 600 "$temporary"
|
||||||
mv "$temporary" "$ENV_FILE"
|
mv "$temporary" "$ENV_FILE"
|
||||||
trap - EXIT HUP INT TERM
|
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 \
|
||||||
load_fixture_password observability_grafana_admin_password database_url
|
load_fixture_password observability_grafana_admin_password \
|
||||||
|
backup_minio_root_user backup_minio_root_password backup_restic_password \
|
||||||
|
database_url
|
||||||
|
|
||||||
echo "Generated independent load-profile secrets in ignored .env.load."
|
echo "Generated independent load-profile secrets in ignored .env.load."
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ quality_image="who-need-help:quality-$run_id"
|
||||||
assets_image="who-need-help:assets-audit-$run_id"
|
assets_image="who-need-help:assets-audit-$run_id"
|
||||||
e2e_image="who-need-help:e2e-audit-$run_id"
|
e2e_image="who-need-help:e2e-audit-$run_id"
|
||||||
release_image="who-need-help:security-$run_id"
|
release_image="who-need-help:security-$run_id"
|
||||||
|
backup_image="who-need-help:backup-audit-$run_id"
|
||||||
|
minio_image="who-need-help:minio-audit-$run_id"
|
||||||
|
mc_image="who-need-help:mc-audit-$run_id"
|
||||||
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
|
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
|
||||||
scan_list="${scan_dir}.files"
|
scan_list="${scan_dir}.files"
|
||||||
scan_tar="${scan_dir}.tar"
|
scan_tar="${scan_dir}.tar"
|
||||||
|
|
@ -32,6 +35,7 @@ compose="docker compose -p $project -f $ROOT/compose.quality.yaml"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
|
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
|
||||||
|
"$backup_image" "$minio_image" "$mc_image" \
|
||||||
>/dev/null 2>&1 || true
|
>/dev/null 2>&1 || true
|
||||||
rm -rf "$scan_dir" "$scan_list" "$scan_tar"
|
rm -rf "$scan_dir" "$scan_list" "$scan_tar"
|
||||||
}
|
}
|
||||||
|
|
@ -49,7 +53,8 @@ docker run --rm \
|
||||||
$(find scripts -type f -name '*.sh' -print | sort)
|
$(find scripts -type f -name '*.sh' -print | sort)
|
||||||
|
|
||||||
echo "Checking Dockerfiles with Hadolint 2.14.0"
|
echo "Checking Dockerfiles with Hadolint 2.14.0"
|
||||||
for dockerfile in Dockerfile android/Dockerfile e2e/Dockerfile; do
|
for dockerfile in Dockerfile Dockerfile.backup Dockerfile.minio \
|
||||||
|
android/Dockerfile e2e/Dockerfile; do
|
||||||
docker run --rm --interactive "$HADOLINT_IMAGE" \
|
docker run --rm --interactive "$HADOLINT_IMAGE" \
|
||||||
hadolint --failure-threshold warning - <"$dockerfile"
|
hadolint --failure-threshold warning - <"$dockerfile"
|
||||||
done
|
done
|
||||||
|
|
@ -73,6 +78,13 @@ OBSERVABILITY_RUNTIME_DIR="$scan_dir/observability-runtime" \
|
||||||
docker compose --env-file .env.load.example \
|
docker compose --env-file .env.load.example \
|
||||||
-f compose.yaml -f compose.load.yaml -f compose.observability.yaml \
|
-f compose.yaml -f compose.load.yaml -f compose.observability.yaml \
|
||||||
--profile observability config --quiet
|
--profile observability config --quiet
|
||||||
|
mkdir -p "$scan_dir/backup-runtime"
|
||||||
|
BACKUP_RUNTIME_DIR="$scan_dir/backup-runtime" \
|
||||||
|
BACKUP_HOST_UID="$(id -u)" \
|
||||||
|
BACKUP_HOST_GID="$(id -g)" \
|
||||||
|
docker compose --env-file .env.load.example \
|
||||||
|
-f compose.yaml -f compose.load.yaml -f compose.backup.yaml \
|
||||||
|
--profile backup config --quiet
|
||||||
docker compose -p "$project" -f compose.quality.yaml config --quiet
|
docker compose -p "$project" -f compose.quality.yaml config --quiet
|
||||||
|
|
||||||
echo "Validating local observability configuration"
|
echo "Validating local observability configuration"
|
||||||
|
|
@ -161,6 +173,76 @@ docker run --rm "$assets_image" npm audit --audit-level=high
|
||||||
docker build --tag "$e2e_image" e2e
|
docker build --tag "$e2e_image" e2e
|
||||||
docker run --rm "$e2e_image" npm audit --audit-level=high
|
docker run --rm "$e2e_image" npm audit --audit-level=high
|
||||||
|
|
||||||
|
echo "Building and scanning the pinned non-root backup tool image"
|
||||||
|
docker build --tag "$backup_image" --file Dockerfile.backup .
|
||||||
|
test "$(docker image inspect --format '{{.Config.User}}' "$backup_image")" = \
|
||||||
|
"10001:10001"
|
||||||
|
backup_versions=$(docker run --rm \
|
||||||
|
--user 10001:10001 \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
"$backup_image" \
|
||||||
|
sh -euc 'restic version; pg_dump --version; test "$(id -u)" = 10001')
|
||||||
|
printf '%s\n' "$backup_versions"
|
||||||
|
printf '%s\n' "$backup_versions" |
|
||||||
|
grep -F 'restic 0.19.1 compiled with go1.26.5' >/dev/null
|
||||||
|
printf '%s\n' "$backup_versions" |
|
||||||
|
grep -F 'pg_dump (PostgreSQL) 18.4' >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||||
|
"$TRIVY_IMAGE" image \
|
||||||
|
--scanners vuln \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--exit-code 1 \
|
||||||
|
"$backup_image"
|
||||||
|
|
||||||
|
echo "Building and scanning the pinned non-root MinIO server and client images"
|
||||||
|
docker build --target server --tag "$minio_image" --file Dockerfile.minio .
|
||||||
|
docker build --target client --tag "$mc_image" --file Dockerfile.minio .
|
||||||
|
test "$(docker image inspect --format '{{.Config.User}}' "$minio_image")" = \
|
||||||
|
"10001:10001"
|
||||||
|
test "$(docker image inspect --format '{{.Config.User}}' "$mc_image")" = \
|
||||||
|
"10001:10001"
|
||||||
|
minio_version=$(docker run --rm \
|
||||||
|
--user 10001:10001 \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
"$minio_image" \
|
||||||
|
--version)
|
||||||
|
mc_version=$(docker run --rm \
|
||||||
|
--user 10001:10001 \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /tmp \
|
||||||
|
"$mc_image" \
|
||||||
|
--version)
|
||||||
|
printf '%s\n' "$minio_version"
|
||||||
|
printf '%s\n' "$mc_version"
|
||||||
|
printf '%s\n' "$minio_version" |
|
||||||
|
grep -F 'RELEASE.2025-10-15T17-29-55Z' >/dev/null
|
||||||
|
printf '%s\n' "$minio_version" |
|
||||||
|
grep -F 'commit-id=9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a' >/dev/null
|
||||||
|
printf '%s\n' "$minio_version" |
|
||||||
|
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
||||||
|
printf '%s\n' "$mc_version" |
|
||||||
|
grep -F 'RELEASE.2025-08-13T08-35-41Z' >/dev/null
|
||||||
|
printf '%s\n' "$mc_version" |
|
||||||
|
grep -F 'commit-id=7394ce0dd2a80935aded936b09fa12cbb3cb8096' >/dev/null
|
||||||
|
printf '%s\n' "$mc_version" |
|
||||||
|
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
||||||
|
for image in "$minio_image" "$mc_image"; do
|
||||||
|
docker run --rm \
|
||||||
|
--volume /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
|
||||||
|
"$TRIVY_IMAGE" image \
|
||||||
|
--scanners vuln \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--exit-code 1 \
|
||||||
|
"$image"
|
||||||
|
done
|
||||||
|
|
||||||
echo "Building and scanning the production release image"
|
echo "Building and scanning the production release image"
|
||||||
docker build --target release --tag "$release_image" .
|
docker build --target release --tag "$release_image" .
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user