diff --git a/at/pds/MAINTENANCE.md b/at/pds/MAINTENANCE.md --- a/at/pds/MAINTENANCE.md +++ b/at/pds/MAINTENANCE.md @@ -16,6 +16,11 @@ ```bash 0 2 * * * /root/backup.sh >> /var/log/pds-backup.log 2>&1 ``` +### AT Frontend Sync (Every minute) +```bash +* * * * * /root/auto-sync-frontend.sh >> /var/log/at-frontend-sync.log 2>&1 +``` + ## Weekly Tasks ### Review Logs @@ -31,6 +36,9 @@ tail -100 /var/log/pds-health.log # Check backup logs tail -50 /var/log/pds-backup.log + +# Check frontend sync logs +tail -100 /var/log/at-frontend-sync.log ``` ### Check Storage Usage diff --git a/at/pds/scripts/README.md b/at/pds/scripts/README.md --- a/at/pds/scripts/README.md +++ b/at/pds/scripts/README.md @@ -28,6 +28,56 @@ ```bash fish generate-pds-env.fish [output-file] ``` +#### `scripts/auto-sync-frontend.sh` +Polls GitHub and auto-deploys configured frontend files into the PDS Caddy container. + +```bash +# Local smoke test (from repo root) +AC_FORCE=1 at/pds/scripts/auto-sync-frontend.sh +``` + +**Default file map:** +- `at/index.html -> /data/www/index.html` +- `at/user-page.html -> /data/www/user.html` + +**Configurable via env:** +- `AC_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/landing-page.html:landing-page.html"` + +**Typical server install (cron every minute):** +```bash +# 1) SSH to server +ssh -i ~/.ssh/aesthetic_pds root@ + +# 2) Install dependencies (jq is required for commit diff checks) +apt update && apt install -y curl jq git + +# 3) Copy script to server +scp -i ~/.ssh/aesthetic_pds /workspaces/aesthetic-computer/at/pds/scripts/auto-sync-frontend.sh root@:/root/auto-sync-frontend.sh +ssh -i ~/.ssh/aesthetic_pds root@ 'chmod +x /root/auto-sync-frontend.sh' + +# 4) Add cron job +ssh -i ~/.ssh/aesthetic_pds root@ '(crontab -l 2>/dev/null; echo "* * * * * /root/auto-sync-frontend.sh >> /var/log/at-frontend-sync.log 2>&1") | crontab -' +``` + +**How it behaves:** +- Tracks latest deployed commit in `/var/lib/at-frontend-sync/last_deployed_sha` +- Compares changed files between last deployed SHA and latest main SHA +- Skips deploy if none of the mapped frontend files changed +- Uses `docker cp` into `caddy:/data/www/*` +- Runs health check against `https://at.aesthetic.computer/xrpc/_health` + +#### `at/scripts/deploy-at-frontend.sh` +Manual one-shot deploy helper from local machine to PDS host. + +```bash +# From repo root (uses defaults + ~/.ssh/aesthetic_pds) +at/scripts/deploy-at-frontend.sh + +# Optional custom map +AT_FRONTEND_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html;at/landing-page.html:landing-page.html" \ + at/scripts/deploy-at-frontend.sh +``` + ### Monitoring #### `scripts/health-check.sh` diff --git a/at/pds/scripts/auto-sync-frontend.sh b/at/pds/scripts/auto-sync-frontend.sh new file mode 100644 --- /dev/null +++ b/at/pds/scripts/auto-sync-frontend.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Auto-sync AT frontend pages from GitHub to the PDS Caddy container. +# Intended for cron/systemd on the PDS server. +# +# Env overrides: +# AC_REPO="whistlegraph/aesthetic-computer" +# AC_BRANCH="main" +# AC_CONTAINER="caddy" +# AC_CONTAINER_WEBROOT="/data/www" +# AC_STATE_DIR="/var/lib/at-frontend-sync" +# AC_HEALTH_URL="https://at.aesthetic.computer/xrpc/_health" +# AC_FILE_MAP="at/index.html:index.html;at/user-page.html:user.html" +# AC_FORCE="1" # force deploy even if SHA unchanged +# +# AC_FILE_MAP format: +# "repo/source/path:container/target/path;repo/source2:path2" + +AC_REPO="${AC_REPO:-whistlegraph/aesthetic-computer}" +AC_BRANCH="${AC_BRANCH:-main}" +AC_CONTAINER="${AC_CONTAINER:-caddy}" +AC_CONTAINER_WEBROOT="${AC_CONTAINER_WEBROOT:-/data/www}" +AC_STATE_DIR="${AC_STATE_DIR:-/var/lib/at-frontend-sync}" +AC_HEALTH_URL="${AC_HEALTH_URL:-https://at.aesthetic.computer/xrpc/_health}" +AC_FILE_MAP="${AC_FILE_MAP:-at/index.html:index.html;at/user-page.html:user.html}" +AC_FORCE="${AC_FORCE:-0}" + +RAW_BASE="https://raw.githubusercontent.com/${AC_REPO}" +REPO_URL="https://github.com/${AC_REPO}.git" +STATE_FILE="${AC_STATE_DIR}/last_deployed_sha" +LOCK_FILE="${AC_STATE_DIR}/sync.lock" +TMP_DIR="$(mktemp -d /tmp/at-frontend-sync.XXXXXX)" +COMPARE_JSON="${TMP_DIR}/compare.json" + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf "%s" "${value}" +} + +declare -a SOURCE_FILES=() +declare -a TARGET_FILES=() +IFS=';' read -r -a FILE_MAP_ENTRIES <<< "${AC_FILE_MAP}" +for raw_entry in "${FILE_MAP_ENTRIES[@]}"; do + entry="$(trim "${raw_entry}")" + [[ -z "${entry}" ]] && continue + + if [[ "${entry}" != *:* ]]; then + echo "Invalid AC_FILE_MAP entry: '${entry}' (expected source:target)" >&2 + exit 1 + fi + + source_path="$(trim "${entry%%:*}")" + target_path="$(trim "${entry#*:}")" + + if [[ -z "${source_path}" || -z "${target_path}" ]]; then + echo "Invalid AC_FILE_MAP entry: '${entry}' (empty source or target)" >&2 + exit 1 + fi + + SOURCE_FILES+=("${source_path}") + TARGET_FILES+=("${target_path}") +done + +if [[ "${#SOURCE_FILES[@]}" -eq 0 ]]; then + echo "No frontend files configured. Set AC_FILE_MAP." >&2 + exit 1 +fi + +cleanup() { + rm -rf "${TMP_DIR}" || true +} +trap cleanup EXIT + +mkdir -p "${AC_STATE_DIR}" + +exec 9>"${LOCK_FILE}" +if ! flock -n 9; then + echo "Auto-sync already running; exiting." + exit 0 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required but not found." >&2 + exit 1 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required but not found." >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required but not found." >&2 + exit 1 +fi + +if ! docker ps --format '{{.Names}}' | grep -Fxq "${AC_CONTAINER}"; then + echo "Container '${AC_CONTAINER}' is not running." >&2 + exit 1 +fi + +echo "Checking latest commit for ${AC_REPO}@${AC_BRANCH}..." +LATEST_SHA="$(git ls-remote "${REPO_URL}" "refs/heads/${AC_BRANCH}" | awk '{print $1}')" +if [[ -z "${LATEST_SHA}" ]]; then + echo "Could not resolve latest SHA for ${AC_REPO}@${AC_BRANCH}" >&2 + exit 1 +fi + +LAST_SHA="" +if [[ -f "${STATE_FILE}" ]]; then + LAST_SHA="$(cat "${STATE_FILE}")" +fi + +if [[ "${AC_FORCE}" != "1" && "${LATEST_SHA}" == "${LAST_SHA}" ]]; then + echo "No new commit (${LATEST_SHA:0:12}); frontend is up to date." + exit 0 +fi + +if [[ "${AC_FORCE}" != "1" && -n "${LAST_SHA}" ]]; then + COMPARE_URL="https://api.github.com/repos/${AC_REPO}/compare/${LAST_SHA}...${LATEST_SHA}" + if curl -fsSL "${COMPARE_URL}" -o "${COMPARE_JSON}"; then + SHOULD_DEPLOY=0 + for source_path in "${SOURCE_FILES[@]}"; do + if jq -e --arg source_path "${source_path}" '.files[]? | select(.filename == $source_path)' "${COMPARE_JSON}" >/dev/null; then + SHOULD_DEPLOY=1 + break + fi + done + + if [[ "${SHOULD_DEPLOY}" == "0" ]]; then + echo "No tracked frontend files changed; skipping deploy for ${LATEST_SHA:0:12}." + echo "${LATEST_SHA}" > "${STATE_FILE}" + exit 0 + fi + else + echo "Could not compare commit diff. Continuing with deploy." + fi +fi + +echo "Deploying frontend for commit ${LATEST_SHA:0:12}..." + +declare -a TMP_FILES=() +for i in "${!SOURCE_FILES[@]}"; do + source_path="${SOURCE_FILES[$i]}" + target_path="${TARGET_FILES[$i]}" + source_url="${RAW_BASE}/${LATEST_SHA}/${source_path}" + tmp_file="${TMP_DIR}/${i}-$(basename "${target_path}")" + + curl -fsSL "${source_url}" -o "${tmp_file}" + if [[ "${source_path}" == *.html ]] && ! grep -qi "" "${tmp_file}"; then + echo "Downloaded ${source_path} does not look like HTML." >&2 + exit 1 + fi + + TMP_FILES+=("${tmp_file}") +done + +for i in "${!TARGET_FILES[@]}"; do + target_path="${TARGET_FILES[$i]}" + tmp_file="${TMP_FILES[$i]}" + target_dir="$(dirname "${target_path}")" + if [[ "${target_dir}" != "." ]]; then + docker exec "${AC_CONTAINER}" mkdir -p "${AC_CONTAINER_WEBROOT}/${target_dir}" + fi + docker cp "${tmp_file}" "${AC_CONTAINER}:${AC_CONTAINER_WEBROOT}/${target_path}" +done + +echo "${LATEST_SHA}" > "${STATE_FILE}" + +if [[ -n "${AC_HEALTH_URL}" ]]; then + echo "Running health check..." + curl -fsSL "${AC_HEALTH_URL}" >/dev/null +fi + +echo "AT frontend sync complete (${LATEST_SHA:0:12})." diff --git a/at/scripts/deploy-at-frontend.sh b/at/scripts/deploy-at-frontend.sh new file mode 100644 --- /dev/null +++ b/at/scripts/deploy-at-frontend.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy AT frontend pages to PDS caddy container. +# Intended for both local use and CI (GitHub Actions). +# +# Required env (or defaults): +# AT_PDS_HOST (default: 165.227.120.137) +# AT_PDS_USER (default: root) +# AT_PDS_SSH_KEY_PATH (default: ~/.ssh/aesthetic_pds) +# AT_PDS_CONTAINER (default: caddy) +# AT_PDS_CONTAINER_WEBROOT (default: /data/www) +# AT_FRONTEND_FILE_MAP (default: at/index.html:index.html;at/user-page.html:user.html) +# +# AT_FRONTEND_FILE_MAP format: +# "repo/source/path:container/target/path;repo/source2:path2" + +AT_PDS_HOST="${AT_PDS_HOST:-165.227.120.137}" +AT_PDS_USER="${AT_PDS_USER:-root}" +AT_PDS_SSH_KEY_PATH="${AT_PDS_SSH_KEY_PATH:-$HOME/.ssh/aesthetic_pds}" +AT_PDS_CONTAINER="${AT_PDS_CONTAINER:-caddy}" +AT_PDS_CONTAINER_WEBROOT="${AT_PDS_CONTAINER_WEBROOT:-/data/www}" +AT_FRONTEND_FILE_MAP="${AT_FRONTEND_FILE_MAP:-at/index.html:index.html;at/user-page.html:user.html}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf "%s" "${value}" +} + +declare -a SOURCE_FILES=() +declare -a TARGET_FILES=() +IFS=';' read -r -a FILE_MAP_ENTRIES <<< "$AT_FRONTEND_FILE_MAP" +for raw_entry in "${FILE_MAP_ENTRIES[@]}"; do + entry="$(trim "$raw_entry")" + [[ -z "$entry" ]] && continue + + if [[ "$entry" != *:* ]]; then + echo "Invalid AT_FRONTEND_FILE_MAP entry: '$entry' (expected source:target)" >&2 + exit 1 + fi + + source_path="$(trim "${entry%%:*}")" + target_path="$(trim "${entry#*:}")" + local_source="$REPO_ROOT/$source_path" + + if [[ -z "$source_path" || -z "$target_path" ]]; then + echo "Invalid AT_FRONTEND_FILE_MAP entry: '$entry' (empty source or target)" >&2 + exit 1 + fi + + if [[ ! -f "$local_source" ]]; then + echo "Missing source file: $local_source" >&2 + exit 1 + fi + + SOURCE_FILES+=("$source_path") + TARGET_FILES+=("$target_path") +done + +if [[ "${#SOURCE_FILES[@]}" -eq 0 ]]; then + echo "No frontend files configured. Set AT_FRONTEND_FILE_MAP." >&2 + exit 1 +fi + +if [[ ! -f "$AT_PDS_SSH_KEY_PATH" ]]; then + echo "Missing SSH key: $AT_PDS_SSH_KEY_PATH" >&2 + exit 1 +fi + +SSH_TARGET="$AT_PDS_USER@$AT_PDS_HOST" +SSH_OPTS=( + -i "$AT_PDS_SSH_KEY_PATH" + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null +) + +STAMP="${GITHUB_SHA:-$(date +%Y%m%d%H%M%S)}" +REMOTE_DIR="/tmp/at-frontend-${STAMP}" + +echo "Deploying AT frontend to ${SSH_TARGET}" +echo "Uploading staging files..." +ssh "${SSH_OPTS[@]}" "$SSH_TARGET" "mkdir -p '$REMOTE_DIR'" + +REMOTE_COPY_STEPS=() +for i in "${!SOURCE_FILES[@]}"; do + source_path="${SOURCE_FILES[$i]}" + target_path="${TARGET_FILES[$i]}" + local_source="$REPO_ROOT/$source_path" + remote_stage="$REMOTE_DIR/${i}-$(basename "$target_path")" + target_dir="$(dirname "$target_path")" + + scp "${SSH_OPTS[@]}" "$local_source" "${SSH_TARGET}:${remote_stage}" + if [[ "$target_dir" != "." ]]; then + REMOTE_COPY_STEPS+=("docker exec '${AT_PDS_CONTAINER}' mkdir -p '${AT_PDS_CONTAINER_WEBROOT}/$target_dir'") + fi + REMOTE_COPY_STEPS+=("docker cp '$remote_stage' '${AT_PDS_CONTAINER}:${AT_PDS_CONTAINER_WEBROOT}/$target_path'") +done + +copy_command="$(IFS=' && '; echo "${REMOTE_COPY_STEPS[*]}")" +ssh "${SSH_OPTS[@]}" "$SSH_TARGET" "$copy_command && rm -rf '$REMOTE_DIR'" + +echo "Deployment complete." +echo "Landing: https://at.aesthetic.computer" +echo "User page: https://jeffrey.at.aesthetic.computer"