Automate immutable production deployments
This commit is contained in:
+11
-2
@@ -1,5 +1,16 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.tools
|
.tools
|
||||||
|
.git
|
||||||
|
**/.terraform
|
||||||
|
**/*.tfstate
|
||||||
|
**/*.tfstate.*
|
||||||
|
**/*.tfplan
|
||||||
|
**/*.tfvars
|
||||||
|
**/*.tfvars.json
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
**/.gitea-token
|
||||||
|
**/.gitea-*-token
|
||||||
dist
|
dist
|
||||||
.vite
|
.vite
|
||||||
coverage
|
coverage
|
||||||
@@ -12,6 +23,4 @@ data/geofabrik/*.osm.pbf.md5
|
|||||||
data/geofabrik/*.part
|
data/geofabrik/*.part
|
||||||
data/geofabrik/*.expected-md5
|
data/geofabrik/*.expected-md5
|
||||||
data/local/*.json
|
data/local/*.json
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: node:22-bookworm
|
image: node:22-bookworm@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37
|
||||||
steps:
|
steps:
|
||||||
- name: Check out source
|
- name: Check out source
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -45,10 +45,13 @@ jobs:
|
|||||||
if: ${{ gitea.event_name == 'push' }}
|
if: ${{ gitea.event_name == 'push' }}
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
|
concurrency:
|
||||||
|
group: watermaps-production-publish
|
||||||
|
cancel-in-progress: false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out source
|
- name: Check out source
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
- name: Log in to the Gitea Container Registry
|
- name: Log in to the Gitea Container Registry
|
||||||
env:
|
env:
|
||||||
@@ -60,11 +63,11 @@ jobs:
|
|||||||
printf '%s\n' "$REGISTRY_TOKEN" |
|
printf '%s\n' "$REGISTRY_TOKEN" |
|
||||||
docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
|
docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
- name: Build images for the triggering commit
|
- name: Build application images for the triggering commit
|
||||||
env:
|
env:
|
||||||
REVISION: ${{ gitea.sha }}
|
REVISION: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
test "$(printf '%s' "$REVISION" | wc -c)" -eq 40
|
[[ "$REVISION" =~ ^[0-9a-f]{40}$ ]]
|
||||||
docker build \
|
docker build \
|
||||||
--label "org.opencontainers.image.revision=$REVISION" \
|
--label "org.opencontainers.image.revision=$REVISION" \
|
||||||
--label "org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}" \
|
--label "org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}" \
|
||||||
@@ -77,12 +80,68 @@ jobs:
|
|||||||
--tag "$REGISTRY/$REGISTRY_OWNER/watermaps-route-data:$REVISION" \
|
--tag "$REGISTRY/$REGISTRY_OWNER/watermaps-route-data:$REVISION" \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Push immutable commit images
|
- name: Push immutable images and build their release manifest
|
||||||
env:
|
env:
|
||||||
REVISION: ${{ gitea.sha }}
|
REVISION: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
docker push "$REGISTRY/$REGISTRY_OWNER/watermaps:$REVISION"
|
set -Eeuo pipefail
|
||||||
docker push "$REGISTRY/$REGISTRY_OWNER/watermaps-route-data:$REVISION"
|
|
||||||
|
push_and_resolve() {
|
||||||
|
local tagged_image="$1"
|
||||||
|
local push_output digest repository
|
||||||
|
|
||||||
|
if ! push_output="$(docker push "$tagged_image" 2>&1)"; then
|
||||||
|
printf '%s\n' "$push_output" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$push_output" >&2
|
||||||
|
digest="$(
|
||||||
|
printf '%s\n' "$push_output" |
|
||||||
|
sed -nE 's/.*digest: (sha256:[0-9a-f]{64}).*/\1/p' |
|
||||||
|
tail -n 1
|
||||||
|
)"
|
||||||
|
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||||
|
repository="${tagged_image%:*}"
|
||||||
|
printf '%s@%s\n' "$repository" "$digest"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_image="$(
|
||||||
|
push_and_resolve \
|
||||||
|
"$REGISTRY/$REGISTRY_OWNER/watermaps:$REVISION"
|
||||||
|
)"
|
||||||
|
route_data_image="$(
|
||||||
|
push_and_resolve \
|
||||||
|
"$REGISTRY/$REGISTRY_OWNER/watermaps-route-data:$REVISION"
|
||||||
|
)"
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--file deploy/release.Dockerfile \
|
||||||
|
--build-arg "REVISION=$REVISION" \
|
||||||
|
--build-arg "SOURCE=${{ gitea.server_url }}/${{ gitea.repository }}" \
|
||||||
|
--build-arg "APP_IMAGE=$app_image" \
|
||||||
|
--build-arg "ROUTE_DATA_IMAGE=$route_data_image" \
|
||||||
|
--tag "$REGISTRY/$REGISTRY_OWNER/watermaps-release:$REVISION" \
|
||||||
|
.
|
||||||
|
docker push "$REGISTRY/$REGISTRY_OWNER/watermaps-release:$REVISION"
|
||||||
|
|
||||||
|
- name: Publish the atomic main release pointer
|
||||||
|
env:
|
||||||
|
REVISION: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
current_main_revision="$(
|
||||||
|
git ls-remote --exit-code origin refs/heads/main |
|
||||||
|
awk 'NR == 1 { print $1 }'
|
||||||
|
)"
|
||||||
|
if [[ "$current_main_revision" != "$REVISION" ]]; then
|
||||||
|
printf 'Commit %s is no longer the main tip; immutable images stay published without moving main.\n' \
|
||||||
|
"$REVISION"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker tag \
|
||||||
|
"$REGISTRY/$REGISTRY_OWNER/watermaps-release:$REVISION" \
|
||||||
|
"$REGISTRY/$REGISTRY_OWNER/watermaps-release:main"
|
||||||
|
docker push "$REGISTRY/$REGISTRY_OWNER/watermaps-release:main"
|
||||||
|
|
||||||
- name: Log out from the registry
|
- name: Log out from the registry
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ COPY packages ./packages
|
|||||||
RUN npm run build \
|
RUN npm run build \
|
||||||
&& npm prune --omit=dev
|
&& npm prune --omit=dev
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runtime
|
FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS runtime
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- Initiales, wiederholbar ausführbares Watermaps-Schema.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marine_features (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
layer text NOT NULL CHECK (layer IN ('seamarks', 'bridges', 'locks', 'harbours', 'waterways', 'fairways')),
|
||||||
|
source text NOT NULL DEFAULT 'manual',
|
||||||
|
source_id text,
|
||||||
|
name text,
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
geom geometry(Geometry, 4326) NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_features_geom_idx ON marine_features USING gist (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_features_layer_idx ON marine_features (layer);
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_features_properties_idx ON marine_features USING gin (properties);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS marine_features_source_layer_uidx
|
||||||
|
ON marine_features (source, source_id, layer)
|
||||||
|
WHERE source_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marine_enrichment_attempts (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
layer text NOT NULL CHECK (layer IN ('locks', 'harbours')),
|
||||||
|
original_source text NOT NULL,
|
||||||
|
original_source_id text NOT NULL,
|
||||||
|
provider text NOT NULL,
|
||||||
|
query_fingerprint text NOT NULL,
|
||||||
|
query text,
|
||||||
|
status text NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'success',
|
||||||
|
'no_match',
|
||||||
|
'ambiguous',
|
||||||
|
'no_contacts',
|
||||||
|
'fetch_failed',
|
||||||
|
'provider_blocked',
|
||||||
|
'invalid_candidate'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
result_url text,
|
||||||
|
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
attempted_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
retry_after timestamptz,
|
||||||
|
UNIQUE (layer, original_source, original_source_id, provider, query_fingerprint)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_enrichment_attempts_retry_idx
|
||||||
|
ON marine_enrichment_attempts (provider, retry_after);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marine_fairway_edges (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
source text NOT NULL DEFAULT 'osm',
|
||||||
|
source_id text,
|
||||||
|
name text,
|
||||||
|
min_depth_m numeric,
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
geom geometry(LineString, 4326) NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_fairway_edges_geom_idx ON marine_fairway_edges USING gist (geom);
|
||||||
|
CREATE INDEX IF NOT EXISTS marine_fairway_edges_source_idx ON marine_fairway_edges (source, source_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS marine_fairway_edges_source_uidx
|
||||||
|
ON marine_fairway_edges (source, source_id)
|
||||||
|
WHERE source_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW marine_features_mvt AS
|
||||||
|
SELECT id, layer, source, name, properties, geom
|
||||||
|
FROM marine_features;
|
||||||
+2
-69
@@ -1,69 +1,2 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
\set ON_ERROR_STOP on
|
||||||
|
\ir migrations/0001_initial.sql
|
||||||
CREATE TABLE IF NOT EXISTS marine_features (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
layer text NOT NULL CHECK (layer IN ('seamarks', 'bridges', 'locks', 'harbours', 'waterways', 'fairways')),
|
|
||||||
source text NOT NULL DEFAULT 'manual',
|
|
||||||
source_id text,
|
|
||||||
name text,
|
|
||||||
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
geom geometry(Geometry, 4326) NOT NULL,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS marine_features_geom_idx ON marine_features USING gist (geom);
|
|
||||||
CREATE INDEX IF NOT EXISTS marine_features_layer_idx ON marine_features (layer);
|
|
||||||
CREATE INDEX IF NOT EXISTS marine_features_properties_idx ON marine_features USING gin (properties);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS marine_features_source_layer_uidx
|
|
||||||
ON marine_features (source, source_id, layer)
|
|
||||||
WHERE source_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS marine_enrichment_attempts (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
layer text NOT NULL CHECK (layer IN ('locks', 'harbours')),
|
|
||||||
original_source text NOT NULL,
|
|
||||||
original_source_id text NOT NULL,
|
|
||||||
provider text NOT NULL,
|
|
||||||
query_fingerprint text NOT NULL,
|
|
||||||
query text,
|
|
||||||
status text NOT NULL CHECK (
|
|
||||||
status IN (
|
|
||||||
'success',
|
|
||||||
'no_match',
|
|
||||||
'ambiguous',
|
|
||||||
'no_contacts',
|
|
||||||
'fetch_failed',
|
|
||||||
'provider_blocked',
|
|
||||||
'invalid_candidate'
|
|
||||||
)
|
|
||||||
),
|
|
||||||
result_url text,
|
|
||||||
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
attempted_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
retry_after timestamptz,
|
|
||||||
UNIQUE (layer, original_source, original_source_id, provider, query_fingerprint)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS marine_enrichment_attempts_retry_idx
|
|
||||||
ON marine_enrichment_attempts (provider, retry_after);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS marine_fairway_edges (
|
|
||||||
id bigserial PRIMARY KEY,
|
|
||||||
source text NOT NULL DEFAULT 'osm',
|
|
||||||
source_id text,
|
|
||||||
name text,
|
|
||||||
min_depth_m numeric,
|
|
||||||
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
geom geometry(LineString, 4326) NOT NULL,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS marine_fairway_edges_geom_idx ON marine_fairway_edges USING gist (geom);
|
|
||||||
CREATE INDEX IF NOT EXISTS marine_fairway_edges_source_idx ON marine_fairway_edges (source, source_id);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS marine_fairway_edges_source_uidx
|
|
||||||
ON marine_fairway_edges (source, source_id)
|
|
||||||
WHERE source_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW marine_features_mvt AS
|
|
||||||
SELECT id, layer, source, name, properties, geom
|
|
||||||
FROM marine_features;
|
|
||||||
|
|||||||
@@ -16,20 +16,22 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime
|
|||||||
|
|
||||||
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
|
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
|
||||||
WATERMAPS_SWAP_SIZE_GB=4
|
WATERMAPS_SWAP_SIZE_GB=4
|
||||||
|
WATERMAPS_RELEASE_RETENTION=5
|
||||||
|
|
||||||
# Persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten.
|
# Persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten.
|
||||||
# Vor dem ersten Deployment beispielsweise mit `openssl rand -hex 32`
|
# Vor dem ersten Deployment beispielsweise mit `openssl rand -hex 32`
|
||||||
# erzeugen. Das Secret muss mindestens 24 Zeichen lang sein.
|
# erzeugen. Das Secret muss mindestens 24 Zeichen lang sein.
|
||||||
WATERMAPS_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_32_BYTE_HEX_SECRET
|
WATERMAPS_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_32_BYTE_HEX_SECRET
|
||||||
WATERMAPS_POSTGRES_IMAGE=postgis/postgis:16-3.4
|
WATERMAPS_POSTGRES_IMAGE=postgis/postgis@sha256:44126d872ac91993766c341e369c539e8196614321765d36a6f1bab0419a5fa5
|
||||||
|
|
||||||
# Gitea Container Registry. Die commitgenauen App-Referenzen werden beim
|
# Gitea Container Registry. Die commitgenauen App-Referenzen werden beim
|
||||||
# Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt.
|
# Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt.
|
||||||
WATERMAPS_REGISTRY=gitea.incoso.eu
|
WATERMAPS_REGISTRY=gitea.incoso.eu
|
||||||
WATERMAPS_REGISTRY_OWNER=kevin_janssen
|
WATERMAPS_REGISTRY_OWNER=kevin_janssen
|
||||||
|
WATERMAPS_GITEA_REPOSITORY=kevin_janssen/watermaps
|
||||||
|
|
||||||
WATERMAPS_NGINX_IMAGE=nginx:1.30.4-alpine
|
WATERMAPS_NGINX_IMAGE=nginx@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
|
||||||
WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0
|
WATERMAPS_CERTBOT_IMAGE=certbot/certbot@sha256:34ee91d2f43008eb78a007d22f23ed4b2eaa9a454cb27ca2c042b49527a695b4
|
||||||
|
|
||||||
# Nur auf true setzen, wenn bei jedem Deployment Deutschland und die
|
# Nur auf true setzen, wenn bei jedem Deployment Deutschland und die
|
||||||
# Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
|
# Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
.env.production
|
.env.production
|
||||||
.env.images
|
.env.images
|
||||||
.env.images.candidate
|
.env.images.candidate
|
||||||
|
.gitea-actions-config-token
|
||||||
|
.gitea-registry-publish-token
|
||||||
|
.gitea-token
|
||||||
!.env.production.example
|
!.env.production.example
|
||||||
|
|||||||
+89
-37
@@ -27,52 +27,87 @@ diese Datei. Er bleibt lokal in der ignorierten Datei
|
|||||||
`infra/opentofu/terraform.tfvars` (alternativ kann der Provider
|
`infra/opentofu/terraform.tfvars` (alternativ kann der Provider
|
||||||
`TF_VAR_hcloud_token` lesen).
|
`TF_VAR_hcloud_token` lesen).
|
||||||
|
|
||||||
Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim
|
Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Standardmäßig
|
||||||
Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden.
|
verwenden die lokalen Deployment-Skripte
|
||||||
Falls OpenTofu keinen Output `ssh_private_key_path` bereitstellt und der Key
|
`~/.ssh/watermaps_deploy_ed25519`; alternativ kann sie mit `--identity` oder
|
||||||
nicht bereits über den SSH-Agenten verfügbar ist, ist eine dieser beiden
|
über `WATERMAPS_SSH_KEY` angegeben werden. Der vorbereitete SSH-Benutzer heißt
|
||||||
Angaben erforderlich. Der vorbereitete SSH-Benutzer heißt standardmäßig
|
standardmäßig `deploy`; die privilegierten Installationsschritte laufen über
|
||||||
`deploy`; die privilegierten Installationsschritte laufen über dessen
|
dessen passwortloses `sudo`.
|
||||||
passwortloses `sudo`.
|
|
||||||
|
|
||||||
## Gitea Actions und Container Registry
|
## Gitea Actions und Container Registry
|
||||||
|
|
||||||
Der Workflow `.gitea/workflows/container-images.yml` führt Typechecks und Tests
|
Der Workflow `.gitea/workflows/container-images.yml` führt Typechecks und Tests
|
||||||
aus. Nach einem erfolgreichen Push auf `main` baut und veröffentlicht er zwei
|
aus. Nach einem erfolgreichen Push auf `main` baut und veröffentlicht er drei
|
||||||
OCI-Images unter der vollständigen Git-Commit-SHA:
|
OCI-Images unter der vollständigen Git-Commit-SHA:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.incoso.eu/kevin_janssen/watermaps:<commit-sha>
|
gitea.incoso.eu/kevin_janssen/watermaps:<commit-sha>
|
||||||
gitea.incoso.eu/kevin_janssen/watermaps-route-data:<commit-sha>
|
gitea.incoso.eu/kevin_janssen/watermaps-route-data:<commit-sha>
|
||||||
|
gitea.incoso.eu/kevin_janssen/watermaps-release:<commit-sha>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Das dritte Image ist ein nicht ausführbares Release-Bundle mit Compose-Datei,
|
||||||
|
Nginx-Konfiguration, Systemd-Units, Deployment-Skripten und versionierten
|
||||||
|
Datenbankmigrationen. Sein geprüftes Manifest bindet die exakten
|
||||||
|
Registry-Digests von App und Routingdaten; `SHA256SUMS` schützt alle Dateien des
|
||||||
|
Bundles. Erst nachdem alle drei Commit-Images veröffentlicht wurden, wird
|
||||||
|
`watermaps-release:main` als einzelner atomarer Release-Pointer verschoben.
|
||||||
|
App- und Routingdaten-Images werden nie über bewegliche Tags gestartet.
|
||||||
|
|
||||||
In Gitea müssen Repository Actions aktiviert und ein Docker-fähiger
|
In Gitea müssen Repository Actions aktiviert und ein Docker-fähiger
|
||||||
`ubuntu-latest`-Runner registriert sein. Unter
|
`ubuntu-latest`-Runner registriert sein. Unter
|
||||||
`Repository → Settings → Actions → Secrets` werden benötigt:
|
`Repository → Settings → Actions → Secrets` werden benötigt:
|
||||||
|
|
||||||
- `REGISTRY_USERNAME`: Gitea-Benutzer, dem die Packages gehören
|
- `REGISTRY_USERNAME`: Gitea-Benutzer, dem die Packages gehören
|
||||||
- `REGISTRY_TOKEN`: Personal Access Token mit `package: Read and Write`
|
- `REGISTRY_TOKEN`: eigener CI-Personal-Access-Token ausschließlich mit
|
||||||
|
`write:package`
|
||||||
|
|
||||||
Der separate PAT ist derzeit nötig, weil Giteas eingebauter Job-Token
|
Der separate PAT ist nötig, weil Gitea 1.26 mit dem eingebauten Job-Token noch
|
||||||
OCI-Pakete noch nicht zuverlässig veröffentlichen kann. Für Pull Requests
|
keine OCI-Pakete veröffentlichen kann. Für Pull Requests werden nur Tests
|
||||||
werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet.
|
ausgeführt; Registry-Secrets werden dabei nicht verwendet.
|
||||||
|
|
||||||
|
Die Secrets lassen sich mit zwei getrennten, lokal ignorierten Token-Dateien
|
||||||
|
konfigurieren:
|
||||||
|
|
||||||
|
- `deploy/.gitea-actions-config-token`: kurzlebiger Konfigurations-PAT nur mit
|
||||||
|
`write:repository`, um die Actions-Secrets über die Repository-API zu setzen
|
||||||
|
- `deploy/.gitea-registry-publish-token`: CI-PAT nur mit `write:package`, der
|
||||||
|
anschließend als `REGISTRY_TOKEN` in Gitea Actions hinterlegt wird
|
||||||
|
|
||||||
|
Beide Dateien enthalten jeweils nur den Token in einer einzelnen Zeile und
|
||||||
|
müssen exakt Dateimodus 600 haben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 \
|
||||||
|
deploy/.gitea-actions-config-token \
|
||||||
|
deploy/.gitea-registry-publish-token
|
||||||
|
./deploy/scripts/configure-gitea-actions.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript nutzt den Konfigurations-PAT ausschließlich lokal für den API-Aufruf
|
||||||
|
und speichert ihn ausdrücklich nicht als Actions-Secret. Anschließend sollten
|
||||||
|
beide lokalen Token-Dateien gelöscht werden; Gitea zeigt den gespeicherten
|
||||||
|
Registry-Secret-Wert nicht wieder an.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den
|
Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den
|
||||||
OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen,
|
OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen,
|
||||||
vollständig committeten Git-Stand und überträgt nur den kleinen Ordner
|
vollständig committeten Git-Stand und überträgt für die Ersteinrichtung nur
|
||||||
`deploy/` sowie das kanonische `database/schema.sql`, nicht den
|
Bootstrap-Dateien und die getrennte Produktionskonfiguration, nicht den
|
||||||
Anwendungsquellcode. Die Image-Tags entsprechen exakt `git rev-parse HEAD`.
|
Anwendungsquellcode. Der Server bezieht das kanonische Release anschließend
|
||||||
|
selbst aus der Gitea Registry und prüft, dass es exakt `git rev-parse HEAD`
|
||||||
|
entspricht.
|
||||||
|
|
||||||
Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel
|
Pakete des öffentlichen Gitea-Benutzers können ohne Server-Credential gelesen
|
||||||
lokale Pull-Zugangsdaten mitgegeben:
|
werden. Wird die Registry später privat geschaltet, werden einmalig
|
||||||
|
beziehungsweise nach Tokenwechsel lokale Pull-Zugangsdaten mitgegeben:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WATERMAPS_REGISTRY_USERNAME=kevin_janssen \
|
WATERMAPS_REGISTRY_USERNAME=kevin_janssen \
|
||||||
WATERMAPS_REGISTRY_TOKEN='<package-read-token>' \
|
WATERMAPS_REGISTRY_TOKEN='<package-read-token>' \
|
||||||
./deploy/scripts/upload-and-deploy.sh \
|
./deploy/scripts/upload-and-deploy.sh \
|
||||||
--identity ~/.ssh/watermaps_hetzner_ed25519
|
--identity ~/.ssh/watermaps_deploy_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Token benötigt auf dem Produktionsserver nur `package: Read`. Er wird per
|
Der Token benötigt auf dem Produktionsserver nur `package: Read`. Er wird per
|
||||||
@@ -84,14 +119,17 @@ Anmeldung gültig ist:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/upload-and-deploy.sh \
|
./deploy/scripts/upload-and-deploy.sh \
|
||||||
--identity ~/.ssh/watermaps_hetzner_ed25519
|
--identity ~/.ssh/watermaps_deploy_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Serverskript lädt App, den kombinierten Routing-/Feature-Daten-Builder,
|
Das Serverskript lädt das Release-Manifest, App, den kombinierten
|
||||||
PostGIS, Nginx und Certbot mit `docker compose pull`. Die beiden commitgenauen
|
Routing-/Feature-Daten-Builder, PostGIS, Nginx und Certbot. App und Builder
|
||||||
Gitea-Tags werden anschließend in ihre unveränderlichen Registry-Digests
|
werden ausschließlich über die im Release gebundenen Digests gestartet.
|
||||||
aufgelöst. Erst danach werden die Container mit `--no-build` gestartet. Der
|
Geordnete SQL-Dateien unter `database/migrations/` werden mit
|
||||||
Produktionsserver benötigt deshalb weder Git noch Node/npm oder den Quellcode.
|
`watermaps_schema_migrations` protokolliert und jeweils in einer Transaktion
|
||||||
|
ausgeführt. Neue Migrationen müssen nach dem Expand/Contract-Prinzip mit dem
|
||||||
|
vorherigen App-Release kompatibel bleiben. Der Produktionsserver benötigt
|
||||||
|
weder Git noch Node/npm oder einen Repository-Checkout.
|
||||||
|
|
||||||
Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die
|
Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die
|
||||||
Niederlande, baut den Fahrroutenindex und importiert daraus die
|
Niederlande, baut den Fahrroutenindex und importiert daraus die
|
||||||
@@ -131,18 +169,19 @@ HTTP-Anfragen erhalten 404.
|
|||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten
|
Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten
|
||||||
Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert
|
Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert; sein
|
||||||
ein Deployment nach dem Containerwechsel, startet `deploy.sh` automatisch das
|
vollständiges Bundle liegt unter `/srv/watermaps-data/releases/<commit>`.
|
||||||
vorherige Release und führt die Health-, Routen- und Featuretests erneut aus.
|
`/opt/watermaps/current` wird erst nach Health-, Routen- und Featuretests
|
||||||
Ein Rollback wechselt nur die unveränderlichen App-/Builder-Images. Die
|
atomar auf ein neues Bundle umgeschaltet. Scheitert ein Deployment, wird das
|
||||||
persistente PostGIS-Datenbank bleibt erhalten; das Schema und der Import sind
|
vorherige unveränderliche Bundle erneut ausgeführt und vollständig geprüft.
|
||||||
aufwärtskompatibel und idempotent.
|
Die persistente PostGIS-Datenbank bleibt erhalten; Migrationen sind deshalb
|
||||||
|
bewusst vorwärtskompatibel zu gestalten.
|
||||||
|
|
||||||
Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:
|
Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/remote-rollback.sh \
|
./deploy/scripts/remote-rollback.sh \
|
||||||
--identity ~/.ssh/watermaps_hetzner_ed25519
|
--identity ~/.ssh/watermaps_deploy_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
Oder ein bestimmter, bereits erfolgreich deployter Commit:
|
Oder ein bestimmter, bereits erfolgreich deployter Commit:
|
||||||
@@ -150,7 +189,7 @@ Oder ein bestimmter, bereits erfolgreich deployter Commit:
|
|||||||
```bash
|
```bash
|
||||||
./deploy/scripts/remote-rollback.sh \
|
./deploy/scripts/remote-rollback.sh \
|
||||||
--revision 0123456789abcdef0123456789abcdef01234567 \
|
--revision 0123456789abcdef0123456789abcdef01234567 \
|
||||||
--identity ~/.ssh/watermaps_hetzner_ed25519
|
--identity ~/.ssh/watermaps_deploy_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Rollback verwendet gespeicherte Digests, nicht einen beweglichen Tag.
|
Der Rollback verwendet gespeicherte Digests, nicht einen beweglichen Tag.
|
||||||
@@ -167,7 +206,7 @@ A watermaps.incoso.eu <server_ipv4>
|
|||||||
Nach der DNS-Propagation wird der Livegang lokal ausgelöst:
|
Nach der DNS-Propagation wird der Livegang lokal ausgelöst:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/remote-go-live.sh --identity ~/.ssh/watermaps_hetzner_ed25519
|
./deploy/scripts/remote-go-live.sh --identity ~/.ssh/watermaps_deploy_ed25519
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Serverskript prüft, dass sämtliche A-Records ausschließlich auf die
|
Das Serverskript prüft, dass sämtliche A-Records ausschließlich auf die
|
||||||
@@ -178,7 +217,19 @@ HTTPS um.
|
|||||||
|
|
||||||
## Automatik
|
## Automatik
|
||||||
|
|
||||||
`bootstrap-server.sh` installiert zwei systemd-Timer:
|
`bootstrap-server.sh` installiert drei systemd-Timer:
|
||||||
|
|
||||||
|
- `watermaps-auto-deploy.timer`: prüft ausgehend jede Minute
|
||||||
|
`watermaps-release:main`. Das Revision-Label muss exakt dem aktuellen
|
||||||
|
Gitea-`main`-Commit entsprechen. Der Server prüft Manifest, Dateiprüfsummen,
|
||||||
|
gebundene Image-Digests und OCI-Revisionen und deployt erst nach einer
|
||||||
|
zweiten Pointer-/Branch-Prüfung. Es ist weder ein eingehender Gitea-Webhook
|
||||||
|
noch ein SSH-Key im Actions-Runner nötig. Aktives, vorheriges und die über
|
||||||
|
`WATERMAPS_RELEASE_RETENTION` festgelegte Zahl lokaler Releases bleiben
|
||||||
|
erhalten; ältere Watermaps-Artefakte werden gezielt bereinigt. Vor dem ersten
|
||||||
|
Release verwendet ein stabiler Systemd-Einstiegspunkt das Bootstrap-Skript,
|
||||||
|
danach automatisch das atomar aktivierte `current`. Noch nicht fertige erste
|
||||||
|
Registry-Builds werden dadurch zuverlässig erneut versucht.
|
||||||
|
|
||||||
- `watermaps-route-update.timer`: täglich neue Deutschland- und
|
- `watermaps-route-update.timer`: täglich neue Deutschland- und
|
||||||
Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der
|
Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der
|
||||||
@@ -193,7 +244,7 @@ Ein vollständiger manueller Feature-Neuimport lässt sich über die
|
|||||||
Option `--force-marine` erzwingen:
|
Option `--force-marine` erzwingen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/opt/watermaps/deploy/scripts/update-route-data.sh --force-marine
|
/opt/watermaps/current/deploy/scripts/update-route-data.sh --force-marine
|
||||||
```
|
```
|
||||||
|
|
||||||
`WATERMAPS_REBUILD_MARINE_DATA=true` in `.env.production` erzwingt den
|
`WATERMAPS_REBUILD_MARINE_DATA=true` in `.env.production` erzwingt den
|
||||||
@@ -205,11 +256,12 @@ Status und Logs:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl list-timers 'watermaps-*'
|
systemctl list-timers 'watermaps-*'
|
||||||
|
journalctl -u watermaps-auto-deploy.service
|
||||||
journalctl -u watermaps-route-update.service
|
journalctl -u watermaps-route-update.service
|
||||||
journalctl -u watermaps-certbot-renew.service
|
journalctl -u watermaps-certbot-renew.service
|
||||||
docker compose \
|
docker compose \
|
||||||
--project-directory /opt/watermaps \
|
--project-directory /opt/watermaps/current \
|
||||||
--env-file /opt/watermaps/deploy/.env.production \
|
--env-file /opt/watermaps/deploy/.env.production \
|
||||||
--env-file /opt/watermaps/deploy/.env.images \
|
--env-file /opt/watermaps/deploy/.env.images \
|
||||||
-f /opt/watermaps/deploy/compose.production.yml ps
|
-f /opt/watermaps/current/deploy/compose.production.yml ps
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ services:
|
|||||||
source: ./database/schema.sql
|
source: ./database/schema.sql
|
||||||
target: /docker-entrypoint-initdb.d/01-schema.sql
|
target: /docker-entrypoint-initdb.d/01-schema.sql
|
||||||
read_only: true
|
read_only: true
|
||||||
|
- type: bind
|
||||||
|
source: ./database/migrations
|
||||||
|
target: /docker-entrypoint-initdb.d/migrations
|
||||||
|
read_only: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
FROM alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce AS bundle
|
||||||
|
|
||||||
|
ARG REVISION
|
||||||
|
ARG SOURCE
|
||||||
|
ARG APP_IMAGE
|
||||||
|
ARG ROUTE_DATA_IMAGE
|
||||||
|
|
||||||
|
WORKDIR /release
|
||||||
|
|
||||||
|
COPY deploy/compose.production.yml /release/deploy/compose.production.yml
|
||||||
|
COPY deploy/nginx /release/deploy/nginx
|
||||||
|
COPY deploy/scripts /release/deploy/scripts
|
||||||
|
COPY deploy/systemd /release/deploy/systemd
|
||||||
|
COPY database /release/database
|
||||||
|
|
||||||
|
RUN set -eu; \
|
||||||
|
case "$REVISION" in \
|
||||||
|
*[!0-9a-f]*|'') exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
test "${#REVISION}" -eq 40; \
|
||||||
|
app_digest="${APP_IMAGE##*@sha256:}"; \
|
||||||
|
route_digest="${ROUTE_DATA_IMAGE##*@sha256:}"; \
|
||||||
|
test "$app_digest" != "$APP_IMAGE"; \
|
||||||
|
test "$route_digest" != "$ROUTE_DATA_IMAGE"; \
|
||||||
|
case "$app_digest" in \
|
||||||
|
*[!0-9a-f]*|'') exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
case "$route_digest" in \
|
||||||
|
*[!0-9a-f]*|'') exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
test "${#app_digest}" -eq 64; \
|
||||||
|
test "${#route_digest}" -eq 64; \
|
||||||
|
printf '%s\n' \
|
||||||
|
"format_version=1" \
|
||||||
|
"revision=$REVISION" \
|
||||||
|
"source=$SOURCE" \
|
||||||
|
"app_image=$APP_IMAGE" \
|
||||||
|
"route_data_image=$ROUTE_DATA_IMAGE" \
|
||||||
|
> release.env; \
|
||||||
|
{ \
|
||||||
|
sha256sum release.env; \
|
||||||
|
find deploy database -type f -print | LC_ALL=C sort | xargs sha256sum; \
|
||||||
|
} > SHA256SUMS
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
ARG REVISION
|
||||||
|
ARG SOURCE
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="Watermaps production release bundle" \
|
||||||
|
org.opencontainers.image.description="Versioned deployment files, exact image digests and database migrations for Watermaps" \
|
||||||
|
org.opencontainers.image.revision="${REVISION}" \
|
||||||
|
org.opencontainers.image.source="${SOURCE}"
|
||||||
|
|
||||||
|
COPY --from=bundle /release /release
|
||||||
|
|
||||||
|
CMD ["/release/deploy/scripts/deploy.sh"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --yes --no-install-recommends \
|
&& apt-get install --yes --no-install-recommends \
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
install_root="${WATERMAPS_INSTALL_DIR:-/opt/watermaps}"
|
||||||
|
[[ "$install_root" == /* && "$install_root" != "/" ]] || {
|
||||||
|
printf 'Ungültiges Watermaps-Installationsverzeichnis: %s\n' "$install_root" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
current_script="$install_root/current/deploy/scripts/auto-deploy.sh"
|
||||||
|
bootstrap_script="$install_root/deploy/scripts/auto-deploy.sh"
|
||||||
|
|
||||||
|
if [[ -x "$current_script" ]]; then
|
||||||
|
exec "$current_script"
|
||||||
|
fi
|
||||||
|
if [[ -x "$bootstrap_script" ]]; then
|
||||||
|
exec "$bootstrap_script"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Kein Watermaps-Auto-Deploy-Skript gefunden.\n' >&2
|
||||||
|
exit 1
|
||||||
Executable
+414
@@ -0,0 +1,414 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "$WM_DEPLOY_DIR/scripts/common.sh"
|
||||||
|
|
||||||
|
[[ "$(id -u)" -eq 0 ]] ||
|
||||||
|
wm_die "Dieses Skript muss als root ausgeführt werden."
|
||||||
|
wm_load_env
|
||||||
|
wm_assert_data_mount
|
||||||
|
|
||||||
|
registry="${WATERMAPS_REGISTRY:-}"
|
||||||
|
registry_owner="${WATERMAPS_REGISTRY_OWNER:-}"
|
||||||
|
repository="${WATERMAPS_GITEA_REPOSITORY:-$registry_owner/watermaps}"
|
||||||
|
|
||||||
|
[[ "$registry" =~ ^[a-z0-9][a-z0-9.-]*(:[0-9]{1,5})?$ ]] ||
|
||||||
|
wm_die "WATERMAPS_REGISTRY muss ein Registry-Hostname ohne URL-Schema sein."
|
||||||
|
[[ "$registry_owner" =~ ^[A-Za-z0-9_.-]+$ ]] ||
|
||||||
|
wm_die "WATERMAPS_REGISTRY_OWNER enthält keinen gültigen Gitea-Besitzer."
|
||||||
|
[[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] ||
|
||||||
|
wm_die "WATERMAPS_GITEA_REPOSITORY muss Besitzer und Repository enthalten."
|
||||||
|
|
||||||
|
release_repository="$registry/$registry_owner/watermaps-release"
|
||||||
|
release_pointer="$release_repository:main"
|
||||||
|
app_repository="$registry/$registry_owner/watermaps"
|
||||||
|
route_data_repository="$registry/$registry_owner/watermaps-route-data"
|
||||||
|
release_store="$WATERMAPS_DATA_DIR/releases"
|
||||||
|
install_root="${WATERMAPS_INSTALL_DIR:-/opt/watermaps}"
|
||||||
|
wm_require_safe_absolute_dir "$install_root"
|
||||||
|
shared_deploy_dir="$install_root/deploy"
|
||||||
|
active_images_file="$shared_deploy_dir/.env.images"
|
||||||
|
current_release_link="$install_root/current"
|
||||||
|
release_retention="${WATERMAPS_RELEASE_RETENTION:-5}"
|
||||||
|
[[ "$release_retention" =~ ^[2-9]$|^1[0-9]$|^20$ ]] ||
|
||||||
|
wm_die "WATERMAPS_RELEASE_RETENTION muss zwischen 2 und 20 liegen."
|
||||||
|
|
||||||
|
install -d -m 0755 \
|
||||||
|
"$WATERMAPS_RUNTIME_DIR/locks" \
|
||||||
|
"$release_store" \
|
||||||
|
"$shared_deploy_dir"
|
||||||
|
exec 8>"$WATERMAPS_RUNTIME_DIR/locks/auto-deploy.lock"
|
||||||
|
if ! flock --nonblock 8; then
|
||||||
|
wm_log "Eine automatische Release-Prüfung läuft bereits."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
temporary_release_dir=""
|
||||||
|
temporary_images_file=""
|
||||||
|
temporary_current_link=""
|
||||||
|
release_container_id=""
|
||||||
|
release_directory=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [[ -n "$release_container_id" ]]; then
|
||||||
|
docker container rm --force "$release_container_id" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
if [[ -n "$temporary_release_dir" && -d "$temporary_release_dir" ]]; then
|
||||||
|
rm -rf -- "$temporary_release_dir"
|
||||||
|
fi
|
||||||
|
if [[ -n "$temporary_images_file" && -f "$temporary_images_file" ]]; then
|
||||||
|
rm -f -- "$temporary_images_file"
|
||||||
|
fi
|
||||||
|
if [[ -n "$temporary_current_link" && -L "$temporary_current_link" ]]; then
|
||||||
|
rm -f -- "$temporary_current_link"
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
image_revision() {
|
||||||
|
local image_reference="$1"
|
||||||
|
local revision
|
||||||
|
|
||||||
|
revision="$(
|
||||||
|
docker image inspect \
|
||||||
|
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
|
||||||
|
"$image_reference"
|
||||||
|
)"
|
||||||
|
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] ||
|
||||||
|
wm_die "Image $image_reference enthält kein gültiges OCI-Revision-Label."
|
||||||
|
printf '%s\n' "$revision"
|
||||||
|
}
|
||||||
|
|
||||||
|
image_id() {
|
||||||
|
docker image inspect --format '{{.Id}}' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea_main_revision() {
|
||||||
|
local api_url
|
||||||
|
api_url="https://$registry/api/v1/repos/$repository/branches/main"
|
||||||
|
curl \
|
||||||
|
--fail \
|
||||||
|
--silent \
|
||||||
|
--show-error \
|
||||||
|
--location \
|
||||||
|
--max-time 30 \
|
||||||
|
"$api_url" |
|
||||||
|
jq --exit-status --raw-output '
|
||||||
|
.commit.id
|
||||||
|
| select(type == "string" and test("^[0-9a-f]{40}$"))
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_release_bundle() {
|
||||||
|
local directory="$1"
|
||||||
|
local expected_revision="$2"
|
||||||
|
local required_file
|
||||||
|
local manifest_revision manifest_source manifest_app manifest_route
|
||||||
|
|
||||||
|
for required_file in \
|
||||||
|
SHA256SUMS \
|
||||||
|
release.env \
|
||||||
|
deploy/compose.production.yml \
|
||||||
|
deploy/nginx/bootstrap.conf \
|
||||||
|
deploy/nginx/https.conf.template \
|
||||||
|
deploy/scripts/common.sh \
|
||||||
|
deploy/scripts/auto-deploy.sh \
|
||||||
|
deploy/scripts/auto-deploy-entrypoint.sh \
|
||||||
|
deploy/scripts/bootstrap-server.sh \
|
||||||
|
deploy/scripts/deploy.sh \
|
||||||
|
deploy/scripts/renew-certificate.sh \
|
||||||
|
deploy/scripts/rollback.sh \
|
||||||
|
deploy/scripts/update-route-data.sh \
|
||||||
|
deploy/systemd/watermaps-auto-deploy.service \
|
||||||
|
deploy/systemd/watermaps-auto-deploy.timer \
|
||||||
|
deploy/systemd/watermaps-certbot-renew.service \
|
||||||
|
deploy/systemd/watermaps-certbot-renew.timer \
|
||||||
|
deploy/systemd/watermaps-route-update.service \
|
||||||
|
deploy/systemd/watermaps-route-update.timer \
|
||||||
|
database/schema.sql \
|
||||||
|
database/migrations/0001_initial.sql; do
|
||||||
|
[[ -f "$directory/$required_file" ]] ||
|
||||||
|
wm_die "Release-Bundle ist unvollständig: $required_file fehlt."
|
||||||
|
done
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$directory"
|
||||||
|
sha256sum --check --strict SHA256SUMS >/dev/null
|
||||||
|
) || wm_die "Release-Bundle hat die Integritätsprüfung nicht bestanden."
|
||||||
|
|
||||||
|
[[ "$(wm_env_value "$directory/release.env" format_version)" == "1" ]] ||
|
||||||
|
wm_die "Release-Manifest hat eine unbekannte Formatversion."
|
||||||
|
manifest_revision="$(wm_env_value "$directory/release.env" revision)"
|
||||||
|
manifest_source="$(wm_env_value "$directory/release.env" source)"
|
||||||
|
manifest_app="$(wm_env_value "$directory/release.env" app_image)"
|
||||||
|
manifest_route="$(wm_env_value "$directory/release.env" route_data_image)"
|
||||||
|
|
||||||
|
[[ "$manifest_revision" == "$expected_revision" ]] ||
|
||||||
|
wm_die "Release-Manifest und erwartete Revision stimmen nicht überein."
|
||||||
|
[[ "$manifest_source" == "https://$registry/$repository" ]] ||
|
||||||
|
wm_die "Release-Manifest verweist auf eine unerwartete Quelle."
|
||||||
|
[[ "$manifest_app" =~ ^${app_repository//./\\.}@sha256:[0-9a-f]{64}$ ]] ||
|
||||||
|
wm_die "Release-Manifest enthält kein gültiges App-Image."
|
||||||
|
[[ "$manifest_route" =~ ^${route_data_repository//./\\.}@sha256:[0-9a-f]{64}$ ]] ||
|
||||||
|
wm_die "Release-Manifest enthält kein gültiges Routingdaten-Image."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_release_bundle() {
|
||||||
|
local revision="$1"
|
||||||
|
local image_reference="$release_repository:$revision"
|
||||||
|
local destination="$release_store/$revision"
|
||||||
|
|
||||||
|
if [[ -d "$destination" ]]; then
|
||||||
|
validate_release_bundle "$destination" "$revision"
|
||||||
|
release_directory="$destination"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker pull "$image_reference" >/dev/null
|
||||||
|
[[ "$(image_revision "$image_reference")" == "$revision" ]] ||
|
||||||
|
wm_die "Release-Image und erwartete Revision stimmen nicht überein."
|
||||||
|
|
||||||
|
temporary_release_dir="$(mktemp -d "$release_store/.candidate-$revision.XXXXXX")"
|
||||||
|
release_container_id="$(docker create "$image_reference")"
|
||||||
|
docker cp "$release_container_id:/release/." "$temporary_release_dir/"
|
||||||
|
docker container rm "$release_container_id" >/dev/null
|
||||||
|
release_container_id=""
|
||||||
|
validate_release_bundle "$temporary_release_dir" "$revision"
|
||||||
|
chmod 0755 "$temporary_release_dir/deploy/scripts/"*.sh
|
||||||
|
mv "$temporary_release_dir" "$destination"
|
||||||
|
temporary_release_dir=""
|
||||||
|
release_directory="$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_release_units() {
|
||||||
|
local source_directory="$1"
|
||||||
|
|
||||||
|
install -m 0755 \
|
||||||
|
"$source_directory/deploy/scripts/auto-deploy-entrypoint.sh" \
|
||||||
|
/usr/local/sbin/watermaps-auto-deploy
|
||||||
|
install -m 0644 \
|
||||||
|
"$source_directory/deploy/systemd/watermaps-auto-deploy.service" \
|
||||||
|
/etc/systemd/system/
|
||||||
|
install -m 0644 \
|
||||||
|
"$source_directory/deploy/systemd/watermaps-auto-deploy.timer" \
|
||||||
|
/etc/systemd/system/
|
||||||
|
install -m 0644 \
|
||||||
|
"$source_directory/deploy/systemd/watermaps-route-update.service" \
|
||||||
|
/etc/systemd/system/
|
||||||
|
install -m 0644 \
|
||||||
|
"$source_directory/deploy/systemd/watermaps-route-update.timer" \
|
||||||
|
/etc/systemd/system/
|
||||||
|
install -m 0644 \
|
||||||
|
"$source_directory/deploy/systemd/watermaps-certbot-renew.service" \
|
||||||
|
/etc/systemd/system/
|
||||||
|
install -m 0644 \
|
||||||
|
"$source_directory/deploy/systemd/watermaps-certbot-renew.timer" \
|
||||||
|
/etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now \
|
||||||
|
watermaps-auto-deploy.timer \
|
||||||
|
watermaps-route-update.timer \
|
||||||
|
watermaps-certbot-renew.timer \
|
||||||
|
>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
activate_release() {
|
||||||
|
local source_directory="$1"
|
||||||
|
|
||||||
|
temporary_current_link="$install_root/.current.$$.new"
|
||||||
|
rm -f -- "$temporary_current_link"
|
||||||
|
ln --symbolic "$source_directory" "$temporary_current_link"
|
||||||
|
mv --no-target-directory "$temporary_current_link" "$current_release_link"
|
||||||
|
temporary_current_link=""
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_old_releases() {
|
||||||
|
local candidate old_revision old_directory old_app old_route
|
||||||
|
local retained_revision retained_manifest image_is_retained
|
||||||
|
local -a revisions=()
|
||||||
|
local -A keep=()
|
||||||
|
|
||||||
|
mapfile -t revisions < <(
|
||||||
|
find "$release_store" \
|
||||||
|
-mindepth 1 \
|
||||||
|
-maxdepth 1 \
|
||||||
|
-type d \
|
||||||
|
-printf '%T@ %f\n' |
|
||||||
|
sort --numeric-sort --reverse |
|
||||||
|
awk '$2 ~ /^[0-9a-f]{40}$/ { print $2 }'
|
||||||
|
)
|
||||||
|
|
||||||
|
keep["$revision"]=1
|
||||||
|
if [[ -n "$active_revision" ]]; then
|
||||||
|
keep["$active_revision"]=1
|
||||||
|
fi
|
||||||
|
for candidate in "${revisions[@]:0:release_retention}"; do
|
||||||
|
keep["$candidate"]=1
|
||||||
|
done
|
||||||
|
|
||||||
|
for old_revision in "${revisions[@]}"; do
|
||||||
|
[[ -z "${keep[$old_revision]:-}" ]] || continue
|
||||||
|
[[ "$old_revision" =~ ^[0-9a-f]{40}$ ]] || continue
|
||||||
|
old_directory="$release_store/$old_revision"
|
||||||
|
[[ -d "$old_directory" ]] || continue
|
||||||
|
|
||||||
|
old_app="$(wm_env_value "$old_directory/release.env" app_image)"
|
||||||
|
old_route="$(wm_env_value "$old_directory/release.env" route_data_image)"
|
||||||
|
for candidate in "$old_app" "$old_route"; do
|
||||||
|
image_is_retained=false
|
||||||
|
for retained_revision in "${!keep[@]}"; do
|
||||||
|
retained_manifest="$release_store/$retained_revision/release.env"
|
||||||
|
if [[ -f "$retained_manifest" ]] &&
|
||||||
|
grep --fixed-strings --line-regexp --quiet \
|
||||||
|
"app_image=$candidate" "$retained_manifest" ||
|
||||||
|
[[ -f "$retained_manifest" ]] &&
|
||||||
|
grep --fixed-strings --line-regexp --quiet \
|
||||||
|
"route_data_image=$candidate" "$retained_manifest"; then
|
||||||
|
image_is_retained=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$image_is_retained" == "false" ]]; then
|
||||||
|
docker image rm "$candidate" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
docker image rm \
|
||||||
|
"$app_repository:$old_revision" \
|
||||||
|
"$route_data_repository:$old_revision" \
|
||||||
|
"$release_repository:$old_revision" \
|
||||||
|
>/dev/null 2>&1 ||
|
||||||
|
true
|
||||||
|
rm -rf -- "$old_directory"
|
||||||
|
wm_log "Altes lokales Release $old_revision wurde gezielt bereinigt."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wm_log "Prüfe den atomaren Gitea-Release-Pointer."
|
||||||
|
docker pull "$release_pointer" >/dev/null
|
||||||
|
revision="$(image_revision "$release_pointer")"
|
||||||
|
release_pointer_id="$(image_id "$release_pointer")"
|
||||||
|
|
||||||
|
main_revision="$(gitea_main_revision)"
|
||||||
|
if [[ "$revision" != "$main_revision" ]]; then
|
||||||
|
wm_log "Release $revision ist nicht der aktuelle main-Commit $main_revision; warte auf den laufenden Build."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_image="$release_repository:$revision"
|
||||||
|
docker pull "$release_image" >/dev/null
|
||||||
|
[[ "$(image_id "$release_image")" == "$release_pointer_id" ]] ||
|
||||||
|
wm_die "Der bewegliche Release-Pointer stimmt nicht mit dem unveränderlichen Commit-Image überein."
|
||||||
|
|
||||||
|
ensure_release_bundle "$revision"
|
||||||
|
|
||||||
|
active_revision=""
|
||||||
|
if [[ -f "$active_images_file" ]]; then
|
||||||
|
wm_validate_images_env "$active_images_file"
|
||||||
|
active_revision="$(wm_env_value "$active_images_file" WATERMAPS_DEPLOY_REVISION)"
|
||||||
|
fi
|
||||||
|
if [[ "$active_revision" == "$revision" ]]; then
|
||||||
|
current_release_target="$(
|
||||||
|
readlink --canonicalize "$current_release_link" 2>/dev/null || true
|
||||||
|
)"
|
||||||
|
expected_release_target="$(readlink --canonicalize "$release_directory")"
|
||||||
|
if [[ ! -L "$current_release_link" ||
|
||||||
|
"$current_release_target" != "$expected_release_target" ]]; then
|
||||||
|
activate_release "$release_directory"
|
||||||
|
install_release_units "$release_directory"
|
||||||
|
wm_log "Der atomare Current-Link für Commit $revision wurde repariert."
|
||||||
|
fi
|
||||||
|
wm_log "Commit $revision ist bereits aktiv."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
app_image="$(wm_env_value "$release_directory/release.env" app_image)"
|
||||||
|
route_data_image="$(wm_env_value "$release_directory/release.env" route_data_image)"
|
||||||
|
docker pull "$app_image" >/dev/null
|
||||||
|
docker pull "$route_data_image" >/dev/null
|
||||||
|
[[ "$(image_revision "$app_image")" == "$revision" ]] ||
|
||||||
|
wm_die "App-Image gehört nicht zu Release $revision."
|
||||||
|
[[ "$(image_revision "$route_data_image")" == "$revision" ]] ||
|
||||||
|
wm_die "Routingdaten-Image gehört nicht zu Release $revision."
|
||||||
|
|
||||||
|
resolved_app_image="$(wm_resolve_image_digest "$app_image")"
|
||||||
|
resolved_route_data_image="$(wm_resolve_image_digest "$route_data_image")"
|
||||||
|
temporary_images_file="$(
|
||||||
|
mktemp "$WATERMAPS_RUNTIME_DIR/.auto-deploy-images.XXXXXX"
|
||||||
|
)"
|
||||||
|
{
|
||||||
|
printf 'WATERMAPS_DEPLOY_REVISION=%s\n' "$revision"
|
||||||
|
printf 'WATERMAPS_APP_IMAGE=%s\n' "$resolved_app_image"
|
||||||
|
printf 'WATERMAPS_ROUTE_DATA_IMAGE=%s\n' "$resolved_route_data_image"
|
||||||
|
} >"$temporary_images_file"
|
||||||
|
chmod 0600 "$temporary_images_file"
|
||||||
|
wm_validate_images_env "$temporary_images_file"
|
||||||
|
|
||||||
|
# A stale workflow must never activate after a newer push. Pull and compare the
|
||||||
|
# single release pointer immediately before changing deployment files.
|
||||||
|
docker pull "$release_pointer" >/dev/null
|
||||||
|
[[ "$(image_revision "$release_pointer")" == "$revision" ]] ||
|
||||||
|
wm_die "Während der Vorbereitung wurde ein neueres Release veröffentlicht."
|
||||||
|
[[ "$(image_id "$release_pointer")" == "$release_pointer_id" ]] ||
|
||||||
|
wm_die "Der Release-Pointer wurde während der Vorbereitung ausgetauscht."
|
||||||
|
[[ "$(gitea_main_revision)" == "$revision" ]] ||
|
||||||
|
wm_die "main wurde während der Vorbereitung auf einen neueren Commit verschoben."
|
||||||
|
|
||||||
|
previous_release_directory=""
|
||||||
|
previous_images_file=""
|
||||||
|
if [[ -n "$active_revision" ]]; then
|
||||||
|
previous_release_directory="$release_store/$active_revision"
|
||||||
|
previous_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$active_revision.env"
|
||||||
|
if [[ ! -d "$previous_release_directory" ||
|
||||||
|
! -f "$previous_images_file" ]]; then
|
||||||
|
previous_release_directory=""
|
||||||
|
previous_images_file=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
WATERMAPS_ENV_FILE="$WM_ENV_FILE" \
|
||||||
|
WATERMAPS_ACTIVE_IMAGES_FILE="$active_images_file" \
|
||||||
|
WATERMAPS_DISABLE_INTERNAL_ROLLBACK=true \
|
||||||
|
"$release_directory/deploy/scripts/deploy.sh" \
|
||||||
|
--images-file "$temporary_images_file"
|
||||||
|
deploy_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$deploy_status" -ne 0 ]]; then
|
||||||
|
if [[ "$deploy_status" -eq 75 ]]; then
|
||||||
|
wm_log "Ein Datenupdate oder manuelles Deployment läuft; das Release wird später erneut versucht."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ -n "$previous_release_directory" &&
|
||||||
|
-n "$previous_images_file" ]]; then
|
||||||
|
wm_log "Das vorherige Release $active_revision wird aus seinem unveränderlichen Bundle wiederhergestellt."
|
||||||
|
set +e
|
||||||
|
WATERMAPS_ENV_FILE="$WM_ENV_FILE" \
|
||||||
|
WATERMAPS_ACTIVE_IMAGES_FILE="$active_images_file" \
|
||||||
|
WATERMAPS_DISABLE_INTERNAL_ROLLBACK=true \
|
||||||
|
"$previous_release_directory/deploy/scripts/deploy.sh" \
|
||||||
|
--images-file "$previous_images_file"
|
||||||
|
rollback_status=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rollback_status" -ne 0 ]]; then
|
||||||
|
wm_die "Release $revision und die Wiederherstellung von $active_revision sind fehlgeschlagen."
|
||||||
|
fi
|
||||||
|
activate_release "$previous_release_directory"
|
||||||
|
wm_log "Vorheriges Release $active_revision wurde erneut geprüft und ist aktiv."
|
||||||
|
fi
|
||||||
|
exit "$deploy_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
activate_release "$release_directory"
|
||||||
|
install_release_units "$release_directory"
|
||||||
|
prune_old_releases
|
||||||
|
wm_log "Automatisches Deployment von Commit $revision abgeschlossen."
|
||||||
@@ -113,7 +113,15 @@ install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.service" /etc/sys
|
|||||||
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.timer" /etc/systemd/system/
|
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-route-update.timer" /etc/systemd/system/
|
||||||
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.service" /etc/systemd/system/
|
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.service" /etc/systemd/system/
|
||||||
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.timer" /etc/systemd/system/
|
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-certbot-renew.timer" /etc/systemd/system/
|
||||||
|
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-auto-deploy.service" /etc/systemd/system/
|
||||||
|
install -m 0644 "$WM_DEPLOY_DIR/systemd/watermaps-auto-deploy.timer" /etc/systemd/system/
|
||||||
|
install -m 0755 \
|
||||||
|
"$WM_DEPLOY_DIR/scripts/auto-deploy-entrypoint.sh" \
|
||||||
|
/usr/local/sbin/watermaps-auto-deploy
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now watermaps-route-update.timer watermaps-certbot-renew.timer
|
systemctl enable \
|
||||||
|
watermaps-route-update.timer \
|
||||||
|
watermaps-certbot-renew.timer
|
||||||
|
systemctl enable --now watermaps-auto-deploy.timer
|
||||||
|
|
||||||
wm_log "Server-Bootstrap abgeschlossen."
|
wm_log "Server-Bootstrap abgeschlossen."
|
||||||
|
|||||||
Executable
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set +x
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
WM_DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WM_ROOT_DIR="$(cd "$WM_DEPLOY_DIR/.." && pwd)"
|
||||||
|
config_token_file="${WATERMAPS_GITEA_CONFIG_TOKEN_FILE:-$WM_DEPLOY_DIR/.gitea-actions-config-token}"
|
||||||
|
registry_token_file="${WATERMAPS_GITEA_REGISTRY_TOKEN_FILE:-$WM_DEPLOY_DIR/.gitea-registry-publish-token}"
|
||||||
|
gitea_url="${WATERMAPS_GITEA_URL:-https://gitea.incoso.eu}"
|
||||||
|
repository="${WATERMAPS_GITEA_REPOSITORY:-kevin_janssen/watermaps}"
|
||||||
|
|
||||||
|
jq_command="$(command -v jq 2>/dev/null || true)"
|
||||||
|
if [[ -z "$jq_command" && -x "$WM_ROOT_DIR/.tools/bin/jq" ]]; then
|
||||||
|
jq_command="$WM_ROOT_DIR/.tools/bin/jq"
|
||||||
|
fi
|
||||||
|
[[ -n "$jq_command" ]] || {
|
||||||
|
printf 'jq fehlt; bitte jq installieren oder unter .tools/bin/jq bereitstellen.\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_secrets() {
|
||||||
|
unset config_token registry_token token_payload
|
||||||
|
}
|
||||||
|
trap cleanup_secrets EXIT
|
||||||
|
|
||||||
|
read_secret_file() {
|
||||||
|
local label="$1"
|
||||||
|
local path="$2"
|
||||||
|
local mode
|
||||||
|
local value
|
||||||
|
|
||||||
|
[[ -f "$path" && ! -L "$path" ]] || {
|
||||||
|
printf '%s fehlt oder ist keine reguläre Datei: %s\n' "$label" "$path" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mode="$(stat --format=%a -- "$path")"
|
||||||
|
[[ "$mode" == "600" ]] || {
|
||||||
|
printf '%s muss Dateimodus 600 haben (chmod 600 %s; aktuell: %s).\n' \
|
||||||
|
"$label" "$path" "$mode" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
value="$(<"$path")"
|
||||||
|
[[ -n "$value" && "$value" != *[[:space:]]* ]] || {
|
||||||
|
printf '%s ist leer oder enthält Leerzeichen beziehungsweise Zeilenumbrüche.\n' \
|
||||||
|
"$label" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
config_token="$(
|
||||||
|
read_secret_file \
|
||||||
|
"Lokaler Gitea-Konfigurations-PAT" \
|
||||||
|
"$config_token_file"
|
||||||
|
)"
|
||||||
|
registry_token="$(
|
||||||
|
read_secret_file \
|
||||||
|
"Gitea-Registry-Publish-PAT" \
|
||||||
|
"$registry_token_file"
|
||||||
|
)"
|
||||||
|
[[ ! "$config_token_file" -ef "$registry_token_file" ]] || {
|
||||||
|
printf 'Konfigurations- und Registry-PAT müssen in zwei getrennten Dateien liegen.\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$config_token" != "$registry_token" ]] || {
|
||||||
|
printf 'Konfigurations- und Registry-PAT müssen zwei unterschiedliche Tokens sein.\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$repository" =~ ^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$ ]] || {
|
||||||
|
printf 'Ungültiges Gitea-Repository: %s\n' "$repository" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
registry_username="${BASH_REMATCH[1]}"
|
||||||
|
[[ "$gitea_url" =~ ^https://[^/?#]+(:[0-9]+)?$ ]] || {
|
||||||
|
printf 'Die Gitea-URL muss eine HTTPS-Origin ohne Pfad sein: %s\n' "$gitea_url" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
api_request() {
|
||||||
|
local method="$1"
|
||||||
|
local endpoint="$2"
|
||||||
|
local payload="${3:-}"
|
||||||
|
local auth_header="Authorization: token $config_token"
|
||||||
|
local args=(
|
||||||
|
--proto '=https'
|
||||||
|
--tlsv1.2
|
||||||
|
--fail
|
||||||
|
--silent
|
||||||
|
--show-error
|
||||||
|
--connect-timeout 10
|
||||||
|
--max-time 30
|
||||||
|
--request "$method"
|
||||||
|
--header @/dev/fd/3
|
||||||
|
)
|
||||||
|
if [[ -n "$payload" ]]; then
|
||||||
|
args+=(
|
||||||
|
--header "Content-Type: application/json"
|
||||||
|
--data-binary @/dev/fd/4
|
||||||
|
)
|
||||||
|
curl "${args[@]}" "$gitea_url/api/v1/$endpoint" \
|
||||||
|
3<<<"$auth_header" \
|
||||||
|
4<<<"$payload"
|
||||||
|
else
|
||||||
|
curl "${args[@]}" "$gitea_url/api/v1/$endpoint" \
|
||||||
|
3<<<"$auth_header"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
username_payload="$(
|
||||||
|
printf '%s' "$registry_username" |
|
||||||
|
"$jq_command" --raw-input --slurp '{data: .}'
|
||||||
|
)"
|
||||||
|
token_payload="$(
|
||||||
|
printf '%s' "$registry_token" |
|
||||||
|
"$jq_command" --raw-input --slurp '{data: .}'
|
||||||
|
)"
|
||||||
|
api_request \
|
||||||
|
PUT \
|
||||||
|
"repos/$repository/actions/secrets/REGISTRY_USERNAME" \
|
||||||
|
"$username_payload" \
|
||||||
|
>/dev/null
|
||||||
|
api_request \
|
||||||
|
PUT \
|
||||||
|
"repos/$repository/actions/secrets/REGISTRY_TOKEN" \
|
||||||
|
"$token_payload" \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
unset config_token registry_token token_payload
|
||||||
|
printf 'Gitea Actions Registry-Secrets wurden für %s aktualisiert.\n' "$repository"
|
||||||
@@ -44,10 +44,14 @@ install -d -m 0755 \
|
|||||||
"$WATERMAPS_RUNTIME_DIR/locks" \
|
"$WATERMAPS_RUNTIME_DIR/locks" \
|
||||||
"$WATERMAPS_RUNTIME_DIR/deployments"
|
"$WATERMAPS_RUNTIME_DIR/deployments"
|
||||||
|
|
||||||
wm_acquire_route_lock ||
|
if ! wm_acquire_route_lock; then
|
||||||
wm_die "Deployment abgebrochen, weil gerade Routingdaten aktualisiert werden."
|
wm_log "Deployment wird später erneut versucht, weil gerade Routingdaten aktualisiert werden."
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
|
||||||
active_images_file="$WM_DEPLOY_DIR/.env.images"
|
active_images_file="${WATERMAPS_ACTIVE_IMAGES_FILE:-${WATERMAPS_IMAGES_ENV_FILE:-$WM_DEPLOY_DIR/.env.images}}"
|
||||||
|
[[ "$active_images_file" == /* ]] ||
|
||||||
|
wm_die "WATERMAPS_ACTIVE_IMAGES_FILE muss ein absoluter Pfad sein."
|
||||||
rollback_images_file=""
|
rollback_images_file=""
|
||||||
resolved_images_file=""
|
resolved_images_file=""
|
||||||
deployment_complete=false
|
deployment_complete=false
|
||||||
@@ -65,7 +69,9 @@ wm_finish_deployment() {
|
|||||||
trap - EXIT HUP INT TERM
|
trap - EXIT HUP INT TERM
|
||||||
set +e
|
set +e
|
||||||
|
|
||||||
if [[ "$status" -ne 0 && "$deployment_complete" != "true" ]]; then
|
if [[ "$status" -ne 0 &&
|
||||||
|
"$deployment_complete" != "true" &&
|
||||||
|
"${WATERMAPS_DISABLE_INTERNAL_ROLLBACK:-false}" != "true" ]]; then
|
||||||
if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then
|
if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then
|
||||||
wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt."
|
wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt."
|
||||||
wm_use_images_env "$rollback_images_file"
|
wm_use_images_env "$rollback_images_file"
|
||||||
@@ -122,6 +128,60 @@ wm_log "Persistentes PostGIS wird vor App und Datenimport gestartet."
|
|||||||
wm_compose up --detach --no-build postgres
|
wm_compose up --detach --no-build postgres
|
||||||
wm_wait_for_health postgres 240
|
wm_wait_for_health postgres 240
|
||||||
|
|
||||||
|
wm_log "Versionierte Datenbankmigrationen für Commit $revision werden angewendet."
|
||||||
|
wm_compose exec --no-TTY postgres \
|
||||||
|
psql \
|
||||||
|
--no-psqlrc \
|
||||||
|
--username seacompass \
|
||||||
|
--dbname seacompass \
|
||||||
|
--set ON_ERROR_STOP=1 \
|
||||||
|
--command '
|
||||||
|
CREATE TABLE IF NOT EXISTS watermaps_schema_migrations (
|
||||||
|
version text PRIMARY KEY,
|
||||||
|
applied_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
'
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
migration_files=("$WM_ROOT_DIR"/database/migrations/*.sql)
|
||||||
|
shopt -u nullglob
|
||||||
|
[[ "${#migration_files[@]}" -gt 0 ]] ||
|
||||||
|
wm_die "Keine Datenbankmigrationen im Release gefunden."
|
||||||
|
|
||||||
|
for migration_file in "${migration_files[@]}"; do
|
||||||
|
migration_name="$(basename "$migration_file")"
|
||||||
|
[[ "$migration_name" =~ ^[0-9]{4}_[a-z0-9_]+\.sql$ ]] ||
|
||||||
|
wm_die "Ungültiger Migrationsdateiname: $migration_name"
|
||||||
|
migration_version="${migration_name%.sql}"
|
||||||
|
migration_applied="$(
|
||||||
|
wm_postgres_query "
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM watermaps_schema_migrations
|
||||||
|
WHERE version = '$migration_version'
|
||||||
|
);
|
||||||
|
"
|
||||||
|
)"
|
||||||
|
migration_applied="${migration_applied//[[:space:]]/}"
|
||||||
|
if [[ "$migration_applied" == "t" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
wm_log "Wende Datenbankmigration $migration_version atomar an."
|
||||||
|
wm_compose exec --no-TTY postgres \
|
||||||
|
psql \
|
||||||
|
--no-psqlrc \
|
||||||
|
--username seacompass \
|
||||||
|
--dbname seacompass \
|
||||||
|
--set ON_ERROR_STOP=1 \
|
||||||
|
--single-transaction \
|
||||||
|
--file "/docker-entrypoint-initdb.d/migrations/$migration_name" \
|
||||||
|
--command "
|
||||||
|
INSERT INTO watermaps_schema_migrations (version)
|
||||||
|
VALUES ('$migration_version');
|
||||||
|
"
|
||||||
|
done
|
||||||
|
|
||||||
data_update_required=false
|
data_update_required=false
|
||||||
update_args=()
|
update_args=()
|
||||||
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
|
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
|
||||||
|
|||||||
@@ -11,25 +11,36 @@ wm_local_die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wm_local_iac_command() {
|
||||||
|
if command -v tofu >/dev/null 2>&1; then
|
||||||
|
command -v tofu
|
||||||
|
elif command -v terraform >/dev/null 2>&1; then
|
||||||
|
command -v terraform
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
wm_local_resolve_ssh() {
|
wm_local_resolve_ssh() {
|
||||||
local requested_server="${1:-}"
|
local requested_server="${1:-}"
|
||||||
local requested_identity="${2:-}"
|
local requested_identity="${2:-}"
|
||||||
|
local iac_command
|
||||||
|
|
||||||
WM_SERVER_IPV4="${requested_server:-${WATERMAPS_SERVER_IPV4:-}}"
|
WM_SERVER_IPV4="${requested_server:-${WATERMAPS_SERVER_IPV4:-}}"
|
||||||
if [[ -z "$WM_SERVER_IPV4" ]]; then
|
if [[ -z "$WM_SERVER_IPV4" ]]; then
|
||||||
command -v tofu >/dev/null 2>&1 ||
|
iac_command="$(wm_local_iac_command)" ||
|
||||||
wm_local_die "OpenTofu fehlt und WATERMAPS_SERVER_IPV4 wurde nicht gesetzt."
|
wm_local_die "OpenTofu/Terraform fehlt und WATERMAPS_SERVER_IPV4 wurde nicht gesetzt."
|
||||||
[[ -d "$WM_LOCAL_INFRA_DIR" ]] ||
|
[[ -d "$WM_LOCAL_INFRA_DIR" ]] ||
|
||||||
wm_local_die "OpenTofu-Verzeichnis fehlt: $WM_LOCAL_INFRA_DIR"
|
wm_local_die "OpenTofu-Verzeichnis fehlt: $WM_LOCAL_INFRA_DIR"
|
||||||
WM_SERVER_IPV4="$(tofu -chdir="$WM_LOCAL_INFRA_DIR" output -raw server_ipv4)"
|
WM_SERVER_IPV4="$("$iac_command" -chdir="$WM_LOCAL_INFRA_DIR" output -raw server_ipv4)"
|
||||||
fi
|
fi
|
||||||
[[ "$WM_SERVER_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] ||
|
[[ "$WM_SERVER_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] ||
|
||||||
wm_local_die "Ungültige Server-IPv4: $WM_SERVER_IPV4"
|
wm_local_die "Ungültige Server-IPv4: $WM_SERVER_IPV4"
|
||||||
|
|
||||||
WM_SSH_IDENTITY="${requested_identity:-${WATERMAPS_SSH_KEY:-}}"
|
WM_SSH_IDENTITY="${requested_identity:-${WATERMAPS_SSH_KEY:-}}"
|
||||||
if [[ -z "$WM_SSH_IDENTITY" ]] && command -v tofu >/dev/null 2>&1 && [[ -d "$WM_LOCAL_INFRA_DIR" ]]; then
|
if [[ -z "$WM_SSH_IDENTITY" ]]; then
|
||||||
candidate_identity="$(tofu -chdir="$WM_LOCAL_INFRA_DIR" output -raw ssh_private_key_path 2>/dev/null || true)"
|
candidate_identity="$HOME/.ssh/watermaps_deploy_ed25519"
|
||||||
if [[ -n "$candidate_identity" && -f "$candidate_identity" ]]; then
|
if [[ -f "$candidate_identity" ]]; then
|
||||||
WM_SSH_IDENTITY="$candidate_identity"
|
WM_SSH_IDENTITY="$candidate_identity"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ if [[ -n "$WM_REMOTE_SUDO" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||||
"${remote_prefix}/opt/watermaps/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
|
"${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images /opt/watermaps/current/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
|
||||||
|
|||||||
@@ -58,4 +58,4 @@ if [[ -n "$WM_REMOTE_SUDO" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||||
"${remote_prefix}/opt/watermaps/deploy/scripts/rollback.sh '$revision'"
|
"${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images WATERMAPS_ACTIVE_IMAGES_FILE=/opt/watermaps/deploy/.env.images /opt/watermaps/current/deploy/scripts/rollback.sh '$revision'"
|
||||||
|
|||||||
@@ -25,4 +25,38 @@ fi
|
|||||||
[[ -f "$images_file" ]] ||
|
[[ -f "$images_file" ]] ||
|
||||||
wm_die "Gespeichertes Release nicht gefunden: $images_file"
|
wm_die "Gespeichertes Release nicht gefunden: $images_file"
|
||||||
|
|
||||||
exec "$WM_DEPLOY_DIR/scripts/deploy.sh" --images-file "$images_file"
|
target_revision="$(wm_env_value "$images_file" WATERMAPS_DEPLOY_REVISION)"
|
||||||
|
release_directory="$WATERMAPS_DATA_DIR/releases/$target_revision"
|
||||||
|
[[ -d "$release_directory" &&
|
||||||
|
-x "$release_directory/deploy/scripts/deploy.sh" ]] ||
|
||||||
|
wm_die "Unveränderliches Release-Bundle fehlt: $release_directory"
|
||||||
|
|
||||||
|
install_root="${WATERMAPS_INSTALL_DIR:-/opt/watermaps}"
|
||||||
|
wm_require_safe_absolute_dir "$install_root"
|
||||||
|
active_images_file="${WATERMAPS_ACTIVE_IMAGES_FILE:-$install_root/deploy/.env.images}"
|
||||||
|
|
||||||
|
WATERMAPS_ENV_FILE="$WM_ENV_FILE" \
|
||||||
|
WATERMAPS_ACTIVE_IMAGES_FILE="$active_images_file" \
|
||||||
|
"$release_directory/deploy/scripts/deploy.sh" \
|
||||||
|
--images-file "$images_file"
|
||||||
|
|
||||||
|
temporary_link="$install_root/.current.rollback.$$.new"
|
||||||
|
trap 'rm -f -- "$temporary_link"' EXIT HUP INT TERM
|
||||||
|
ln --symbolic "$release_directory" "$temporary_link"
|
||||||
|
mv --no-target-directory "$temporary_link" "$install_root/current"
|
||||||
|
temporary_link=""
|
||||||
|
|
||||||
|
for unit in \
|
||||||
|
watermaps-auto-deploy.service \
|
||||||
|
watermaps-auto-deploy.timer \
|
||||||
|
watermaps-route-update.service \
|
||||||
|
watermaps-route-update.timer \
|
||||||
|
watermaps-certbot-renew.service \
|
||||||
|
watermaps-certbot-renew.timer; do
|
||||||
|
install -m 0644 \
|
||||||
|
"$release_directory/deploy/systemd/$unit" \
|
||||||
|
"/etc/systemd/system/$unit"
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
wm_log "Rollback auf Commit $target_revision ist geprüft und atomar aktiviert."
|
||||||
|
|||||||
Executable
+605
@@ -0,0 +1,605 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
AUTO_DEPLOY="$ROOT_DIR/deploy/scripts/auto-deploy.sh"
|
||||||
|
TEST_ROOT="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TEST_ROOT"' EXIT
|
||||||
|
|
||||||
|
REVISION="1111111111111111111111111111111111111111"
|
||||||
|
PREVIOUS_REVISION="2222222222222222222222222222222222222222"
|
||||||
|
APP_DIGEST="$(printf 'a%.0s' {1..64})"
|
||||||
|
ROUTE_DIGEST="$(printf 'b%.0s' {1..64})"
|
||||||
|
PREVIOUS_APP_DIGEST="$(printf 'c%.0s' {1..64})"
|
||||||
|
PREVIOUS_ROUTE_DIGEST="$(printf 'd%.0s' {1..64})"
|
||||||
|
APP_IMAGE="registry.example/team/watermaps@sha256:$APP_DIGEST"
|
||||||
|
ROUTE_IMAGE="registry.example/team/watermaps-route-data@sha256:$ROUTE_DIGEST"
|
||||||
|
PREVIOUS_APP_IMAGE="registry.example/team/watermaps@sha256:$PREVIOUS_APP_DIGEST"
|
||||||
|
PREVIOUS_ROUTE_IMAGE="registry.example/team/watermaps-route-data@sha256:$PREVIOUS_ROUTE_DIGEST"
|
||||||
|
RELEASE_POINTER="registry.example/team/watermaps-release:main"
|
||||||
|
RELEASE_IMAGE="registry.example/team/watermaps-release:$REVISION"
|
||||||
|
FAKE_BIN="$TEST_ROOT/bin"
|
||||||
|
mkdir -p "$FAKE_BIN"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'Fehler: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual="$2"
|
||||||
|
grep -Fq -- "$expected" <<<"$actual" ||
|
||||||
|
fail "Erwarteter Text fehlt: $expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_file_contains() {
|
||||||
|
local expected="$1"
|
||||||
|
local file="$2"
|
||||||
|
grep -Fq -- "$expected" "$file" ||
|
||||||
|
fail "$file enthält nicht: $expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_file_not_contains() {
|
||||||
|
local unexpected="$1"
|
||||||
|
local file="$2"
|
||||||
|
if grep -Fq -- "$unexpected" "$file"; then
|
||||||
|
fail "$file enthält unerwartet: $unexpected"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_no_temporary_artifacts() {
|
||||||
|
local artifacts=()
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
artifacts+=(
|
||||||
|
"$CASE_DATA/releases"/.candidate-*
|
||||||
|
"$CASE_RUNTIME"/.auto-deploy-images.*
|
||||||
|
"$CASE_INSTALL"/.current.*.new
|
||||||
|
)
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
if (( ${#artifacts[@]} > 0 )); then
|
||||||
|
printf 'Temporäre Artefakte wurden nicht aufgeräumt:\n' >&2
|
||||||
|
printf ' %s\n' "${artifacts[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_images_file() {
|
||||||
|
local destination="$1"
|
||||||
|
local revision="$2"
|
||||||
|
local app_image="$3"
|
||||||
|
local route_image="$4"
|
||||||
|
|
||||||
|
install -d -m 0755 "$(dirname "$destination")"
|
||||||
|
{
|
||||||
|
printf 'WATERMAPS_DEPLOY_REVISION=%s\n' "$revision"
|
||||||
|
printf 'WATERMAPS_APP_IMAGE=%s\n' "$app_image"
|
||||||
|
printf 'WATERMAPS_ROUTE_DATA_IMAGE=%s\n' "$route_image"
|
||||||
|
} >"$destination"
|
||||||
|
chmod 0600 "$destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/fake-populate-release" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
destination="$1"
|
||||||
|
revision="$2"
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
"$destination/deploy/nginx" \
|
||||||
|
"$destination/deploy/scripts" \
|
||||||
|
"$destination/deploy/systemd" \
|
||||||
|
"$destination/database/migrations"
|
||||||
|
|
||||||
|
for file in \
|
||||||
|
deploy/compose.production.yml \
|
||||||
|
deploy/nginx/bootstrap.conf \
|
||||||
|
deploy/nginx/https.conf.template \
|
||||||
|
deploy/scripts/common.sh \
|
||||||
|
deploy/scripts/auto-deploy.sh \
|
||||||
|
deploy/scripts/auto-deploy-entrypoint.sh \
|
||||||
|
deploy/scripts/bootstrap-server.sh \
|
||||||
|
deploy/scripts/renew-certificate.sh \
|
||||||
|
deploy/scripts/rollback.sh \
|
||||||
|
deploy/scripts/update-route-data.sh \
|
||||||
|
deploy/systemd/watermaps-auto-deploy.service \
|
||||||
|
deploy/systemd/watermaps-auto-deploy.timer \
|
||||||
|
deploy/systemd/watermaps-certbot-renew.service \
|
||||||
|
deploy/systemd/watermaps-certbot-renew.timer \
|
||||||
|
deploy/systemd/watermaps-route-update.service \
|
||||||
|
deploy/systemd/watermaps-route-update.timer \
|
||||||
|
database/schema.sql \
|
||||||
|
database/migrations/0001_initial.sql; do
|
||||||
|
printf 'Testinhalt für %s\n' "$file" >"$destination/$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
cat >"$destination/deploy/scripts/deploy.sh" <<'DEPLOY'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
[[ "${1:-}" == "--images-file" && -n "${2:-}" ]] ||
|
||||||
|
exit 97
|
||||||
|
images_file="$2"
|
||||||
|
revision="$(
|
||||||
|
awk -F= '$1 == "WATERMAPS_DEPLOY_REVISION" { print substr($0, length($1) + 2) }' \
|
||||||
|
"$images_file"
|
||||||
|
)"
|
||||||
|
app_image="$(
|
||||||
|
awk -F= '$1 == "WATERMAPS_APP_IMAGE" { print substr($0, length($1) + 2) }' \
|
||||||
|
"$images_file"
|
||||||
|
)"
|
||||||
|
route_image="$(
|
||||||
|
awk -F= '$1 == "WATERMAPS_ROUTE_DATA_IMAGE" { print substr($0, length($1) + 2) }' \
|
||||||
|
"$images_file"
|
||||||
|
)"
|
||||||
|
|
||||||
|
printf '%s|%s|%s|%s|%s|%s\n' \
|
||||||
|
"$0" \
|
||||||
|
"$revision" \
|
||||||
|
"${WATERMAPS_DISABLE_INTERNAL_ROLLBACK:-}" \
|
||||||
|
"$images_file" \
|
||||||
|
"$app_image" \
|
||||||
|
"$route_image" \
|
||||||
|
>>"$FAKE_DEPLOY_LOG"
|
||||||
|
|
||||||
|
if [[ "$revision" == "$FAKE_REVISION" ]]; then
|
||||||
|
[[ "$app_image" == "$FAKE_APP_IMAGE" ]] || exit 98
|
||||||
|
[[ "$route_image" == "$FAKE_ROUTE_IMAGE" ]] || exit 99
|
||||||
|
if [[ "${FAKE_DEPLOY_STATUS:-0}" -ne 0 ]]; then
|
||||||
|
exit "$FAKE_DEPLOY_STATUS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$images_file" "$WATERMAPS_ACTIVE_IMAGES_FILE"
|
||||||
|
DEPLOY
|
||||||
|
chmod 0755 "$destination/deploy/scripts/"*.sh
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'format_version=1\n'
|
||||||
|
printf 'revision=%s\n' "$revision"
|
||||||
|
printf 'source=https://registry.example/team/watermaps\n'
|
||||||
|
printf 'app_image=%s\n' "$FAKE_APP_IMAGE"
|
||||||
|
printf 'route_data_image=%s\n' "$FAKE_ROUTE_IMAGE"
|
||||||
|
} >"$destination/release.env"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$destination"
|
||||||
|
{
|
||||||
|
sha256sum release.env
|
||||||
|
find deploy database -type f -print |
|
||||||
|
LC_ALL=C sort |
|
||||||
|
xargs sha256sum
|
||||||
|
} >SHA256SUMS
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${FAKE_CORRUPT_BUNDLE:-0}" == "1" ]]; then
|
||||||
|
printf 'nachträgliche Manipulation\n' >>"$destination/database/schema.sql"
|
||||||
|
fi
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/id" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${1:-}" == "-u" ]]; then
|
||||||
|
printf '0\n'
|
||||||
|
else
|
||||||
|
exec /usr/bin/id "$@"
|
||||||
|
fi
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/mountpoint" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'curl|%s\n' "$*" >>"$FAKE_CURL_LOG"
|
||||||
|
printf '{"commit":{"id":"%s"}}\n' "$FAKE_MAIN_REVISION"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/jq" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
input="$(tr -d '\r\n')"
|
||||||
|
revision="${input#*\"id\":\"}"
|
||||||
|
revision="${revision%%\"*}"
|
||||||
|
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||||
|
printf '%s\n' "$revision"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/docker" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
printf 'docker|%s\n' "$*" >>"$FAKE_DOCKER_LOG"
|
||||||
|
command_name="${1:-}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$command_name" in
|
||||||
|
pull)
|
||||||
|
[[ -n "${1:-}" ]]
|
||||||
|
;;
|
||||||
|
image)
|
||||||
|
[[ "${1:-}" == "inspect" ]] || exit 91
|
||||||
|
shift
|
||||||
|
reference="${!#}"
|
||||||
|
if [[ "$*" == *'org.opencontainers.image.revision'* ]]; then
|
||||||
|
if [[ "$reference" == "$FAKE_RELEASE_POINTER" ]]; then
|
||||||
|
printf '%s\n' "$FAKE_POINTER_REVISION"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$FAKE_REVISION"
|
||||||
|
fi
|
||||||
|
elif [[ "$*" == *'{{.Id}}'* ]]; then
|
||||||
|
if [[ "$reference" == "$FAKE_RELEASE_POINTER" ]]; then
|
||||||
|
printf '%s\n' "$FAKE_POINTER_ID"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$FAKE_IMMUTABLE_ID"
|
||||||
|
fi
|
||||||
|
elif [[ "$*" == *'.RepoDigests'* ]]; then
|
||||||
|
[[ "$reference" == *@sha256:* ]] || exit 92
|
||||||
|
printf '%s\n' "$reference"
|
||||||
|
else
|
||||||
|
exit 93
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
create)
|
||||||
|
printf 'fake-release-container\n'
|
||||||
|
;;
|
||||||
|
cp)
|
||||||
|
[[ "${1:-}" == "fake-release-container:/release/." ]] || exit 94
|
||||||
|
destination="${2%/}"
|
||||||
|
fake-populate-release "$destination" "$FAKE_REVISION"
|
||||||
|
;;
|
||||||
|
container)
|
||||||
|
[[ "${1:-}" == "rm" ]] || exit 95
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Unerwarteter Docker-Aufruf: %s %s\n' "$command_name" "$*" >&2
|
||||||
|
exit 96
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/install" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-d" ]]; then
|
||||||
|
exec /usr/bin/install "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
destination="${!#}"
|
||||||
|
if [[ "$destination" == "/usr/local/sbin/watermaps-auto-deploy" ]]; then
|
||||||
|
source_file="${@: -2:1}"
|
||||||
|
[[ -x "$source_file" ]]
|
||||||
|
printf '%s\n' "watermaps-auto-deploy-entrypoint" >>"$FAKE_INSTALL_LOG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$destination" == "/etc/systemd/system/" ]]; then
|
||||||
|
source_file="${@: -2:1}"
|
||||||
|
[[ -f "$source_file" ]]
|
||||||
|
printf '%s\n' "$(basename "$source_file")" >>"$FAKE_INSTALL_LOG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /usr/bin/install "$@"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$FAKE_BIN/systemctl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" >>"$FAKE_SYSTEMCTL_LOG"
|
||||||
|
SH
|
||||||
|
|
||||||
|
chmod 0755 "$FAKE_BIN/"*
|
||||||
|
export PATH="$FAKE_BIN:/usr/bin:/bin"
|
||||||
|
|
||||||
|
new_case() {
|
||||||
|
local name="$1"
|
||||||
|
|
||||||
|
CASE_DIR="$TEST_ROOT/$name"
|
||||||
|
CASE_DATA="$CASE_DIR/data"
|
||||||
|
CASE_RUNTIME="$CASE_DIR/runtime"
|
||||||
|
CASE_INSTALL="$CASE_DIR/install"
|
||||||
|
CASE_ENV="$CASE_DIR/production.env"
|
||||||
|
mkdir -p \
|
||||||
|
"$CASE_DATA" \
|
||||||
|
"$CASE_RUNTIME" \
|
||||||
|
"$CASE_INSTALL/deploy"
|
||||||
|
|
||||||
|
FAKE_DOCKER_LOG="$CASE_DIR/docker.log"
|
||||||
|
FAKE_DEPLOY_LOG="$CASE_DIR/deploy.log"
|
||||||
|
FAKE_SYSTEMCTL_LOG="$CASE_DIR/systemctl.log"
|
||||||
|
FAKE_INSTALL_LOG="$CASE_DIR/install.log"
|
||||||
|
FAKE_CURL_LOG="$CASE_DIR/curl.log"
|
||||||
|
: >"$FAKE_DOCKER_LOG"
|
||||||
|
: >"$FAKE_DEPLOY_LOG"
|
||||||
|
: >"$FAKE_SYSTEMCTL_LOG"
|
||||||
|
: >"$FAKE_INSTALL_LOG"
|
||||||
|
: >"$FAKE_CURL_LOG"
|
||||||
|
|
||||||
|
cat >"$CASE_ENV" <<EOF
|
||||||
|
WATERMAPS_DATA_DIR=$CASE_DATA
|
||||||
|
WATERMAPS_RUNTIME_DIR=$CASE_RUNTIME
|
||||||
|
WATERMAPS_POSTGRES_PASSWORD=0123456789abcdef0123456789abcdef
|
||||||
|
WATERMAPS_REGISTRY=registry.example
|
||||||
|
WATERMAPS_REGISTRY_OWNER=team
|
||||||
|
WATERMAPS_GITEA_REPOSITORY=team/watermaps
|
||||||
|
EOF
|
||||||
|
|
||||||
|
export CASE_DIR CASE_DATA CASE_RUNTIME CASE_INSTALL
|
||||||
|
export FAKE_DOCKER_LOG FAKE_DEPLOY_LOG FAKE_SYSTEMCTL_LOG
|
||||||
|
export FAKE_INSTALL_LOG FAKE_CURL_LOG
|
||||||
|
export FAKE_REVISION="$REVISION"
|
||||||
|
export FAKE_MAIN_REVISION="$REVISION"
|
||||||
|
export FAKE_POINTER_REVISION="$REVISION"
|
||||||
|
export FAKE_POINTER_ID="sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||||
|
export FAKE_IMMUTABLE_ID="$FAKE_POINTER_ID"
|
||||||
|
export FAKE_RELEASE_POINTER="$RELEASE_POINTER"
|
||||||
|
export FAKE_APP_IMAGE="$APP_IMAGE"
|
||||||
|
export FAKE_ROUTE_IMAGE="$ROUTE_IMAGE"
|
||||||
|
export FAKE_DEPLOY_STATUS=0
|
||||||
|
export FAKE_CORRUPT_BUNDLE=0
|
||||||
|
export WATERMAPS_ENV_FILE="$CASE_ENV"
|
||||||
|
export WATERMAPS_INSTALL_DIR="$CASE_INSTALL"
|
||||||
|
}
|
||||||
|
|
||||||
|
populate_release() {
|
||||||
|
local revision="$1"
|
||||||
|
local destination="$CASE_DATA/releases/$revision"
|
||||||
|
|
||||||
|
mkdir -p "$destination"
|
||||||
|
fake-populate-release "$destination" "$revision"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_auto_deploy() {
|
||||||
|
set +e
|
||||||
|
RUN_OUTPUT="$("$AUTO_DEPLOY" 2>&1)"
|
||||||
|
RUN_STATUS=$?
|
||||||
|
set -e
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_systemd_installed() {
|
||||||
|
local expected_units=(
|
||||||
|
watermaps-auto-deploy.service
|
||||||
|
watermaps-auto-deploy.timer
|
||||||
|
watermaps-route-update.service
|
||||||
|
watermaps-route-update.timer
|
||||||
|
watermaps-certbot-renew.service
|
||||||
|
watermaps-certbot-renew.timer
|
||||||
|
)
|
||||||
|
local unit
|
||||||
|
|
||||||
|
[[ "$(wc -l <"$FAKE_INSTALL_LOG")" -eq "$((${#expected_units[@]} + 1))" ]] ||
|
||||||
|
fail "Es wurden nicht exakt sechs Systemd-Units und der stabile Einstiegspunkt installiert."
|
||||||
|
grep -Fxq "watermaps-auto-deploy-entrypoint" "$FAKE_INSTALL_LOG" ||
|
||||||
|
fail "Stabiler Auto-Deploy-Einstiegspunkt wurde nicht installiert."
|
||||||
|
for unit in "${expected_units[@]}"; do
|
||||||
|
grep -Fxq "$unit" "$FAKE_INSTALL_LOG" ||
|
||||||
|
fail "Systemd-Unit wurde nicht installiert: $unit"
|
||||||
|
done
|
||||||
|
grep -Fxq 'daemon-reload' "$FAKE_SYSTEMCTL_LOG" ||
|
||||||
|
fail "systemctl daemon-reload fehlt."
|
||||||
|
assert_file_contains \
|
||||||
|
'enable --now watermaps-auto-deploy.timer watermaps-route-update.timer watermaps-certbot-renew.timer' \
|
||||||
|
"$FAKE_SYSTEMCTL_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_already_active_repairs_current_link() {
|
||||||
|
new_case already-active
|
||||||
|
mkdir -p "$CASE_DATA/releases"
|
||||||
|
populate_release "$REVISION"
|
||||||
|
write_images_file \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images" \
|
||||||
|
"$REVISION" \
|
||||||
|
"$APP_IMAGE" \
|
||||||
|
"$ROUTE_IMAGE"
|
||||||
|
mkdir -p "$CASE_DIR/wrong-release"
|
||||||
|
ln -s "$CASE_DIR/wrong-release" "$CASE_INSTALL/current"
|
||||||
|
|
||||||
|
run_auto_deploy
|
||||||
|
|
||||||
|
[[ "$RUN_STATUS" -eq 0 ]] || fail "Bereits aktives Release schlug fehl: $RUN_OUTPUT"
|
||||||
|
assert_contains "Der atomare Current-Link für Commit $REVISION wurde repariert." "$RUN_OUTPUT"
|
||||||
|
assert_contains "Commit $REVISION ist bereits aktiv." "$RUN_OUTPUT"
|
||||||
|
[[ "$(readlink --canonicalize "$CASE_INSTALL/current")" == \
|
||||||
|
"$(readlink --canonicalize "$CASE_DATA/releases/$REVISION")" ]] ||
|
||||||
|
fail "Current-Link wurde für das aktive Release nicht repariert."
|
||||||
|
[[ ! -s "$FAKE_DEPLOY_LOG" ]] ||
|
||||||
|
fail "Ein bereits aktives Release darf deploy.sh nicht erneut ausführen."
|
||||||
|
assert_file_not_contains 'docker|create ' "$FAKE_DOCKER_LOG"
|
||||||
|
assert_file_not_contains 'docker|cp ' "$FAKE_DOCKER_LOG"
|
||||||
|
assert_systemd_installed
|
||||||
|
assert_no_temporary_artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
test_successful_release_uses_manifest_digests() {
|
||||||
|
new_case successful-release
|
||||||
|
|
||||||
|
run_auto_deploy
|
||||||
|
|
||||||
|
[[ "$RUN_STATUS" -eq 0 ]] || fail "Neues Release schlug fehl: $RUN_OUTPUT"
|
||||||
|
assert_contains "Automatisches Deployment von Commit $REVISION abgeschlossen." "$RUN_OUTPUT"
|
||||||
|
[[ -f "$CASE_DATA/releases/$REVISION/SHA256SUMS" ]] ||
|
||||||
|
fail "Validiertes Release-Bundle wurde nicht atomar installiert."
|
||||||
|
(
|
||||||
|
cd "$CASE_DATA/releases/$REVISION"
|
||||||
|
sha256sum --check --strict SHA256SUMS >/dev/null
|
||||||
|
) || fail "Installiertes Release-Bundle besteht seine SHA256-Prüfung nicht."
|
||||||
|
[[ "$(readlink --canonicalize "$CASE_INSTALL/current")" == \
|
||||||
|
"$(readlink --canonicalize "$CASE_DATA/releases/$REVISION")" ]] ||
|
||||||
|
fail "Current-Link zeigt nicht auf das neue Release."
|
||||||
|
|
||||||
|
[[ "$(wc -l <"$FAKE_DEPLOY_LOG")" -eq 1 ]] ||
|
||||||
|
fail "Das neue Release muss deploy.sh exakt einmal ausführen."
|
||||||
|
IFS='|' read -r invoked_script deployed_revision rollback_disabled \
|
||||||
|
images_file deployed_app deployed_route <"$FAKE_DEPLOY_LOG"
|
||||||
|
[[ "$invoked_script" == \
|
||||||
|
"$CASE_DATA/releases/$REVISION/deploy/scripts/deploy.sh" ]] ||
|
||||||
|
fail "deploy.sh wurde nicht aus dem unveränderlichen Release-Bundle ausgeführt."
|
||||||
|
[[ "$deployed_revision" == "$REVISION" ]] ||
|
||||||
|
fail "deploy.sh erhielt die falsche Revision."
|
||||||
|
[[ "$rollback_disabled" == "true" ]] ||
|
||||||
|
fail "Der interne Rollback muss beim orchestrierten Deployment deaktiviert sein."
|
||||||
|
[[ "$images_file" == "$CASE_RUNTIME"/.auto-deploy-images.* ]] ||
|
||||||
|
fail "deploy.sh erhielt keine temporäre, validierte Image-Datei."
|
||||||
|
[[ "$deployed_app" == "$APP_IMAGE" && "$deployed_route" == "$ROUTE_IMAGE" ]] ||
|
||||||
|
fail "deploy.sh erhielt nicht die im Release-Manifest gebundenen Digests."
|
||||||
|
[[ ! -e "$images_file" ]] ||
|
||||||
|
fail "Temporäre Image-Datei blieb nach dem Deployment bestehen."
|
||||||
|
|
||||||
|
assert_file_contains "docker|pull $APP_IMAGE" "$FAKE_DOCKER_LOG"
|
||||||
|
assert_file_contains "docker|pull $ROUTE_IMAGE" "$FAKE_DOCKER_LOG"
|
||||||
|
assert_file_not_contains "docker|pull registry.example/team/watermaps:$REVISION" \
|
||||||
|
"$FAKE_DOCKER_LOG"
|
||||||
|
assert_file_contains "WATERMAPS_DEPLOY_REVISION=$REVISION" \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images"
|
||||||
|
assert_file_contains "WATERMAPS_APP_IMAGE=$APP_IMAGE" \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images"
|
||||||
|
assert_file_contains "WATERMAPS_ROUTE_DATA_IMAGE=$ROUTE_IMAGE" \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images"
|
||||||
|
assert_systemd_installed
|
||||||
|
assert_no_temporary_artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
test_pointer_main_mismatch_does_not_deploy() {
|
||||||
|
new_case pointer-main-mismatch
|
||||||
|
export FAKE_MAIN_REVISION="$PREVIOUS_REVISION"
|
||||||
|
|
||||||
|
run_auto_deploy
|
||||||
|
|
||||||
|
[[ "$RUN_STATUS" -eq 0 ]] ||
|
||||||
|
fail "Pointer/main-Abweichung soll sauber warten: $RUN_OUTPUT"
|
||||||
|
assert_contains \
|
||||||
|
"Release $REVISION ist nicht der aktuelle main-Commit $PREVIOUS_REVISION" \
|
||||||
|
"$RUN_OUTPUT"
|
||||||
|
[[ ! -e "$CASE_DATA/releases/$REVISION" ]] ||
|
||||||
|
fail "Bei Pointer/main-Abweichung darf kein Bundle extrahiert werden."
|
||||||
|
[[ ! -L "$CASE_INSTALL/current" ]] ||
|
||||||
|
fail "Bei Pointer/main-Abweichung darf Current nicht verändert werden."
|
||||||
|
[[ ! -s "$FAKE_DEPLOY_LOG" ]] ||
|
||||||
|
fail "Bei Pointer/main-Abweichung darf deploy.sh nicht laufen."
|
||||||
|
[[ ! -s "$FAKE_SYSTEMCTL_LOG" && ! -s "$FAKE_INSTALL_LOG" ]] ||
|
||||||
|
fail "Bei Pointer/main-Abweichung darf Systemd nicht verändert werden."
|
||||||
|
assert_file_contains "docker|pull $RELEASE_POINTER" "$FAKE_DOCKER_LOG"
|
||||||
|
assert_file_not_contains "docker|pull $RELEASE_IMAGE" "$FAKE_DOCKER_LOG"
|
||||||
|
assert_file_not_contains 'docker|create ' "$FAKE_DOCKER_LOG"
|
||||||
|
assert_no_temporary_artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
test_failed_release_restores_previous_immutable_release() {
|
||||||
|
new_case failed-release
|
||||||
|
mkdir -p "$CASE_DATA/releases" "$CASE_RUNTIME/deployments"
|
||||||
|
populate_release "$PREVIOUS_REVISION"
|
||||||
|
write_images_file \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images" \
|
||||||
|
"$PREVIOUS_REVISION" \
|
||||||
|
"$PREVIOUS_APP_IMAGE" \
|
||||||
|
"$PREVIOUS_ROUTE_IMAGE"
|
||||||
|
cp \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images" \
|
||||||
|
"$CASE_RUNTIME/deployments/$PREVIOUS_REVISION.env"
|
||||||
|
ln -s "$CASE_DATA/releases/$PREVIOUS_REVISION" "$CASE_INSTALL/current"
|
||||||
|
export FAKE_DEPLOY_STATUS=42
|
||||||
|
|
||||||
|
run_auto_deploy
|
||||||
|
|
||||||
|
[[ "$RUN_STATUS" -eq 42 ]] ||
|
||||||
|
fail "Fehlgeschlagenes Release muss seinen Status weitergeben: $RUN_OUTPUT"
|
||||||
|
assert_contains \
|
||||||
|
"Das vorherige Release $PREVIOUS_REVISION wird aus seinem unveränderlichen Bundle wiederhergestellt." \
|
||||||
|
"$RUN_OUTPUT"
|
||||||
|
assert_contains \
|
||||||
|
"Vorheriges Release $PREVIOUS_REVISION wurde erneut geprüft und ist aktiv." \
|
||||||
|
"$RUN_OUTPUT"
|
||||||
|
[[ "$(wc -l <"$FAKE_DEPLOY_LOG")" -eq 2 ]] ||
|
||||||
|
fail "Nach einem Fehler müssen Kandidat und vorheriges Release genau einmal laufen."
|
||||||
|
|
||||||
|
candidate_log="$(sed -n '1p' "$FAKE_DEPLOY_LOG")"
|
||||||
|
previous_log="$(sed -n '2p' "$FAKE_DEPLOY_LOG")"
|
||||||
|
assert_contains \
|
||||||
|
"$CASE_DATA/releases/$REVISION/deploy/scripts/deploy.sh|$REVISION|true|" \
|
||||||
|
"$candidate_log"
|
||||||
|
assert_contains "|$APP_IMAGE|$ROUTE_IMAGE" "$candidate_log"
|
||||||
|
assert_contains \
|
||||||
|
"$CASE_DATA/releases/$PREVIOUS_REVISION/deploy/scripts/deploy.sh|$PREVIOUS_REVISION|true|$CASE_RUNTIME/deployments/$PREVIOUS_REVISION.env|" \
|
||||||
|
"$previous_log"
|
||||||
|
assert_contains "|$PREVIOUS_APP_IMAGE|$PREVIOUS_ROUTE_IMAGE" "$previous_log"
|
||||||
|
|
||||||
|
[[ "$(readlink --canonicalize "$CASE_INSTALL/current")" == \
|
||||||
|
"$(readlink --canonicalize "$CASE_DATA/releases/$PREVIOUS_REVISION")" ]] ||
|
||||||
|
fail "Current-Link wurde nach dem Fehler nicht auf das vorherige Release zurückgesetzt."
|
||||||
|
assert_file_contains "WATERMAPS_DEPLOY_REVISION=$PREVIOUS_REVISION" \
|
||||||
|
"$CASE_INSTALL/deploy/.env.images"
|
||||||
|
[[ ! -s "$FAKE_SYSTEMCTL_LOG" && ! -s "$FAKE_INSTALL_LOG" ]] ||
|
||||||
|
fail "Ein fehlgeschlagener Kandidat darf keine neuen Systemd-Units aktivieren."
|
||||||
|
assert_no_temporary_artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
test_invalid_bundle_is_rejected_and_cleaned() {
|
||||||
|
new_case corrupt-release
|
||||||
|
export FAKE_CORRUPT_BUNDLE=1
|
||||||
|
|
||||||
|
run_auto_deploy
|
||||||
|
|
||||||
|
[[ "$RUN_STATUS" -ne 0 ]] ||
|
||||||
|
fail "Ein manipuliertes Release-Bundle wurde akzeptiert."
|
||||||
|
assert_contains "Release-Bundle hat die Integritätsprüfung nicht bestanden." "$RUN_OUTPUT"
|
||||||
|
[[ ! -e "$CASE_DATA/releases/$REVISION" ]] ||
|
||||||
|
fail "Manipuliertes Bundle wurde in den Release-Store verschoben."
|
||||||
|
[[ ! -s "$FAKE_DEPLOY_LOG" ]] ||
|
||||||
|
fail "Manipuliertes Bundle darf deploy.sh nicht ausführen."
|
||||||
|
[[ ! -s "$FAKE_SYSTEMCTL_LOG" && ! -s "$FAKE_INSTALL_LOG" ]] ||
|
||||||
|
fail "Manipuliertes Bundle darf Systemd nicht verändern."
|
||||||
|
assert_file_contains 'docker|container rm fake-release-container' "$FAKE_DOCKER_LOG"
|
||||||
|
assert_no_temporary_artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
test_stable_entrypoint_bootstraps_and_then_uses_current() {
|
||||||
|
local entrypoint_root="$TEST_ROOT/stable-entrypoint"
|
||||||
|
local output
|
||||||
|
|
||||||
|
mkdir -p "$entrypoint_root/deploy/scripts"
|
||||||
|
cat >"$entrypoint_root/deploy/scripts/auto-deploy.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'bootstrap\n'
|
||||||
|
SH
|
||||||
|
chmod 0755 "$entrypoint_root/deploy/scripts/auto-deploy.sh"
|
||||||
|
|
||||||
|
output="$(
|
||||||
|
WATERMAPS_INSTALL_DIR="$entrypoint_root" \
|
||||||
|
"$ROOT_DIR/deploy/scripts/auto-deploy-entrypoint.sh"
|
||||||
|
)"
|
||||||
|
[[ "$output" == "bootstrap" ]] ||
|
||||||
|
fail "Stabiler Einstiegspunkt verwendet vor dem ersten Release nicht das Bootstrap-Skript."
|
||||||
|
|
||||||
|
mkdir -p "$entrypoint_root/current/deploy/scripts"
|
||||||
|
cat >"$entrypoint_root/current/deploy/scripts/auto-deploy.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'current\n'
|
||||||
|
SH
|
||||||
|
chmod 0755 "$entrypoint_root/current/deploy/scripts/auto-deploy.sh"
|
||||||
|
|
||||||
|
output="$(
|
||||||
|
WATERMAPS_INSTALL_DIR="$entrypoint_root" \
|
||||||
|
"$ROOT_DIR/deploy/scripts/auto-deploy-entrypoint.sh"
|
||||||
|
)"
|
||||||
|
[[ "$output" == "current" ]] ||
|
||||||
|
fail "Stabiler Einstiegspunkt wechselt nach der Aktivierung nicht auf Current."
|
||||||
|
}
|
||||||
|
|
||||||
|
test_stable_entrypoint_bootstraps_and_then_uses_current
|
||||||
|
test_already_active_repairs_current_link
|
||||||
|
test_successful_release_uses_manifest_digests
|
||||||
|
test_pointer_main_mismatch_does_not_deploy
|
||||||
|
test_failed_release_restores_previous_immutable_release
|
||||||
|
test_invalid_bundle_is_rejected_and_cleaned
|
||||||
|
|
||||||
|
printf 'Auto-Deploy: Manifest-Digests, atomare Aktivierung und Rollback: OK\n'
|
||||||
@@ -99,17 +99,6 @@ if [[ -n "$registry_username" || -n "$registry_token" ]]; then
|
|||||||
wm_local_die "WATERMAPS_REGISTRY_USERNAME enthält ungültige Zeichen."
|
wm_local_die "WATERMAPS_REGISTRY_USERNAME enthält ungültige Zeichen."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
images_env="$(mktemp)"
|
|
||||||
trap 'rm -f "$images_env"' EXIT
|
|
||||||
chmod 0600 "$images_env"
|
|
||||||
{
|
|
||||||
printf 'WATERMAPS_DEPLOY_REVISION=%s\n' "$revision"
|
|
||||||
printf 'WATERMAPS_APP_IMAGE=%s/%s/watermaps:%s\n' \
|
|
||||||
"$registry" "$registry_owner" "$revision"
|
|
||||||
printf 'WATERMAPS_ROUTE_DATA_IMAGE=%s/%s/watermaps-route-data:%s\n' \
|
|
||||||
"$registry" "$registry_owner" "$revision"
|
|
||||||
} >"$images_env"
|
|
||||||
|
|
||||||
wm_local_resolve_ssh "$server_ipv4" "$identity_file"
|
wm_local_resolve_ssh "$server_ipv4" "$identity_file"
|
||||||
wm_local_wait_for_ssh
|
wm_local_wait_for_ssh
|
||||||
|
|
||||||
@@ -124,9 +113,9 @@ printf '[watermaps] Warte auf Cloud-init und das persistente Hetzner-Volume.\n'
|
|||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||||
"cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data"
|
"cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data"
|
||||||
|
|
||||||
printf '[watermaps] Übertrage Deployment-Dateien und Datenbankschema nach %s:/opt/watermaps\n' "$WM_SSH_TARGET"
|
printf '[watermaps] Übertrage Bootstrap-Dateien nach %s:/opt/watermaps\n' "$WM_SSH_TARGET"
|
||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||||
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy /opt/watermaps/database"
|
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy"
|
||||||
|
|
||||||
rsync \
|
rsync \
|
||||||
--archive \
|
--archive \
|
||||||
@@ -136,18 +125,12 @@ rsync \
|
|||||||
--rsync-path="$rsync_path" \
|
--rsync-path="$rsync_path" \
|
||||||
--exclude='.env.production' \
|
--exclude='.env.production' \
|
||||||
--exclude='.env.images*' \
|
--exclude='.env.images*' \
|
||||||
|
--exclude='.gitea-token' \
|
||||||
|
--exclude='.gitea-*-token' \
|
||||||
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
|
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
|
||||||
"$WM_DEPLOY_DIR/" \
|
"$WM_DEPLOY_DIR/" \
|
||||||
"$WM_SSH_TARGET:/opt/watermaps/deploy/"
|
"$WM_SSH_TARGET:/opt/watermaps/deploy/"
|
||||||
|
|
||||||
rsync \
|
|
||||||
--archive \
|
|
||||||
--chmod=F644 \
|
|
||||||
--rsync-path="$rsync_path" \
|
|
||||||
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
|
|
||||||
"$WM_LOCAL_ROOT_DIR/database/schema.sql" \
|
|
||||||
"$WM_SSH_TARGET:/opt/watermaps/database/schema.sql"
|
|
||||||
|
|
||||||
rsync \
|
rsync \
|
||||||
--archive \
|
--archive \
|
||||||
--chmod=F600 \
|
--chmod=F600 \
|
||||||
@@ -156,14 +139,6 @@ rsync \
|
|||||||
"$local_env" \
|
"$local_env" \
|
||||||
"$WM_SSH_TARGET:/opt/watermaps/deploy/.env.production"
|
"$WM_SSH_TARGET:/opt/watermaps/deploy/.env.production"
|
||||||
|
|
||||||
rsync \
|
|
||||||
--archive \
|
|
||||||
--chmod=F600 \
|
|
||||||
--rsync-path="$rsync_path" \
|
|
||||||
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
|
|
||||||
"$images_env" \
|
|
||||||
"$WM_SSH_TARGET:/opt/watermaps/deploy/.env.images.candidate"
|
|
||||||
|
|
||||||
if [[ -n "$registry_token" ]]; then
|
if [[ -n "$registry_token" ]]; then
|
||||||
printf '[watermaps] Aktualisiere den privaten Registry-Login auf dem Server.\n'
|
printf '[watermaps] Aktualisiere den privaten Registry-Login auf dem Server.\n'
|
||||||
printf '%s\n' "$registry_token" |
|
printf '%s\n' "$registry_token" |
|
||||||
@@ -173,14 +148,14 @@ fi
|
|||||||
|
|
||||||
remote_command="${remote_prefix}chmod +x /opt/watermaps/deploy/scripts/*.sh"
|
remote_command="${remote_prefix}chmod +x /opt/watermaps/deploy/scripts/*.sh"
|
||||||
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/bootstrap-server.sh"
|
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/bootstrap-server.sh"
|
||||||
remote_command+=" && ${remote_prefix}/opt/watermaps/deploy/scripts/deploy.sh --images-file /opt/watermaps/deploy/.env.images.candidate"
|
remote_command+=" && ${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production /opt/watermaps/deploy/scripts/auto-deploy.sh"
|
||||||
|
remote_command+=" && ${remote_prefix}test -L /opt/watermaps/current"
|
||||||
|
remote_command+=" && ${remote_prefix}grep -Fxq 'WATERMAPS_DEPLOY_REVISION=$revision' /opt/watermaps/deploy/.env.images"
|
||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" "$remote_command"
|
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" "$remote_command"
|
||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
|
||||||
"${remote_prefix}rm -f /opt/watermaps/deploy/.env.images.candidate"
|
|
||||||
|
|
||||||
if [[ "$run_go_live" == "true" ]]; then
|
if [[ "$run_go_live" == "true" ]]; then
|
||||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||||
"${remote_prefix}/opt/watermaps/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
|
"${remote_prefix}WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production /opt/watermaps/current/deploy/scripts/go-live.sh '$WM_SERVER_IPV4'"
|
||||||
else
|
else
|
||||||
printf '[watermaps] Commit %s wurde aus der Registry deployt. Nach dem DNS-Eintrag:\n' "$revision"
|
printf '[watermaps] Commit %s wurde aus der Registry deployt. Nach dem DNS-Eintrag:\n' "$revision"
|
||||||
printf ' ./deploy/scripts/remote-go-live.sh --host %s\n' "$WM_SERVER_IPV4"
|
printf ' ./deploy/scripts/remote-go-live.sh --host %s\n' "$WM_SERVER_IPV4"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Neues Watermaps-Release aus der Gitea Registry deployen
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target docker.service watermaps-volume-setup.service
|
||||||
|
Requires=docker.service
|
||||||
|
RequiresMountsFor=/srv/watermaps-data
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/watermaps
|
||||||
|
Environment=WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production
|
||||||
|
Environment=WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images
|
||||||
|
ExecStart=/usr/local/sbin/watermaps-auto-deploy
|
||||||
|
TimeoutStartSec=12h
|
||||||
|
UMask=0027
|
||||||
|
Nice=5
|
||||||
|
IOSchedulingClass=best-effort
|
||||||
|
IOSchedulingPriority=5
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gitea regelmäßig auf ein neues Watermaps-Release prüfen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3m
|
||||||
|
OnUnitInactiveSec=1m
|
||||||
|
RandomizedDelaySec=10s
|
||||||
|
Persistent=true
|
||||||
|
Unit=watermaps-auto-deploy.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -7,5 +7,7 @@ RequiresMountsFor=/srv/watermaps-data
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
WorkingDirectory=/opt/watermaps
|
WorkingDirectory=/opt/watermaps/current
|
||||||
ExecStart=/opt/watermaps/deploy/scripts/renew-certificate.sh
|
Environment=WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production
|
||||||
|
Environment=WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images
|
||||||
|
ExecStart=/opt/watermaps/current/deploy/scripts/renew-certificate.sh
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ RequiresMountsFor=/srv/watermaps-data
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
WorkingDirectory=/opt/watermaps
|
WorkingDirectory=/opt/watermaps/current
|
||||||
ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh
|
Environment=WATERMAPS_ENV_FILE=/opt/watermaps/deploy/.env.production
|
||||||
|
Environment=WATERMAPS_IMAGES_ENV_FILE=/opt/watermaps/deploy/.env.images
|
||||||
|
ExecStart=/opt/watermaps/current/deploy/scripts/update-route-data.sh
|
||||||
TimeoutStartSec=0
|
TimeoutStartSec=0
|
||||||
Nice=10
|
Nice=10
|
||||||
IOSchedulingClass=best-effort
|
IOSchedulingClass=best-effort
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
- ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
- ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
|
||||||
|
- ./database/migrations:/docker-entrypoint-initdb.d/migrations:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U seacompass -d seacompass"]
|
test: ["CMD-SHELL", "pg_isready -U seacompass -d seacompass"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ chmod 600 terraform.tfvars
|
|||||||
```
|
```
|
||||||
|
|
||||||
Auch `terraform.tfstate` bleibt lokal und wird nicht nach Git oder auf den
|
Auch `terraform.tfstate` bleibt lokal und wird nicht nach Git oder auf den
|
||||||
Anwendungsserver übertragen. Nach dem ersten Apply sollte die State-Datei
|
Anwendungsserver übertragen. Nach jedem Import oder Apply muss die State-Datei
|
||||||
verschlüsselt gesichert werden, weil OpenTofu sie für spätere Änderungen an
|
mit Dateimodus `0600` verschlüsselt gesichert werden, weil OpenTofu sie für
|
||||||
denselben Ressourcen benötigt.
|
spätere Änderungen an denselben Ressourcen benötigt. Ist ein älterer State
|
||||||
|
verloren, werden vorhandene Ressourcen vor jedem Apply anhand ihrer
|
||||||
|
Hetzner-IDs importiert; ein paralleles Neuerzeugen gleichnamiger Ressourcen ist
|
||||||
|
nicht zulässig.
|
||||||
|
|
||||||
## Infrastruktur erzeugen
|
## Infrastruktur erzeugen
|
||||||
|
|
||||||
@@ -44,10 +47,15 @@ cd infra/opentofu
|
|||||||
tofu init
|
tofu init
|
||||||
tofu fmt -check
|
tofu fmt -check
|
||||||
tofu validate
|
tofu validate
|
||||||
|
umask 077
|
||||||
tofu plan -out=watermaps.tfplan
|
tofu plan -out=watermaps.tfplan
|
||||||
tofu apply watermaps.tfplan
|
tofu apply watermaps.tfplan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Gespeicherte Plan-Dateien enthalten trotz als sensibel markierter Variablen
|
||||||
|
unter Umständen den Hetzner-Token. Sie müssen deshalb wie `terraform.tfvars`
|
||||||
|
und der State immer Modus `0600` haben und dürfen nicht weitergegeben werden.
|
||||||
|
|
||||||
Die feste IPv4-Adresse und den erforderlichen manuellen DNS-Eintrag zeigt
|
Die feste IPv4-Adresse und den erforderlichen manuellen DNS-Eintrag zeigt
|
||||||
OpenTofu anschließend an:
|
OpenTofu anschließend an:
|
||||||
|
|
||||||
@@ -79,3 +87,25 @@ Der Status des ersten Starts lässt sich so prüfen:
|
|||||||
ssh deploy@"$(tofu output -raw server_ipv4)" \
|
ssh deploy@"$(tofu output -raw server_ipv4)" \
|
||||||
"cloud-init status --wait && systemctl status watermaps-volume-setup --no-pager"
|
"cloud-init status --wait && systemctl status watermaps-volume-setup --no-pager"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Cloud-init bindet ausschließlich das von OpenTofu erzeugte Volume über den
|
||||||
|
stabilen Hetzner-Gerätepfad
|
||||||
|
`/dev/disk/by-id/scsi-0HC_Volume_<volume-id>` ein. Es wird nicht heuristisch
|
||||||
|
das erste Blockgerät mit dem Modellnamen `Volume` ausgewählt. Nach dem Mount
|
||||||
|
wird zusätzlich die UUID des tatsächlich eingehängten Geräts geprüft. Ein
|
||||||
|
Volume ohne lesbares Dateisystem wird standardmäßig nicht formatiert; nur bei
|
||||||
|
einem garantiert neuen, leeren Volume darf
|
||||||
|
`initialize_empty_routing_volume = true` für die einmalige Initialisierung
|
||||||
|
gesetzt werden. Bei Importen und Serverwechseln bleibt der Wert `false`.
|
||||||
|
|
||||||
|
Der Löschschutz ist getrennt konfiguriert:
|
||||||
|
|
||||||
|
- `enable_server_protection` für den austauschbaren Host,
|
||||||
|
- `enable_primary_ip_protection` für die feste öffentliche Adresse,
|
||||||
|
- `enable_volume_protection` für Routing-, Zertifikats- und PostGIS-Daten.
|
||||||
|
|
||||||
|
Bei einem kontrollierten Servertausch bleiben IP- und Volume-Schutz immer
|
||||||
|
aktiv. Ein Plan, der `hcloud_primary_ip.main` oder
|
||||||
|
`hcloud_volume.routing_data` ersetzen beziehungsweise löschen will, darf nicht
|
||||||
|
angewendet werden. Zusätzlich verhindert OpenTofu für beide Ressourcen mit
|
||||||
|
`prevent_destroy` eine versehentliche Löschung durch den IaC-Plan.
|
||||||
|
|||||||
@@ -50,36 +50,45 @@ write_files:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
mount_path="/srv/watermaps-data"
|
mount_path="/srv/watermaps-data"
|
||||||
|
device="/dev/disk/by-id/scsi-0HC_Volume_${routing_volume_id}"
|
||||||
|
allow_initial_format="${initialize_empty_routing_volume}"
|
||||||
install -d -m 0755 "$mount_path"
|
install -d -m 0755 "$mount_path"
|
||||||
|
|
||||||
if ! mountpoint -q "$mount_path"; then
|
for attempt in $(seq 1 120); do
|
||||||
device=""
|
if [ -b "$device" ]; then
|
||||||
for attempt in $(seq 1 120); do
|
break
|
||||||
device="$(lsblk -dnpo NAME,MODEL | awk '$2 == "Volume" { print $1; exit }')"
|
fi
|
||||||
if [ -n "$device" ] && [ -b "$device" ]; then
|
sleep 5
|
||||||
break
|
done
|
||||||
fi
|
|
||||||
device=""
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$device" ]; then
|
if [ ! -b "$device" ]; then
|
||||||
echo "Kein angehängtes Hetzner Cloud Volume gefunden." >&2
|
echo "Hetzner Cloud Volume ${routing_volume_id} wurde nicht unter $device gefunden." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
filesystem="$(blkid -o value -s TYPE "$device" || true)"
|
||||||
|
if [ -z "$filesystem" ]; then
|
||||||
|
signatures="$(wipefs --no-act --output TYPE --noheadings "$device" | tr -d '[:space:]')"
|
||||||
|
if [ -n "$signatures" ]; then
|
||||||
|
echo "Volume enthält unbekannte Signaturen und wird nicht verändert." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [ "$allow_initial_format" = "true" ]; then
|
||||||
filesystem="$(blkid -o value -s TYPE "$device" || true)"
|
|
||||||
if [ -z "$filesystem" ]; then
|
|
||||||
mkfs.ext4 -F "$device"
|
mkfs.ext4 -F "$device"
|
||||||
filesystem="ext4"
|
filesystem="ext4"
|
||||||
fi
|
else
|
||||||
|
echo "Volume hat kein lesbares Dateisystem; automatische Formatierung ist deaktiviert." >&2
|
||||||
if [ "$filesystem" != "ext4" ]; then
|
|
||||||
echo "Unerwartetes Dateisystem auf $device: $filesystem" >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
uuid="$(blkid -o value -s UUID "$device")"
|
if [ "$filesystem" != "ext4" ]; then
|
||||||
|
echo "Unerwartetes Dateisystem auf $device: $filesystem" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
uuid="$(blkid -o value -s UUID "$device")"
|
||||||
|
if ! mountpoint -q "$mount_path"; then
|
||||||
if ! grep -q "^UUID=$uuid " /etc/fstab; then
|
if ! grep -q "^UUID=$uuid " /etc/fstab; then
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"UUID=$uuid $mount_path ext4 defaults,nofail,x-systemd.device-timeout=30 0 2" \
|
"UUID=$uuid $mount_path ext4 defaults,nofail,x-systemd.device-timeout=30 0 2" \
|
||||||
@@ -89,6 +98,13 @@ write_files:
|
|||||||
mount "$mount_path"
|
mount "$mount_path"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mounted_source="$(findmnt --noheadings --output SOURCE --target "$mount_path")"
|
||||||
|
mounted_uuid="$(blkid -o value -s UUID "$mounted_source" || true)"
|
||||||
|
if [ "$mounted_uuid" != "$uuid" ]; then
|
||||||
|
echo "Unter $mount_path ist nicht das erwartete Volume ${routing_volume_id} eingehängt." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
install -d -m 0755 -o ${deploy_user} -g ${deploy_user} \
|
install -d -m 0755 -o ${deploy_user} -g ${deploy_user} \
|
||||||
"$mount_path/geofabrik" \
|
"$mount_path/geofabrik" \
|
||||||
"$mount_path/local" \
|
"$mount_path/local" \
|
||||||
|
|||||||
+16
-7
@@ -16,8 +16,12 @@ resource "hcloud_primary_ip" "main" {
|
|||||||
location = var.location
|
location = var.location
|
||||||
type = "ipv4"
|
type = "ipv4"
|
||||||
auto_delete = false
|
auto_delete = false
|
||||||
delete_protection = var.enable_resource_protection
|
delete_protection = var.enable_primary_ip_protection
|
||||||
labels = local.common_labels
|
labels = local.common_labels
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "hcloud_firewall" "main" {
|
resource "hcloud_firewall" "main" {
|
||||||
@@ -75,8 +79,10 @@ resource "hcloud_server" "main" {
|
|||||||
firewall_ids = [hcloud_firewall.main.id]
|
firewall_ids = [hcloud_firewall.main.id]
|
||||||
|
|
||||||
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
||||||
deploy_user = var.deploy_user
|
deploy_user = var.deploy_user
|
||||||
ssh_public_key = jsonencode(trimspace(var.ssh_public_key))
|
ssh_public_key = jsonencode(trimspace(var.ssh_public_key))
|
||||||
|
routing_volume_id = hcloud_volume.routing_data.id
|
||||||
|
initialize_empty_routing_volume = var.initialize_empty_routing_volume
|
||||||
})
|
})
|
||||||
|
|
||||||
public_net {
|
public_net {
|
||||||
@@ -85,8 +91,8 @@ resource "hcloud_server" "main" {
|
|||||||
ipv6_enabled = false
|
ipv6_enabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
delete_protection = var.enable_resource_protection
|
delete_protection = var.enable_server_protection
|
||||||
rebuild_protection = var.enable_resource_protection
|
rebuild_protection = var.enable_server_protection
|
||||||
shutdown_before_deletion = true
|
shutdown_before_deletion = true
|
||||||
|
|
||||||
labels = local.common_labels
|
labels = local.common_labels
|
||||||
@@ -96,9 +102,12 @@ resource "hcloud_volume" "routing_data" {
|
|||||||
name = "${var.server_name}-routing-data"
|
name = "${var.server_name}-routing-data"
|
||||||
location = var.location
|
location = var.location
|
||||||
size = var.routing_volume_size_gb
|
size = var.routing_volume_size_gb
|
||||||
format = "ext4"
|
delete_protection = var.enable_volume_protection
|
||||||
delete_protection = var.enable_resource_protection
|
|
||||||
labels = local.common_labels
|
labels = local.common_labels
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "hcloud_volume_attachment" "routing_data" {
|
resource "hcloud_volume_attachment" "routing_data" {
|
||||||
|
|||||||
@@ -19,5 +19,12 @@ server_type = "cx33"
|
|||||||
deploy_user = "deploy"
|
deploy_user = "deploy"
|
||||||
routing_volume_size_gb = 40
|
routing_volume_size_gb = 40
|
||||||
|
|
||||||
# Schützt Ressourcen vor versehentlicher Löschung über Console/API.
|
# Nur für ein neu angelegtes, garantiert leeres Volume einmalig auf true
|
||||||
enable_resource_protection = true
|
# setzen. Bei Import/Migration eines bestehenden Volumes immer false lassen.
|
||||||
|
initialize_empty_routing_volume = false
|
||||||
|
|
||||||
|
# Der Server-Schutz kann für einen kontrollierten Hosttausch separat gelöst
|
||||||
|
# werden, ohne jemals IP- oder Volume-Schutz zu deaktivieren.
|
||||||
|
enable_server_protection = true
|
||||||
|
enable_primary_ip_protection = true
|
||||||
|
enable_volume_protection = true
|
||||||
|
|||||||
@@ -115,8 +115,26 @@ variable "routing_volume_size_gb" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "enable_resource_protection" {
|
variable "initialize_empty_routing_volume" {
|
||||||
description = "Aktiviert Hetzner-Löschschutz für Server, Primary IP und Volume."
|
description = "Erlaubt genau bei einem nachweislich leeren Volume die initiale ext4-Formatierung. Für importierte oder bestehende Volumes muss dies false bleiben."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "enable_server_protection" {
|
||||||
|
description = "Aktiviert Hetzner-Lösch- und Rebuild-Schutz für den austauschbaren Server."
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "enable_primary_ip_protection" {
|
||||||
|
description = "Aktiviert Hetzner-Löschschutz für die persistente öffentliche IPv4."
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "enable_volume_protection" {
|
||||||
|
description = "Aktiviert Hetzner-Löschschutz für das persistente Datenvolume."
|
||||||
type = bool
|
type = bool
|
||||||
default = true
|
default = true
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7
-7
@@ -3841,15 +3841,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
@@ -4904,9 +4904,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/filelist/node_modules/brace-expansion": {
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
"setup:local-routing": "./scripts/setup-local-routing.sh",
|
"setup:local-routing": "./scripts/setup-local-routing.sh",
|
||||||
"sync:euris-locks": "node scripts/sync-euris-locks.mjs",
|
"sync:euris-locks": "node scripts/sync-euris-locks.mjs",
|
||||||
"test": "npm run build --workspace @watermaps/shared && npm run test --workspace @watermaps/shared && npm run test --workspace @watermaps/api && npm run test --workspace @watermaps/web && npm run test:local-routing && npm run test:deployment",
|
"test": "npm run build --workspace @watermaps/shared && npm run test --workspace @watermaps/shared && npm run test --workspace @watermaps/api && npm run test --workspace @watermaps/web && npm run test:local-routing && npm run test:deployment",
|
||||||
"test:deployment": "bash deploy/scripts/tests/image-references.test.sh && bash deploy/scripts/tests/route-data-helpers.test.sh && bash deploy/scripts/tests/update-route-data.test.sh",
|
"test:deployment": "bash deploy/scripts/tests/image-references.test.sh && bash deploy/scripts/tests/auto-deploy.test.sh && bash deploy/scripts/tests/route-data-helpers.test.sh && bash deploy/scripts/tests/update-route-data.test.sh",
|
||||||
"test:e2e": "npm run test:e2e --workspace @watermaps/web",
|
"test:e2e": "npm run test:e2e --workspace @watermaps/web",
|
||||||
"test:local-routing": "PYTHONPATH=.tools/python python3 -m unittest discover -s scripts/tests -p 'test_*.py'",
|
"test:local-routing": "PYTHONPATH=.tools/python python3 -m unittest discover -s scripts/tests -p 'test_*.py'",
|
||||||
"typecheck": "npm run build --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/api && npm run typecheck --workspace @watermaps/web"
|
"typecheck": "npm run build --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/shared && npm run typecheck --workspace @watermaps/api && npm run typecheck --workspace @watermaps/web"
|
||||||
|
|||||||
Reference in New Issue
Block a user