mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
ci: deploy demo.bulwarkmail.org from here instead of Watchtower
The demo container followed ghcr.io/bulwarkmail/webmail:latest through Watchtower, so a release reached it silently and there was no way to see, trigger or undo it. It is now a deployment of this repo: - .github/workflows/deploy-demo.yml runs after "Publish Docker Image on Release" succeeds, or on demand with a tag (`gh workflow run deploy-demo.yml -f tag=1.10.0`, or action=rollback). It only talks to the server; nothing is built. - deploy/demo-deploy.sh is the forced command behind the deploy key (/usr/local/bin/bulwark-demo-deploy): pulls the tag, recreates the container with the demo mounts, checks /api/health and goes back to the image it replaced if that fails. A pre-release moves no :latest, so the deploy after one finds the same image and does nothing. Watchtower has been recreated without bulwark-demo; it still updates the two rbm.systems containers.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
name: Deploy demo
|
||||
|
||||
# Keeps https://demo.bulwarkmail.org on the published release image. It used to
|
||||
# follow ghcr.io/bulwarkmail/webmail:latest through Watchtower; now the deploy
|
||||
# is a step of its own, so it shows up under Deployments, can be triggered by
|
||||
# hand and can be rolled back.
|
||||
#
|
||||
# Runs after "Publish Docker Image on Release" succeeds (the image has to exist
|
||||
# before the server can pull it), or on demand:
|
||||
#
|
||||
# gh workflow run deploy-demo.yml -f action=deploy -f tag=1.10.0
|
||||
# gh workflow run deploy-demo.yml -f action=rollback
|
||||
#
|
||||
# Nothing is built here. The server script /usr/local/bin/bulwark-demo-deploy
|
||||
# (deploy/demo-deploy.sh) pulls the tag, recreates the container, health-checks
|
||||
# it and rolls back on failure. A pre-release publishes no :latest, so the
|
||||
# deploy that follows it finds the same image and does nothing.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Publish Docker Image on Release"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: "What to do"
|
||||
type: choice
|
||||
options: [deploy, rollback]
|
||||
default: deploy
|
||||
tag:
|
||||
description: "Image tag to deploy"
|
||||
type: string
|
||||
default: latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-demo
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: 62.176.249.204
|
||||
DEPLOY_USER: ubuntu
|
||||
# ED25519 host key of mail.rath.li (SHA256:KJU3AEoj23/nBmHDF5w8o+wfRQcQTLdg3HHBrmlMdSQ).
|
||||
DEPLOY_HOST_KEY: "62.176.249.204 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINOvbgUpI8cbxGf9nLOZlaG+0xd/bkJosDpBTyMxuurC"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: ${{ inputs.action == 'rollback' && 'Roll back' || 'Deploy' }}
|
||||
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# A workflow_run has no inputs; it always deploys the release tag.
|
||||
ACTION: ${{ inputs.action || 'deploy' }}
|
||||
TAG: ${{ inputs.tag || 'latest' }}
|
||||
environment:
|
||||
name: demo
|
||||
url: https://demo.bulwarkmail.org
|
||||
steps:
|
||||
- name: Set up SSH
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key && chmod 600 ~/.ssh/deploy_key
|
||||
printf '%s\n' "$DEPLOY_HOST_KEY" > ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy
|
||||
if: env.ACTION == 'deploy'
|
||||
run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" "deploy $TAG"
|
||||
|
||||
- name: Roll back
|
||||
if: env.ACTION == 'rollback'
|
||||
run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" rollback
|
||||
|
||||
- name: Check the live demo
|
||||
run: |
|
||||
for i in $(seq 1 10); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' https://demo.bulwarkmail.org/api/health)
|
||||
[ "$code" = 200 ] && { echo "live: $code"; exit 0; }
|
||||
sleep 3
|
||||
done
|
||||
echo "the demo answered $code"; exit 1
|
||||
|
||||
- name: Show what is running
|
||||
if: always()
|
||||
run: ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" status
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploys the demo at https://demo.bulwarkmail.org on mail.rath.li.
|
||||
#
|
||||
# The demo is a single container off the published release image, so there is
|
||||
# nothing to build here: this pulls a tag from GHCR and recreates the
|
||||
# container. Installed as /usr/local/bin/bulwark-demo-deploy and run as the
|
||||
# `ubuntu` user through a forced command on the deploy key in
|
||||
# ~ubuntu/.ssh/authorized_keys:
|
||||
#
|
||||
# command="/usr/local/bin/bulwark-demo-deploy",restrict ssh-ed25519 AAAA... demo deploy
|
||||
#
|
||||
# so the key can do nothing but this. Re-install it by hand when this file
|
||||
# changes. The requested action comes in SSH_ORIGINAL_COMMAND:
|
||||
#
|
||||
# deploy [tag] pull ghcr.io/bulwarkmail/webmail:<tag> (default latest),
|
||||
# recreate the container, check it, roll back on failure
|
||||
# rollback go back to the image the last deploy replaced
|
||||
# status print the running image, its version and health
|
||||
#
|
||||
# Replaces Watchtower for this container: the webmail repo deploys here after
|
||||
# it publishes a release image (.github/workflows/deploy-demo.yml). The two
|
||||
# rbm.systems containers are still on Watchtower's watch list.
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE=ghcr.io/bulwarkmail/webmail
|
||||
NAME=bulwark-demo
|
||||
PORT=3002
|
||||
DATA=/opt/bulwark-demo-data
|
||||
# The image this deploy replaced, kept tagged so it survives image pruning.
|
||||
PREVIOUS=bulwark-demo:previous
|
||||
STATE=/opt/bulwark-demo-deploy
|
||||
|
||||
log() { echo "[demo-deploy] $*"; }
|
||||
|
||||
docker_() { sudo -n docker "$@"; }
|
||||
|
||||
image_id() { docker_ image inspect -f '{{.Id}}' "$1" 2>/dev/null || true; }
|
||||
|
||||
running_image_id() { docker_ inspect -f '{{.Image}}' "$NAME" 2>/dev/null || true; }
|
||||
|
||||
version_of() { docker_ image inspect -f '{{index .Config.Labels "org.opencontainers.image.version"}}' "$1" 2>/dev/null || true; }
|
||||
|
||||
# Recreate the container from $1. Mirrors the original `docker run`: demo mode,
|
||||
# host port 3002, and the three bind mounts that keep the demo past the setup
|
||||
# wizard (admin/config.json holds setupComplete).
|
||||
run_container() {
|
||||
docker_ rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
docker_ run -d \
|
||||
--name "$NAME" \
|
||||
--restart unless-stopped \
|
||||
-p "${PORT}:3000" \
|
||||
-e DEMO_MODE=true \
|
||||
-v "$DATA/admin:/app/data/admin" \
|
||||
-v "$DATA/settings:/app/data/settings" \
|
||||
-v "$DATA/telemetry:/app/data/telemetry" \
|
||||
"$1" >/dev/null
|
||||
}
|
||||
|
||||
healthy() {
|
||||
local i
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/api/health"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd=${SSH_ORIGINAL_COMMAND:-${1:-}}
|
||||
mkdir -p "$STATE"
|
||||
exec 9>"$STATE/.deploy.lock"
|
||||
flock -n 9 || { log "another deploy is running"; exit 1; }
|
||||
|
||||
case "$cmd" in
|
||||
deploy|deploy\ *)
|
||||
tag=${cmd#deploy}
|
||||
tag=${tag# }
|
||||
tag=${tag:-latest}
|
||||
[[ "$tag" =~ ^[A-Za-z0-9._-]{1,128}$ ]] || { log "bad tag: $tag"; exit 2; }
|
||||
|
||||
log "pulling $IMAGE:$tag"
|
||||
docker_ pull -q "$IMAGE:$tag" >/dev/null
|
||||
|
||||
new=$(image_id "$IMAGE:$tag")
|
||||
[[ -n "$new" ]] || { log "no such image after pull"; exit 3; }
|
||||
old=$(running_image_id)
|
||||
|
||||
if [[ "$new" == "$old" ]] && healthy; then
|
||||
log "already on $(version_of "$new") ($tag), nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -n "$old" ]] && docker_ tag "$old" "$PREVIOUS"
|
||||
run_container "$IMAGE:$tag"
|
||||
if healthy; then
|
||||
log "live: $(version_of "$new") ($tag)"
|
||||
else
|
||||
log "health check failed"
|
||||
if [[ -n "$old" ]]; then
|
||||
run_container "$PREVIOUS"
|
||||
healthy && log "back on $(version_of "$old")" || log "rollback is not answering either"
|
||||
fi
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# Only untagged leftovers; $PREVIOUS stays because it is tagged.
|
||||
docker_ image prune -f >/dev/null || true
|
||||
;;
|
||||
|
||||
rollback)
|
||||
prev=$(image_id "$PREVIOUS")
|
||||
[[ -n "$prev" ]] || { log "no earlier image to roll back to"; exit 5; }
|
||||
cur=$(running_image_id)
|
||||
run_container "$PREVIOUS"
|
||||
# Point $PREVIOUS at what we just left, so a second rollback comes back.
|
||||
[[ -n "$cur" ]] && docker_ tag "$cur" "$PREVIOUS"
|
||||
healthy && log "rolled back to $(version_of "$prev")" || { log "rolled back, but the health check failed"; exit 4; }
|
||||
;;
|
||||
|
||||
status)
|
||||
cur=$(running_image_id)
|
||||
if [[ -n "$cur" ]]; then
|
||||
echo "running: $(version_of "$cur") ($(docker_ inspect -f '{{.Config.Image}}' "$NAME")) ${cur:7:12}"
|
||||
echo "state: $(docker_ inspect -f '{{.State.Status}}' "$NAME")"
|
||||
else
|
||||
echo "running: none"
|
||||
fi
|
||||
prev=$(image_id "$PREVIOUS")
|
||||
[[ -n "$prev" ]] && echo "previous: $(version_of "$prev") ${prev:7:12}" || echo "previous: none"
|
||||
curl -fsS -o /dev/null -w "health: %{http_code}\n" "http://127.0.0.1:${PORT}/api/health" || true
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "usage: deploy [tag] | rollback | status" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user