396 lines
20 KiB
YAML
396 lines
20 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: Provision or verify stack 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:
|
|
# First-deploy auto-provisioning. If $path/.git is missing we
|
|
# clone the stack repo into place using the runner's PAT, then
|
|
# fall through to the existing Sync step.
|
|
#
|
|
# Auth model: the PAT is passed as a one-shot
|
|
# `http.extraHeader` for the clone *only*. It is NOT written to
|
|
# `.git/config`, so subsequent `git fetch` calls in the Sync
|
|
# step use the host's existing git credentials (same as
|
|
# manually-cloned stacks). On a host with no sys-gitea-runner
|
|
# git credentials configured, Sync will fail loudly — surfacing
|
|
# the missing per-host identity instead of silently planting a
|
|
# long-lived PAT on disk. Tracked as roadmap.md → Identity &
|
|
# access → "Per-host git identity".
|
|
#
|
|
# Gitea Actions masks `${{ secrets.* }}` in workflow logs; the
|
|
# base64-encoded value is briefly visible in the target host's
|
|
# process list while git runs. Acceptable on the intranet-only
|
|
# homelab.
|
|
run: |
|
|
set -euo pipefail
|
|
path="${{ steps.resolve.outputs.path }}"
|
|
ref="${{ steps.resolve.outputs.ref }}"
|
|
repo="${{ github.repository }}"
|
|
# Avoid `exit 0` for the "already-cloned" branch: a known
|
|
# interaction between `bash -lc`, `set -e`, and a non-trivial
|
|
# /etc/bash.bash_logout (Debian ships one that exits non-zero
|
|
# if /usr/bin/clear_console exists) makes the SSH command
|
|
# return 1 even though we explicitly exit 0. Falling through
|
|
# naturally keeps the final command status clean.
|
|
if [ -d "$path/.git" ]; then
|
|
: # working tree already provisioned; nothing to do
|
|
else
|
|
if [ -e "$path" ]; then
|
|
# Refuse to clone over a non-git directory — almost
|
|
# certainly a hand-created working tree we shouldn't
|
|
# clobber. Operator decides: empty it manually, then re-run.
|
|
if [ -n "$(ls -A "$path" 2>/dev/null)" ]; then
|
|
echo "::error::stack path '$path' exists, is not a git working tree, and is non-empty — refusing to provision"
|
|
exit 1
|
|
fi
|
|
# Empty pre-existing dir: rmdir so `git clone` can create.
|
|
rmdir "$path"
|
|
fi
|
|
parent="$(dirname "$path")"
|
|
# /mnt/nas/stacks itself should already exist on every host
|
|
# per onboarding.md; this just ensures any deeper parent (rare)
|
|
# is in place. Owned by the deploy user so subsequent
|
|
# `git fetch/reset` don't need sudo.
|
|
#
|
|
# Skip sudo entirely when the parent is already there — sudo
|
|
# is non-interactive over the ssh multiplex socket and would
|
|
# otherwise fail on hosts where sys-gitea-runner has no
|
|
# passwordless sudo rule for `install` (i.e. all of them).
|
|
[ -d "$parent" ] || sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0755 "$parent"
|
|
echo "::notice::provisioning stack working tree at $path from $repo (ref: $ref)"
|
|
auth=$(printf '%s' 'sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}' | base64 -w0)
|
|
git -c "http.extraheader=Authorization: Basic ${auth}" \
|
|
clone --branch "$ref" \
|
|
"https://gitea.alexandru.macocian.me/${repo}.git" \
|
|
"$path"
|
|
fi
|
|
|
|
- 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 }}"
|
|
# Hygiene clean. Excludes:
|
|
# .env / secrets/*.plain* -> our own decrypted plaintext
|
|
# .nfs* -> NFS silly-rename files left when a
|
|
# container holds an inode that git
|
|
# reset just rewrote. Transient; they
|
|
# vanish when the container closes them.
|
|
# /mnt/nas/data -> never; that's runtime state, not the
|
|
# working tree (already separate path)
|
|
git clean -fd \
|
|
--exclude='.env' \
|
|
--exclude='secrets/*.plain*' \
|
|
--exclude='*.nfs*'
|
|
|
|
- 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"
|
|
# Pre-clear any leftover destination. Catches the case where
|
|
# a stack's first SOPS deploy lands on a host whose old
|
|
# hand-edited plaintext .env is still root-owned (the runner
|
|
# can't overwrite it, but it can unlink it because it owns
|
|
# the stack dir). On a non-empty repo where the runner is
|
|
# the legitimate writer of $dst, this is a no-op.
|
|
rm -f -- "$dst"
|
|
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: Registry login
|
|
# Fresh `docker login` on the target host immediately before pull.
|
|
# Avoids relying on creds cached in ~/.docker/config.json — those
|
|
# get wiped by build-image.yml's logout-on-cleanup, which was the
|
|
# source of the intermittent 401 on pull. Uses a dedicated
|
|
# read-only PAT (scope: `read:package`) for `sys-gitea-runner` so
|
|
# a host compromise can't push images.
|
|
#
|
|
# The PAT lives inlined below as SOPS ciphertext, encrypted to
|
|
# every host's age key (see /.sops.yaml). The target decrypts
|
|
# with its own `/etc/charlie/age.key`. Ciphertext is safe to
|
|
# commit; rotation = re-encrypt locally, repaste, push (see
|
|
# docs/secrets.md → "Registry credentials (read-only PAT)").
|
|
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:
|
|
run: |
|
|
set -euo pipefail
|
|
enc="/tmp/charlie-registry-creds.$$.sops.yaml"
|
|
umask 077
|
|
cat > "$enc" <<'SOPS_BLOB'
|
|
REGISTRY_USER: ENC[AES256_GCM,data:KlMpZ1TfgPNdbgUgNjEZ4g==,iv:RPhUcIca4dA3DgEXDC0YXTK/8jX2x7HFSZnQxQEbBeA=,tag:h7P3CMgQSBRWl72UEy0FfQ==,type:str]
|
|
REGISTRY_PAT: ENC[AES256_GCM,data:x7b7UcAWwVrTsRQeAvoWr6o4DyZKSTwSoRP59lhrHzxcSmocnz4uHQ==,iv:OxR2A7yN3Yn6SsgxgOukDVmdHhx/K3vHVF7XPk6qvwQ=,tag:1T2IJUdEDKzw9FH+zhIypA==,type:str]
|
|
sops:
|
|
age:
|
|
- enc: |
|
|
-----BEGIN AGE ENCRYPTED FILE-----
|
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzV1hqNnQvQ1lETmlnTFgw
|
|
NCtBeXBzeHZ3bllWeXVOVUJiWmhxTThzZG4wCjE2MS8wZ1pvM2Z0SWZRQWtkb0lF
|
|
Tm8zNWtZNEtIYU90UWpJWkxlbHYzOTgKLS0tIHg1bjExaXJ4VVIweVN2bEFlMndU
|
|
UkQvZzBueHY1RVUyVXdMWTJDSjFxSzQKfU+QNptiHxqAn/8YIoGRR2GeZ5iZCBDo
|
|
GQe3YDI2ifE6IQhoMFIeF4lylVxx5RkCAZjcbfoiSeZHhfhGwWYyXA==
|
|
-----END AGE ENCRYPTED FILE-----
|
|
recipient: age19d6mc8arznnwnvtp0xrphlseuwf94p0f3pznszrtmd5wgmjpzqdq9mltvs
|
|
- enc: |
|
|
-----BEGIN AGE ENCRYPTED FILE-----
|
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0bjlTa29Ic1crTTFJbmJw
|
|
bEpqQ0hOc3ZlT0VTTnJEOVdWRzIrWmdobzIwCmFKZzlpTHZCeC9ZQk5KbzA1V2ty
|
|
OGpmSFpDNys3c3YrbG5WRGZDUklpcTQKLS0tIDh1bU8xUGppMWdNNjNFUlFFTjg0
|
|
dVIzK1g4K05vM3VzNGtzZWJTMVFicWcKk6z2PUFpm/FhUC2EK6qO//t0g0Tjx+9z
|
|
CMzb9uP7UHSAUTVWv76jQp77D4raQK3s6/ihqezkUPoJ22pJKC+c9g==
|
|
-----END AGE ENCRYPTED FILE-----
|
|
recipient: age1m5ha3fd8psek5pym4spk7encjk4vmsml46eya3eymp7q9d9jre5s7xe84r
|
|
- enc: |
|
|
-----BEGIN AGE ENCRYPTED FILE-----
|
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUU3pEdTQrSTk3SU1MY3Uy
|
|
c05jWTZ1VG5YUWpHczhseVZXL3pBaXJERUVRCmxUMzMxZWZSSFVzVWYxNGExU3pF
|
|
VTVzVjZhRE1jUVBiTDhURG5jZE90ZlkKLS0tIC94ZzNEdnpsQWVqOFVtcXZjWWdj
|
|
Q1phNVpjeXVHSlFFYk5XUlAvVm9IRmcKgHeRc5Dj1sxXRFZ//RjoPCUHIsgA9YLx
|
|
EsNknYCR2ca+MEHulO2bNEvQx4T1DsXcS3NawEx3ABE9P481HG+/6A==
|
|
-----END AGE ENCRYPTED FILE-----
|
|
recipient: age1xfp878l4ul5zulqqul0u60c44c5wj94cmladgc6jt5jrlmrdrezsd8twh2
|
|
lastmodified: "2026-05-24T09:56:34Z"
|
|
mac: ENC[AES256_GCM,data:Y6XIHhVZtwggKw+/8+MR3SJPAfUv4egIfKiDrhMP143RfjZlwXZhfRzQVgZtNxRTynJjAUzqWyDdgqGPc0QdRjKRO8Wp3faMqJoP1zfD1VZwSWA95OG04mcrLpVb9BhBhg0NyINT8uwjuTcOax/6WEaSwBFurNzfJS9GA4YQrZ0=,iv:LVK6I2LA+IQ2ybs8U9tbcYQnBpGpZJ4yc0N5NLIZw2I=,tag:q2ZJ+/i7cABrJVtbiTK0Aw==,type:str]
|
|
unencrypted_suffix: _unencrypted
|
|
version: 3.13.1
|
|
SOPS_BLOB
|
|
export SOPS_AGE_KEY_FILE=/etc/charlie/age.key
|
|
# Decrypt to a shell-eval'able dotenv, then immediately scrub
|
|
# the ciphertext so it doesn't outlive the in-memory plaintext.
|
|
plain=$(sops --decrypt --output-type dotenv "$enc")
|
|
rm -f "$enc"
|
|
eval "$plain"
|
|
unset plain
|
|
printf '%s' "$REGISTRY_PAT" | docker login gitea.alexandru.macocian.me \
|
|
--username "$REGISTRY_USER" --password-stdin
|
|
unset REGISTRY_PAT REGISTRY_USER
|
|
|
|
- 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"
|
|
# --force-recreate: a deploy run is always an intentional
|
|
# "apply latest" — recreate containers even if compose thinks
|
|
# nothing changed (e.g. mutable tag pointing at a new digest,
|
|
# bind-mounted config rewritten in place, env-only churn that
|
|
# compose doesn't diff). Cheap, predictable, makes Redeploy
|
|
# actually mean Redeploy.
|
|
# shellcheck disable=SC2086
|
|
docker compose "$@" up -d --force-recreate ${{ inputs.compose-args }}
|
|
|
|
- name: Cleanup
|
|
# Unconditionally runs. Tolerates "never logged in", "never
|
|
# decrypted", "step failed before tmp files were written" — all
|
|
# the unlinks/logouts are best-effort. Never leak the registry
|
|
# PAT or decrypted plaintext on disk, regardless of where in the
|
|
# job we bailed.
|
|
if: ${{ always() }}
|
|
uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main
|
|
with:
|
|
# No workdir: cleanup operates on /tmp paths and the docker
|
|
# daemon — never the stack working tree. If we set workdir to
|
|
# the stack path and a very early step (e.g. provisioning)
|
|
# failed, charlie-ssh-target would `cd` into a non-existent
|
|
# directory and bail before the cleanup commands run, leaving
|
|
# PAT/plaintext on disk. Exactly what cleanup must prevent.
|
|
run: |
|
|
set -u
|
|
# 1. Drop registry creds first. Idempotent / namespaced per
|
|
# registry, so it's safe even if Registry login never ran.
|
|
docker logout gitea.alexandru.macocian.me >/dev/null 2>&1 || true
|
|
# 2. Scrub any leftover ciphertext if the Registry login step
|
|
# crashed between `cat > "$enc"` and `rm -f "$enc"`.
|
|
# Glob match is intentional — PID-suffixed, multiple
|
|
# runs may overlap.
|
|
rm -f /tmp/charlie-registry-creds.*.sops.yaml 2>/dev/null || true
|
|
# 3. Drop the per-run compose args scratch file.
|
|
rm -f /tmp/charlie-deploy-compose-args 2>/dev/null || true
|
|
# 4. Decrypted-plaintext scrub: only meaningful when the
|
|
# SOPS decrypt step actually ran. The list file's
|
|
# absence is the normal "no decrypt requested" path.
|
|
list=/tmp/charlie-deploy-cleanup.list
|
|
if [ -f "$list" ]; then
|
|
while IFS= read -r f; do
|
|
[ -z "$f" ] && continue
|
|
rm -f -- "$f" && echo "removed: $f"
|
|
done < "$list"
|
|
rm -f "$list"
|
|
fi
|
|
|
|
- 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
|