225 lines
9.2 KiB
YAML
225 lines
9.2 KiB
YAML
name: Deploy stack
|
|
|
|
# Reusable workflow. Replaces the old per-repo `deploy.sh`.
|
|
#
|
|
# This version uses the SSH-into-host model: the runner is stateless and
|
|
# every operation happens on the target host as `sys-gitea-runner` (LDAP,
|
|
# member of charlie-deploy) over a short-lived step-ca-issued SSH user
|
|
# cert. Sidesteps the Gitea reusable-workflow label-routing bug
|
|
# (https://github.com/go-gitea/gitea/issues/32348).
|
|
#
|
|
# Caller pattern (from a stack repo):
|
|
#
|
|
# on:
|
|
# push:
|
|
# branches: [main]
|
|
# jobs:
|
|
# deploy:
|
|
# uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/deploy-stack.yml@main
|
|
# with:
|
|
# host: morgott
|
|
# decrypt: |
|
|
# secrets/env.sops.yaml -> .env
|
|
# secrets: inherit
|
|
#
|
|
# Per-host compose / env resolution mirrors docs/strategy.md ("Shared stacks"):
|
|
# 1. If `docker-compose.<host>.yml` exists -> use only that.
|
|
# 2. Else `docker-compose.yml` plus (if present) `overrides/<host>.yml`.
|
|
# 3. If `envs/<host>.env` exists -> loaded with `--env-file`.
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
host:
|
|
description: Hostname this deploy targets. The runner SSHes in here.
|
|
required: true
|
|
type: string
|
|
stack-path:
|
|
description: |
|
|
Absolute path on the host where the stack's working tree lives.
|
|
Defaults to `/mnt/nas/stacks/<repo-name>`.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
decrypt:
|
|
description: |
|
|
Multi-line `src -> dst` SOPS decrypt list. Empty for stacks without
|
|
secrets. Decryption happens on the target host using its
|
|
`/etc/charlie/age.key`.
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
compose-args:
|
|
description: Extra args appended to `docker compose ... up -d` (advanced).
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
pull:
|
|
description: Run `docker compose ... pull` before `up -d`.
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
ref:
|
|
description: Git ref to check out into the stack working tree (default = the branch this workflow ran on).
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
secrets:
|
|
RUNNER_REPO_PAT:
|
|
description: |
|
|
PAT used in the absolute-URL form of `uses:` to clone composite actions
|
|
from `Charlie/project-charlie`. Set as a Charlie org-level secret;
|
|
callers pass via `secrets: inherit` (or explicitly).
|
|
required: true
|
|
|
|
jobs:
|
|
deploy:
|
|
# `self-hosted` is the only label requirement now. Routing to the right
|
|
# host is done by SSH inside the job, not by Gitea's runner picker.
|
|
# Sidesteps Gitea's reusable-workflow `runs-on` matching bug.
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Resolve stack path and ref
|
|
id: resolve
|
|
shell: bash
|
|
env:
|
|
STACK_PATH_INPUT: ${{ inputs.stack-path }}
|
|
REF_INPUT: ${{ inputs.ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
path="$STACK_PATH_INPUT"
|
|
if [ -z "$path" ]; then
|
|
path="/mnt/nas/stacks/${GITHUB_REPOSITORY##*/}"
|
|
fi
|
|
ref="$REF_INPUT"
|
|
if [ -z "$ref" ]; then
|
|
ref="${GITHUB_REF_NAME:-main}"
|
|
fi
|
|
echo "path=$path" >> "$GITHUB_OUTPUT"
|
|
echo "ref=$ref" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Open SSH session
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-open@main
|
|
with:
|
|
host: ${{ inputs.host }}
|
|
|
|
- name: Verify stack working tree exists
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
run: |
|
|
test -d "${{ steps.resolve.outputs.path }}/.git" || {
|
|
echo "::error::stack path '${{ steps.resolve.outputs.path }}' is not a git working tree"
|
|
exit 1
|
|
}
|
|
|
|
- name: Sync working tree
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
workdir: ${{ steps.resolve.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch --prune origin
|
|
git reset --hard "origin/${{ steps.resolve.outputs.ref }}"
|
|
# Keep decrypted .env-style files out of the way; never touch /mnt/nas/data.
|
|
git clean -fd --exclude='.env' --exclude='secrets/*.plain*'
|
|
|
|
- name: Decrypt SOPS secrets
|
|
if: ${{ inputs.decrypt != '' }}
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
workdir: ${{ steps.resolve.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
export SOPS_AGE_KEY_FILE=/etc/charlie/age.key
|
|
cleanup_list=$(mktemp)
|
|
while IFS= read -r raw; do
|
|
line="${raw%%#*}"
|
|
line="$(printf '%s' "$line" | awk '{$1=$1;print}')"
|
|
[ -z "$line" ] && continue
|
|
case "$line" in
|
|
*"->"*) ;;
|
|
*) echo "::error::invalid decrypt entry (missing '->'): $raw"; exit 1 ;;
|
|
esac
|
|
src="$(printf '%s' "${line%%->*}" | awk '{$1=$1;print}')"
|
|
dst="$(printf '%s' "${line#*->}" | awk '{$1=$1;print}')"
|
|
[ -z "$src" ] || [ -z "$dst" ] && {
|
|
echo "::error::invalid decrypt entry: $raw"; exit 1; }
|
|
[ -f "$src" ] || { echo "::error::source not found: $src"; exit 1; }
|
|
case "$dst" in
|
|
*.env|.env) out_format="dotenv" ;;
|
|
*.json) out_format="json" ;;
|
|
*.yaml|*.yml) out_format="yaml" ;;
|
|
*.ini) out_format="ini" ;;
|
|
*) out_format="binary" ;;
|
|
esac
|
|
dst_dir="$(dirname -- "$dst")"
|
|
[ "$dst_dir" = "." ] || mkdir -p "$dst_dir"
|
|
umask 077
|
|
sops --decrypt --output-type "$out_format" --output "$dst" "$src"
|
|
chmod 600 "$dst"
|
|
printf '%s\n' "$dst" >> "$cleanup_list"
|
|
echo "decrypted: $src -> $dst"
|
|
done <<< "${{ inputs.decrypt }}"
|
|
mv "$cleanup_list" /tmp/charlie-deploy-cleanup.list
|
|
|
|
- name: Resolve compose invocation
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
workdir: ${{ steps.resolve.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
host="$(printf '%s' "${{ inputs.host }}" | tr '[:upper:]' '[:lower:]')"
|
|
files=()
|
|
if [ -f "docker-compose.$host.yml" ]; then
|
|
files+=(-f "docker-compose.$host.yml")
|
|
else
|
|
files+=(-f "docker-compose.yml")
|
|
[ -f "overrides/$host.yml" ] && files+=(-f "overrides/$host.yml")
|
|
fi
|
|
envfile=()
|
|
[ -f "envs/$host.env" ] && envfile+=(--env-file "envs/$host.env")
|
|
args=$(printf '%q ' "${envfile[@]}" "${files[@]}")
|
|
printf '%s\n' "$args" > /tmp/charlie-deploy-compose-args
|
|
echo "compose args: $args"
|
|
|
|
- name: Pull images
|
|
if: ${{ inputs.pull }}
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
workdir: ${{ steps.resolve.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
args=$(cat /tmp/charlie-deploy-compose-args)
|
|
eval "set -- $args"
|
|
docker compose "$@" pull
|
|
|
|
- name: Up
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
workdir: ${{ steps.resolve.outputs.path }}
|
|
run: |
|
|
set -euo pipefail
|
|
args=$(cat /tmp/charlie-deploy-compose-args)
|
|
eval "set -- $args"
|
|
# shellcheck disable=SC2086
|
|
docker compose "$@" up -d ${{ inputs.compose-args }}
|
|
|
|
- name: Cleanup decrypted plaintext
|
|
if: ${{ always() && inputs.decrypt != '' }}
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
workdir: ${{ steps.resolve.outputs.path }}
|
|
run: |
|
|
set -u
|
|
list=/tmp/charlie-deploy-cleanup.list
|
|
[ -f "$list" ] || exit 0
|
|
while IFS= read -r f; do
|
|
[ -z "$f" ] && continue
|
|
rm -f -- "$f" && echo "removed: $f"
|
|
done < "$list"
|
|
rm -f "$list" /tmp/charlie-deploy-compose-args
|
|
|
|
- name: Close SSH session
|
|
if: always()
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-close@main
|