diff --git a/README.md b/README.md index 9be12a9..ff96806 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,55 @@ docker compose up -d --build Then open `http://:8091`. +## Deployment helper for published images + +For host deployments on this machine, use `deploy/deploy.sh` instead of editing +compose files by hand. The script is meant to be copied into other repos too: it +infers the repo name from `origin`, looks up the numeric Gitea repo id via the +API, computes the host port as `5000 + 10*repo_id + service_number`, copies the +repo's deployment assets, and writes a ready-to-run deployment directory. + +For Arcana FX the Gitea repo id is `4`, so the default deployment port is: + +```text +5000 + 10*4 + 0 = 5040 +``` + +Generate a deployment directory for a tagged image: + +```bash +./deploy/deploy.sh v0.1.0 +``` + +That creates `~/deployments/arcana-fx-0/` containing: + +- `docker-compose.yml` pinned to `192.168.33.22:3300/gemma/arcana-fx:v0.1.0` +- `.env` copied from `.env.example` on first run +- `config/` copied from the repo via `deploy/include-paths.txt` +- `.deploy.meta` with the resolved repo id, version, and host port + +Then edit the generated `.env` and start it: + +```bash +cd ~/deployments/arcana-fx-0 +$EDITOR .env +docker compose pull +docker compose up -d +``` + +A second service instance can use another service number, which shifts the port +by 1 while keeping the same repo-id block: + +```bash +./deploy/deploy.sh v0.1.0 1 +# host port becomes 5041 +``` + +When copying this deployment helper to another repo, update +`deploy/docker-compose.template.yml` for that app's internal port and mounts, +and `deploy/include-paths.txt` for any extra files/directories that must travel +with the generated compose file. + ## Container publishing pipeline A Gitea Actions workflow lives at `.gitea/workflows/release-container.yml`. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..81a9e30 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# deploy.sh — prepare a deployment directory for the current repository. +# +# Designed to be copied verbatim into other Gitea-hosted service repos on this +# machine. Repo-specific values (service name, image path, project id) are +# discovered from the `origin` remote and the Gitea API, so the only thing +# baked into the convention is the host-port formula: +# +# host_port = 5000 + 10 * + +# +# Usage: +# deploy/deploy.sh [service_number] +# +# Output: +# /-/docker-compose.yml +# /-/.deploy.meta +# /-/.env (copied from .env.example +# on first run if absent) +# +# Environment overrides: +# DEPLOY_ROOT base directory (default: $HOME/deployments) +# TEMPLATE path to compose template (default: +# /deploy/docker-compose.template.yml) +# GITEA_BASE_URL e.g. http://192.168.33.22:3300 +# (default: http://:3300) +# GITEA_API_BASE full API base (default: $GITEA_BASE_URL/api/v1) +# GITEA_TOKEN token for private repos (optional) +# GITEA_PROJECT_ID skip API discovery and use this id +# REGISTRY container registry host[:port] +# (default: :3300) +# IMAGE_REPO full image path host[:port]/owner/repo +# (default: $REGISTRY//) +# INCLUDE_PATHS_FILE newline-delimited repo-relative paths to copy into the +# deployment dir (default: /deploy/include-paths.txt) + +set -euo pipefail + +die() { printf 'deploy: %s\n' "$*" >&2; exit 1; } + +usage() { + sed -n '2,/^set -euo pipefail$/p' "$0" \ + | sed '$d' \ + | sed 's/^# \{0,1\}//' + exit 1 +} + +[ "$#" -ge 1 ] || usage +case "$1" in -h|--help|help) usage;; esac + +VERSION="$1" +SERVICE_NUMBER="${2:-0}" + +case "$SERVICE_NUMBER" in + ''|*[!0-9]*) die "service_number must be a non-negative integer: $SERVICE_NUMBER";; +esac +case "$VERSION" in + ''|*[[:space:]]*) die "version must be non-empty and contain no whitespace";; +esac + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)" +REPO_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" \ + || die "must be run from inside a git working tree" +TEMPLATE="${TEMPLATE:-$REPO_DIR/deploy/docker-compose.template.yml}" +DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/deployments}" +INCLUDE_PATHS_FILE="${INCLUDE_PATHS_FILE:-$REPO_DIR/deploy/include-paths.txt}" + +[ -r "$TEMPLATE" ] || die "template not found: $TEMPLATE" + +remote_url="$(git -C "$REPO_DIR" remote get-url origin)" \ + || die "no 'origin' remote configured" + +REMOTE_HOST=""; REMOTE_OWNER=""; REMOTE_REPO="" +parse_remote() { + local url="$1" rest host_part path_part + url="${url%.git}" + case "$url" in + ssh://*) + rest="${url#ssh://}" + rest="${rest#*@}" + host_part="${rest%%/*}" + path_part="${rest#*/}" + REMOTE_HOST="${host_part%%:*}" + ;; + http://*|https://*) + rest="${url#*://}" + host_part="${rest%%/*}" + path_part="${rest#*/}" + REMOTE_HOST="${host_part%%:*}" + ;; + *@*:*) + rest="${url#*@}" + REMOTE_HOST="${rest%%:*}" + path_part="${rest#*:}" + ;; + *) + die "cannot parse git remote URL: $url" + ;; + esac + REMOTE_OWNER="${path_part%/*}" + REMOTE_REPO="${path_part##*/}" + [ -n "$REMOTE_HOST" ] && [ -n "$REMOTE_OWNER" ] && [ -n "$REMOTE_REPO" ] \ + || die "could not extract host/owner/repo from: $url" +} +parse_remote "$remote_url" + +GITEA_BASE_URL="${GITEA_BASE_URL:-http://${REMOTE_HOST}:3300}" +GITEA_API_BASE="${GITEA_API_BASE:-$GITEA_BASE_URL/api/v1}" +REGISTRY="${REGISTRY:-${REMOTE_HOST}:3300}" +IMAGE_REPO="${IMAGE_REPO:-${REGISTRY}/${REMOTE_OWNER}/${REMOTE_REPO}}" + +if [ -z "${GITEA_PROJECT_ID:-}" ]; then + api_url="${GITEA_API_BASE}/repos/${REMOTE_OWNER}/${REMOTE_REPO}" + printf 'deploy: looking up project id at %s\n' "$api_url" >&2 + curl_args=(-fsS --max-time 10) + if [ -n "${GITEA_TOKEN:-}" ]; then + curl_args+=(-H "Authorization: token ${GITEA_TOKEN}") + fi + if ! resp="$(curl "${curl_args[@]}" "$api_url")"; then + die "Gitea API call failed: $api_url + set GITEA_PROJECT_ID= to bypass discovery, or + set GITEA_API_BASE / GITEA_TOKEN to override defaults." + fi + GITEA_PROJECT_ID="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" \ + || die "could not extract repo id from Gitea response" +fi + +case "$GITEA_PROJECT_ID" in + ''|*[!0-9]*) die "GITEA_PROJECT_ID is not a non-negative integer: $GITEA_PROJECT_ID";; +esac + +HOST_PORT=$(( 5000 + 10 * GITEA_PROJECT_ID + SERVICE_NUMBER )) + +deploy_dir="$DEPLOY_ROOT/${REMOTE_REPO}-${SERVICE_NUMBER}" +mkdir -p "$deploy_dir" + +out_file="$deploy_dir/docker-compose.yml" + +if [ -r "$INCLUDE_PATHS_FILE" ]; then + while IFS= read -r include_path || [ -n "$include_path" ]; do + include_path="${include_path%%#*}" + include_path="$(printf '%s' "$include_path" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + [ -n "$include_path" ] || continue + src_path="$REPO_DIR/$include_path" + dst_path="$deploy_dir/$include_path" + [ -e "$src_path" ] || die "include path listed but missing: $include_path" + mkdir -p "$(dirname "$dst_path")" + rm -rf "$dst_path" + cp -R "$src_path" "$dst_path" + done < "$INCLUDE_PATHS_FILE" +fi + +awk \ + -v image="$IMAGE_REPO" \ + -v tag="$VERSION" \ + -v host_port="$HOST_PORT" \ + -v service="$REMOTE_REPO" \ + -v service_num="$SERVICE_NUMBER" \ + -v project_id="$GITEA_PROJECT_ID" \ + '{ + gsub(/__IMAGE_TAG__/, tag); + gsub(/__IMAGE__/, image); + gsub(/__HOST_PORT__/, host_port); + gsub(/__SERVICE_NAME__/, service); + gsub(/__SERVICE_NUMBER__/, service_num); + gsub(/__GITEA_PROJECT_ID__/, project_id); + print; + }' "$TEMPLATE" > "$out_file" + +# Sanity check: the template authors might leave unresolved tokens by mistake. +if grep -q '__[A-Z_]\+__' "$out_file"; then + printf 'deploy: WARNING: unresolved __TOKEN__ placeholders remain in %s\n' \ + "$out_file" >&2 +fi + +env_example="$REPO_DIR/.env.example" +if [ ! -e "$deploy_dir/.env" ] && [ -r "$env_example" ]; then + cp "$env_example" "$deploy_dir/.env" + env_seeded=1 +else + env_seeded=0 +fi + +cat > "$deploy_dir/.deploy.meta" <