diff --git a/README.md b/README.md deleted file mode 100644 index 4c74947..0000000 --- a/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# openclaw-deploy-scripts - -Deployment and backup scripts for services on this host. - -## Highlights - -- Docker and local deployment helpers under `/opt/deploy` -- Shared Traefik helpers in `common.sh` -- Local OpenClaw deployment now supports automatic Traefik wiring -- Companion how-to docs live under `how-to/` - -## Docs - -- `how-to/traefik-openclaw.md` -- `how-to/mattermost-openclaw.md` -- `how-to/gitea-openclaw-bootstrap.md` - -## Notes - -These scripts are evolving toward unattended rebuilds. diff --git a/backup-agenticseek.sh b/backup-agenticseek.sh new file mode 100755 index 0000000..9e33607 --- /dev/null +++ b/backup-agenticseek.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# ============================================================================== +# AgenticSeek Backup Script +# ============================================================================== +# Usage: backup-agenticseek.sh +# backup-agenticseek.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="agenticseek" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "AgenticSeek is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Cold backup: stop containers --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping containers)..." + (cd "$BASE_DIR" && docker compose down) +fi + +# --- Create archive --- +info "Creating archive..." +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" \ + "opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \ +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + ok "Containers restarted." +fi + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-all.sh b/backup-all.sh new file mode 100755 index 0000000..ac644b6 --- /dev/null +++ b/backup-all.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# ============================================================================== +# Full Server Backup Script +# ============================================================================== +# Discovers deployed services by scanning /opt/*/docker-compose.yml and +# local service units, runs the appropriate backup script for each, then +# creates a combined full-server archive. +# +# Usage: backup-all.sh +# backup-all.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +COLD_FLAG="" +[[ "$ARG_COLD" == "1" ]] && COLD_FLAG="--cold" + +mkdir -p "$BACKUP_DIR" + +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Full Server Backup${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +if [[ -n "$COLD_FLAG" ]]; then + echo -e " Mode: ${YELLOW}Cold (services will be stopped)${NC}" +else + echo -e " Mode: Hot (services stay running)" +fi +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +failed=0 + +# --- Traefik --- +if [[ -d /opt/traefik ]]; then + info "Backing up traefik..." + "${SCRIPT_DIR}/backup-traefik.sh" $COLD_FLAG || { warn "Traefik backup failed."; (( failed++ )); } +fi + +# --- Mattermost --- +if [[ -f /opt/mattermost/docker-compose.yml ]]; then + info "Backing up mattermost..." + "${SCRIPT_DIR}/backup-mattermost.sh" $COLD_FLAG || { warn "Mattermost backup failed."; (( failed++ )); } +fi + +# --- Gitea --- +if [[ -f /opt/gitea/docker-compose.yml ]]; then + info "Backing up gitea..." + "${SCRIPT_DIR}/backup-gitea.sh" $COLD_FLAG || { warn "Gitea backup failed."; (( failed++ )); } +fi + +# --- Hermes --- +if [[ -f /opt/hermes/docker-compose.yml ]] || [[ -f /etc/systemd/system/hermes.service ]]; then + info "Backing up hermes..." + "${SCRIPT_DIR}/backup-hermes.sh" $COLD_FLAG || { warn "Hermes backup failed."; (( failed++ )); } +fi + +# --- Paperclip --- +if [[ -d /opt/paperclip ]]; then + info "Backing up paperclip..." + "${SCRIPT_DIR}/backup-paperclip.sh" $COLD_FLAG || { warn "Paperclip backup failed."; (( failed++ )); } +fi + +# --- OpenClaw --- +if [[ -d /opt/openclaw ]]; then + info "Backing up openclaw..." + "${SCRIPT_DIR}/backup-openclaw.sh" $COLD_FLAG || { warn "OpenClaw backup failed."; (( failed++ )); } +fi + +# --- Open WebUI --- +if [[ -f /opt/open-webui/docker-compose.yml ]]; then + info "Backing up open-webui..." + "${SCRIPT_DIR}/backup-open-webui.sh" $COLD_FLAG || { warn "Open WebUI backup failed."; (( failed++ )); } +fi + +# --- AgenticSeek --- +if [[ -f /opt/agenticseek/docker-compose.yml ]]; then + info "Backing up agenticseek..." + "${SCRIPT_DIR}/backup-agenticseek.sh" $COLD_FLAG || { warn "AgenticSeek backup failed."; (( failed++ )); } +fi + +# --- Web instances (discover by /opt/*/docker-compose.yml with web- container) --- +for dir in /opt/*/; do + dir_name="$(basename "$dir")" + compose="${dir}docker-compose.yml" + if [[ -f "$compose" ]] && grep -q "web-${dir_name}" "$compose" 2>/dev/null; then + info "Backing up web instance: ${dir_name}..." + "${SCRIPT_DIR}/backup-web.sh" --dir "$dir_name" $COLD_FLAG || { warn "Web (${dir_name}) backup failed."; (( failed++ )); } + fi +done + +# --- Combined full-server archive --- +FULL_BACKUP="${BACKUP_DIR}/backup-all-${STAMP}.tar.gz" +info "Creating combined full-server archive..." +tar czf "$FULL_BACKUP" -C / opt/ 2>/dev/null || warn "Full archive creation failed." + +# --- Rotate full archives (keep last 3) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-all-"*.tar.gz 2>/dev/null | tail -n +4 | xargs -r rm -- + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Backup complete${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +if [[ -f "$FULL_BACKUP" ]]; then + echo -e " Full: ${FULL_BACKUP} ($(du -h "$FULL_BACKUP" | cut -f1))" +fi +echo -e " Dir: ${BACKUP_DIR}/" +if (( failed > 0 )); then + echo -e " ${YELLOW}Warnings: ${failed} backup(s) had issues${NC}" +fi +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/backup-gitea.sh b/backup-gitea.sh new file mode 100755 index 0000000..eba840e --- /dev/null +++ b/backup-gitea.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# ============================================================================== +# Gitea Backup Script +# ============================================================================== +# Creates a backup archive of Gitea + PostgreSQL data and Traefik config. +# +# Hot backup (default): runs gitea dump + pg_dump while containers stay up. +# Cold backup (--cold): stops containers, tars data, restarts. +# +# Usage: backup-gitea.sh +# backup-gitea.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="gitea" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Gitea is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Hot backup: application-level dumps --- +if [[ "$ARG_COLD" != "1" ]]; then + info "Running hot backup (containers stay up)..." + + # Gitea built-in dump + info "Running gitea dump..." + mkdir -p "${BASE_DIR}/gitea/data/backup" + docker exec -u git gitea bash -c \ + '/app/gitea/gitea dump -c /data/gitea/conf/app.ini --type tar.gz -f /data/backup/gitea-dump.tar.gz' \ + 2>/dev/null || warn "Gitea dump failed — archive will contain live data files only." + + # PostgreSQL dump + info "Running pg_dump..." + docker exec gitea-postgres pg_dump -U gitea -d gitea \ + > "${BASE_DIR}/postgres/dump.sql" \ + 2>/dev/null || warn "pg_dump failed — archive will contain live data files only." +fi + +# --- Cold backup: stop containers --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping containers)..." + (cd "$BASE_DIR" && docker compose down) +fi + +# --- Create archive --- +info "Creating archive..." +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" \ + "opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \ +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + ok "Containers restarted." +fi + +# --- Clean up dump files --- +rm -f "${BASE_DIR}/gitea/data/backup/gitea-dump.tar.gz" 2>/dev/null +rm -f "${BASE_DIR}/postgres/dump.sql" 2>/dev/null + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-hermes.sh b/backup-hermes.sh new file mode 100755 index 0000000..d3d701b --- /dev/null +++ b/backup-hermes.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# ============================================================================== +# Hermes Agent Backup Script +# ============================================================================== +# Works for both local and Docker deployments. +# +# Usage: backup-hermes.sh +# backup-hermes.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="hermes" +BASE_DIR="/opt/${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Hermes is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Cold backup: stop service --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping service)..." + if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then + (cd "$BASE_DIR" && docker compose down) + elif systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then + systemctl stop "${SERVICE_NAME}.service" + fi +fi + +# --- Create archive (no traefik config for hermes) --- +info "Creating archive..." +tar czf "$BACKUP_FILE" -C / "opt/${SERVICE_NAME}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + elif [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then + systemctl start "${SERVICE_NAME}.service" + fi + ok "Service restarted." +fi + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-mattermost.sh b/backup-mattermost.sh new file mode 100755 index 0000000..fddee3f --- /dev/null +++ b/backup-mattermost.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# ============================================================================== +# Mattermost Backup Script +# ============================================================================== +# Hot backup (default): pg_dump while containers stay up. +# Cold backup (--cold): stops containers, tars data, restarts. +# +# Usage: backup-mattermost.sh +# backup-mattermost.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="mattermost" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Mattermost is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Hot backup: pg_dump --- +if [[ "$ARG_COLD" != "1" ]]; then + info "Running hot backup (containers stay up)..." + + info "Running pg_dump..." + docker exec mm-postgres pg_dump -U mmuser -d mattermost \ + > "${BASE_DIR}/postgres/dump.sql" \ + 2>/dev/null || warn "pg_dump failed — archive will contain live data files only." +fi + +# --- Cold backup: stop containers --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping containers)..." + (cd "$BASE_DIR" && docker compose down) +fi + +# --- Create archive --- +info "Creating archive..." +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" \ + "opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \ +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + ok "Containers restarted." +fi + +# --- Clean up dump --- +rm -f "${BASE_DIR}/postgres/dump.sql" 2>/dev/null + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-open-webui.sh b/backup-open-webui.sh new file mode 100755 index 0000000..f87f9ed --- /dev/null +++ b/backup-open-webui.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# ============================================================================== +# Open WebUI Backup Script +# ============================================================================== +# Creates a backup archive of Open WebUI data and Traefik config. +# +# Open WebUI uses SQLite internally. Hot backup copies the data directory +# while running (safe for most cases). Use --cold for guaranteed consistency. +# +# Usage: backup-open-webui.sh +# backup-open-webui.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="open-webui" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Open WebUI is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Hot backup: attempt SQLite .backup if sqlite3 is available --- +if [[ "$ARG_COLD" != "1" ]]; then + info "Running hot backup (containers stay up)..." + local_db="${BASE_DIR}/data/webui.db" + if [[ -f "$local_db" ]] && command -v sqlite3 &>/dev/null; then + info "Creating SQLite snapshot..." + sqlite3 "$local_db" ".backup '${BASE_DIR}/data/webui-backup.db'" 2>/dev/null || \ + warn "SQLite .backup failed — archive will contain live database file." + fi +fi + +# --- Cold backup: stop containers --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping containers)..." + (cd "$BASE_DIR" && docker compose down) +fi + +# --- Create archive --- +info "Creating archive..." +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" \ + "opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \ +tar czf "$BACKUP_FILE" -C / \ + "opt/${SERVICE_NAME}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + ok "Containers restarted." +fi + +# --- Clean up snapshot --- +rm -f "${BASE_DIR}/data/webui-backup.db" 2>/dev/null + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-openclaw.sh b/backup-openclaw.sh new file mode 100755 index 0000000..8e51a26 --- /dev/null +++ b/backup-openclaw.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# ============================================================================== +# OpenClaw Backup Script +# ============================================================================== +# Works for both local and Docker deployments. +# +# Usage: backup-openclaw.sh +# backup-openclaw.sh --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="openclaw" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "OpenClaw is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Cold backup: stop service --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping service)..." + if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then + (cd "$BASE_DIR" && docker compose down) + elif systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then + systemctl stop "${SERVICE_NAME}.service" + fi +fi + +# --- Create archive --- +info "Creating archive..." +tar_paths=("opt/${SERVICE_NAME}") +if [[ -f "${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" ]]; then + tar_paths+=("opt/traefik/dynamic/${CONFIG_NAME}.yml") +fi +tar czf "$BACKUP_FILE" -C / "${tar_paths[@]}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + elif [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then + systemctl start "${SERVICE_NAME}.service" + fi + ok "Service restarted." +fi + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-paperclip.sh b/backup-paperclip.sh new file mode 100755 index 0000000..ddb9930 --- /dev/null +++ b/backup-paperclip.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# ============================================================================== +# Paperclip Backup Script +# ============================================================================== +# Creates a backup archive of Paperclip data (works for both local and Docker). +# +# Usage: backup-paperclip.sh +# backup-paperclip.sh --cold # Stop service before backup +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="paperclip" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Paperclip is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Cold backup: stop service --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping service)..." + # Handle both Docker and local deployments + if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then + (cd "$BASE_DIR" && docker compose down) + elif systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then + systemctl stop "${SERVICE_NAME}.service" + fi +fi + +# --- Create archive --- +info "Creating archive..." +tar_paths=("opt/${SERVICE_NAME}") + +# Include traefik dynamic config if it exists (Docker variant or manual setup) +if [[ -f "${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" ]]; then + tar_paths+=("opt/traefik/dynamic/${CONFIG_NAME}.yml") +fi + +tar czf "$BACKUP_FILE" -C / "${tar_paths[@]}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + if [[ -f "${BASE_DIR}/docker-compose.yml" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + elif [[ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then + systemctl start "${SERVICE_NAME}.service" + fi + ok "Service restarted." +fi + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-traefik.sh b/backup-traefik.sh new file mode 100755 index 0000000..04b4849 --- /dev/null +++ b/backup-traefik.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# ============================================================================== +# Traefik Backup Script +# ============================================================================== +# Archives /opt/traefik/ (acme.json certs + all dynamic configs). +# Always hot — Traefik doesn't need to be stopped for file copy. +# +# Usage: backup-traefik.sh +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +SERVICE_NAME="traefik" +BASE_DIR="/opt/${SERVICE_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${SERVICE_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Traefik is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +info "Creating archive (hot — Traefik stays running)..." +tar czf "$BACKUP_FILE" -C / "opt/${SERVICE_NAME}" + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${SERVICE_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/backup-web.sh b/backup-web.sh new file mode 100755 index 0000000..e610b4e --- /dev/null +++ b/backup-web.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# ============================================================================== +# Web Instance Backup Script +# ============================================================================== +# Requires --dir to identify which instance to back up. +# No pre-backup commands — just static files. +# +# Usage: backup-web.sh --dir acme +# backup-web.sh --dir acme --cold +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" +parse_args "$@" + +[[ -n "$ARG_DIR" ]] || fatal "--dir is required. Example: $0 --dir acme" + +DIR_NAME="$ARG_DIR" +BASE_DIR="/opt/${DIR_NAME}" +CONFIG_NAME="web-${DIR_NAME}" +BACKUP_DIR="/opt/backup" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="${BACKUP_DIR}/backup-${DIR_NAME}-${STAMP}.tar.gz" + +[[ -d "$BASE_DIR" ]] || fatal "Web instance '${DIR_NAME}' is not deployed at ${BASE_DIR}." + +mkdir -p "$BACKUP_DIR" + +# --- Cold backup: stop container --- +if [[ "$ARG_COLD" == "1" ]]; then + info "Running cold backup (stopping container)..." + (cd "$BASE_DIR" && docker compose down) +fi + +# --- Create archive --- +info "Creating archive..." +tar czf "$BACKUP_FILE" -C / \ + "opt/${DIR_NAME}" \ + "opt/traefik/dynamic/${CONFIG_NAME}.yml" 2>/dev/null || \ +tar czf "$BACKUP_FILE" -C / \ + "opt/${DIR_NAME}" + +# --- Cold: restart --- +if [[ "$ARG_COLD" == "1" ]]; then + (cd "$BASE_DIR" && docker compose up -d) + ok "Container restarted." +fi + +# --- Rotate old backups (keep last 5) --- +# shellcheck disable=SC2012 +ls -t "${BACKUP_DIR}/backup-${DIR_NAME}-"*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -- + +ok "Backup: ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))" diff --git a/common.sh b/common.sh new file mode 100644 index 0000000..27f3245 --- /dev/null +++ b/common.sh @@ -0,0 +1,514 @@ +# common.sh — Shared functions sourced by all deploy and backup scripts +# This file should NOT be executed directly. +# +# Usage: source "${SCRIPT_DIR}/common.sh" + +# ──────────────────────────── VARIABLES ─────────────────────────────────────── + +ACME_EMAIL="${ACME_EMAIL:-admin@an2.io}" +TRAEFIK_DIR="/opt/traefik" +TRAEFIK_DYNAMIC_DIR="/opt/traefik/dynamic" +DOCKER_NETWORK="traefik-public" + +# ──────────────────────────── COLOURS / LOGGING ─────────────────────────────── + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[ OK ]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +fatal() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } + +# ──────────────────────────── ARGUMENT PARSING ──────────────────────────────── + +parse_args() { + ARG_DOMAIN="" + ARG_REMOVE="0" + ARG_PURGE="0" + ARG_YES="0" + ARG_COLD="0" + ARG_DIR="" + ARG_EXTRA=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --domain) + [[ $# -ge 2 ]] || fatal "--domain requires a value" + ARG_DOMAIN="$2"; shift 2 ;; + --dir) + [[ $# -ge 2 ]] || fatal "--dir requires a value" + ARG_DIR="$2"; shift 2 ;; + --remove) ARG_REMOVE="1"; shift ;; + --purge) ARG_PURGE="1"; shift ;; + --yes) ARG_YES="1"; shift ;; + --cold) ARG_COLD="1"; shift ;; + *) ARG_EXTRA+=("$1"); shift ;; + esac + done +} + +# ──────────────────────────── REQUIRE ROOT ──────────────────────────────────── + +require_root() { + [[ $EUID -eq 0 ]] || fatal "This script must be run as root (or via sudo)." +} + +# ──────────────────────────── OS DETECTION ──────────────────────────────────── + +detect_os() { + if [[ -f /etc/os-release ]]; then + # shellcheck source=/dev/null + source /etc/os-release + OS_ID="${ID,,}" + OS_VERSION="${VERSION_ID:-}" + OS_NAME="${PRETTY_NAME}" + else + fatal "Cannot detect OS — /etc/os-release not found." + fi + + case "$OS_ID" in + fedora|rhel|centos|rocky|alma|amzn) + DISTRO_FAMILY="fedora" ;; + debian|ubuntu|pop|linuxmint|raspbian|kali) + DISTRO_FAMILY="debian" ;; + *) + fatal "Unsupported distro: $OS_ID ($OS_NAME). Supported: Fedora/RHEL family, Debian/Ubuntu family." ;; + esac + ok "Detected OS: $OS_NAME (family: $DISTRO_FAMILY)" +} + +# ──────────────────────────── INSTALL PREREQUISITES ─────────────────────────── + +install_prerequisites() { + case "$DISTRO_FAMILY" in + debian) + info "Installing prerequisites (Debian/Ubuntu)..." + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq \ + apt-transport-https ca-certificates curl gnupg lsb-release \ + openssl jq net-tools > /dev/null 2>&1 + + if ! command -v docker &>/dev/null; then + info "Installing Docker Engine..." + install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | \ + gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null + chmod a+r /etc/apt/keyrings/docker.gpg + local codename + codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-$(lsb_release -cs)}")" + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://download.docker.com/linux/${OS_ID} ${codename} stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1 + fi + ok "Prerequisites installed (Debian/Ubuntu)." + ;; + fedora) + info "Installing prerequisites (Fedora/RHEL)..." + dnf install -y -q \ + ca-certificates curl gnupg2 openssl jq net-tools > /dev/null 2>&1 + + if ! command -v docker &>/dev/null; then + info "Installing Docker Engine..." + dnf -y install dnf-plugins-core > /dev/null 2>&1 + dnf config-manager --add-repo \ + https://download.docker.com/linux/fedora/docker-ce.repo 2>/dev/null || \ + dnf config-manager --add-repo \ + https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null + dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1 + fi + ok "Prerequisites installed (Fedora/RHEL)." + ;; + esac + + systemctl enable --now docker > /dev/null 2>&1 + ok "Docker $(docker --version | awk '{print $3}') is running." + + if ! docker compose version &>/dev/null; then + fatal "docker compose v2 plugin not found. Please install docker-compose-plugin." + fi + ok "Docker Compose $(docker compose version --short) available." +} + +# ──────────────────────────── DOCKER NETWORK ────────────────────────────────── + +ensure_docker_network() { + docker network inspect "$DOCKER_NETWORK" &>/dev/null || \ + docker network create "$DOCKER_NETWORK" + ok "Docker network '$DOCKER_NETWORK' ready." +} + +# ──────────────────────────── TRAEFIK ───────────────────────────────────────── +# Deploys shared Traefik at /opt/traefik/ if not already present. +# Uses file provider (not Docker socket) — Docker Engine 29+ has API +# negotiation bugs with Traefik's Go client, and the docs warn about +# Docker socket security. + +ensure_traefik() { + # Already running — nothing to do + if [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + if docker ps --format '{{.Names}}' | grep -q '^traefik$'; then + ok "Traefik is already running." + return 0 + fi + info "Traefik compose exists but not running. Starting..." + (cd "$TRAEFIK_DIR" && docker compose up -d) + ok "Traefik started." + return 0 + fi + + # Full deploy + info "Deploying Traefik (file provider, Let's Encrypt)..." + + # Check port 443 + if ss -tlnp 2>/dev/null | grep -q ':443 '; then + local proc + proc="$(ss -tlnp 2>/dev/null | grep ':443 ')" + if ! echo "$proc" | grep -q 'traefik'; then + echo "$proc" | sed 's/^/ /' + fatal "Port 443 is in use by another process. Free it before deploying Traefik." + fi + fi + + # Warn if port 80 is busy (LE HTTP challenge needs it) + if ss -tlnp 2>/dev/null | grep -q ':80 '; then + warn "Port 80 is in use. Let's Encrypt HTTP challenge requires port 80." + fi + + mkdir -p "$TRAEFIK_DIR" "$TRAEFIK_DYNAMIC_DIR" + + touch "${TRAEFIK_DIR}/acme.json" + chmod 600 "${TRAEFIK_DIR}/acme.json" + + cat > "${TRAEFIK_DIR}/docker-compose.yml" </dev/null && ufw status 2>/dev/null | grep -q "active"; then + ufw allow 80/tcp > /dev/null 2>&1 + ufw allow 443/tcp > /dev/null 2>&1 + for port in "${extra_ports[@]}"; do + ufw allow "$port" > /dev/null 2>&1 + done + ok "UFW rules added (80, 443${extra_ports:+, ${extra_ports[*]}})." + elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then + firewall-cmd --permanent --add-service=http > /dev/null 2>&1 + firewall-cmd --permanent --add-service=https > /dev/null 2>&1 + for port in "${extra_ports[@]}"; do + firewall-cmd --permanent --add-port="$port" > /dev/null 2>&1 + done + firewall-cmd --reload > /dev/null 2>&1 + ok "Firewalld rules added (http, https${extra_ports:+, ${extra_ports[*]}})." + else + warn "No active firewall detected. Ensure ports 80, 443${extra_ports:+, ${extra_ports[*]}} are open." + fi +} + +# ──────────────────────────── TRAEFIK DYNAMIC CONFIG ────────────────────────── + +write_traefik_dynamic_config() { + local config_name="$1" + local domain="$2" + local backend_url="$3" + + info "Writing Traefik dynamic config: ${config_name}.yml" + + cat > "${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml" </dev/null | head -1)" || true + if [[ -n "$latest" ]]; then + backup_file="$latest" + fi + fi + + if [[ -z "$backup_file" ]]; then + return 1 + fi + + local size + size="$(du -h "$backup_file" | cut -f1)" + info "Found backup: $backup_file ($size)" + + if [[ "$ARG_YES" != "1" ]]; then + read -rp "Restore from this backup? [y/N] " confirm + if [[ "${confirm,,}" != "y" ]]; then + info "Skipping backup restore." + return 1 + fi + fi + + info "Restoring backup..." + tar xf "$backup_file" -C / + ok "Backup restored from $backup_file" + return 0 +} + +# ──────────────────────────── HEALTH CHECK ──────────────────────────────────── + +wait_for_healthy() { + local container_name="$1" + local health_url="$2" + local timeout="${3:-120}" + + info "Waiting for ${container_name} to become healthy (up to ${timeout}s)..." + local elapsed=0 + while (( elapsed < timeout )); do + # Try curl first, fall back to wget (Alpine images lack curl) + if docker exec "$container_name" curl -sf "$health_url" > /dev/null 2>&1 || \ + docker exec "$container_name" wget -q --spider "$health_url" 2>/dev/null; then + ok "${container_name} is healthy." + return 0 + fi + sleep 5 + (( elapsed += 5 )) + done + warn "${container_name} did not become healthy within ${timeout}s. Check: docker logs ${container_name}" + return 1 +} + +# ──────────────────────────── PASSWORD GENERATION ───────────────────────────── + +generate_password() { + openssl rand -base64 32 | tr -d '/+=' | head -c 32 +} + +# ──────────────────────────── SYSTEMD UNITS ─────────────────────────────────── + +install_systemd_unit() { + local unit_name="$1" + local working_directory="$2" + + cat > "/etc/systemd/system/${unit_name}.service" < /dev/null 2>&1 + ok "Systemd unit installed: ${unit_name}.service" +} + +remove_systemd_unit() { + local unit_name="$1" + local unit_file="/etc/systemd/system/${unit_name}.service" + + if [[ -f "$unit_file" ]]; then + systemctl stop "${unit_name}.service" > /dev/null 2>&1 || true + systemctl disable "${unit_name}.service" > /dev/null 2>&1 || true + rm -f "$unit_file" + systemctl daemon-reload + ok "Removed systemd unit: ${unit_name}.service" + fi +} + +# ──────────────────────────── REMOVAL ───────────────────────────────────────── + +do_remove() { + local service_name="$1" + local config_name="$2" + local unit_name="$3" + local base_dir="$4" + + local backup_script="${SCRIPT_DIR}/backup-${service_name}.sh" + + echo "" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + echo -e "${YELLOW} Removing ${service_name}${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + if [[ "$ARG_PURGE" == "1" ]]; then + echo -e " Mode: ${RED}Purge (delete all data)${NC}" + else + echo -e " Mode: Safe (keep data)" + fi + echo -e " Data: ${base_dir}" + [[ -n "$config_name" ]] && \ + echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml" + echo -e " Systemd: ${unit_name}.service" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + echo "" + + # Suggest backup before purge + if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$base_dir" ]]; then + if [[ -f "$backup_script" ]]; then + warn "Back up first? ${backup_script}" + fi + echo "" + fi + + # Stop containers + if [[ -d "$base_dir" ]] && [[ -f "${base_dir}/docker-compose.yml" ]]; then + (cd "$base_dir" && docker compose down) || true + ok "Containers stopped." + fi + + # Remove traefik dynamic config + [[ -n "$config_name" ]] && remove_traefik_dynamic_config "$config_name" + + # Remove systemd unit + remove_systemd_unit "$unit_name" + + # Purge data if requested + if [[ "$ARG_PURGE" == "1" ]]; then + echo "" + warn "This will permanently delete: $base_dir" + if [[ "$ARG_YES" != "1" ]]; then + read -rp "Are you sure? [y/N] " confirm + if [[ "${confirm,,}" != "y" ]]; then + info "Purge cancelled." + return 0 + fi + fi + rm -rf "$base_dir" + ok "Purged: $base_dir" + fi + + # Summary + echo "" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo -e "${GREEN} ${service_name} removed${NC}" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + if [[ "$ARG_PURGE" != "1" ]]; then + echo -e " Data preserved at ${base_dir}" + echo -e " To purge: $0 --remove --purge" + fi + echo -e " To redeploy: $0" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo "" + return 0 +} diff --git a/deploy b/deploy new file mode 100755 index 0000000..cd2a849 --- /dev/null +++ b/deploy @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# ============================================================================== +# Unified Deploy Wrapper +# ============================================================================== +# Auto-discovers deploy-{docker,local}-*.sh scripts in the same directory and +# dispatches to them with a shorter CLI interface. +# +# Usage: +# deploy.sh # List available services +# deploy.sh mattermost # Deploy via docker (default) +# deploy.sh -l paperclip # Deploy via local (bare-metal) +# deploy.sh --local hermes --domain h.an2.io # Local deploy with flags +# deploy.sh -r mattermost # --remove +# deploy.sh -r -p mattermost # --remove --purge +# deploy.sh -r -p -y mattermost # --remove --purge --yes +# deploy.sh web --domain w.an2.io --dir acme # Pass-through flags +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Colours ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m' +BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m' + +# ── Discover available deploy scripts ──────────────────────────────────────── +# Builds two associative arrays: +# DOCKER_SCRIPTS[service] = script_path +# LOCAL_SCRIPTS[service] = script_path +# And a sorted list of unique service names. + +declare -A DOCKER_SCRIPTS=() +declare -A LOCAL_SCRIPTS=() +declare -a ALL_SERVICES=() + +discover_scripts() { + local script basename method service + for script in "${SCRIPT_DIR}"/deploy-docker-*.sh "${SCRIPT_DIR}"/deploy-local-*.sh; do + [[ -f "$script" ]] || continue + basename="$(basename "$script" .sh)" # deploy-docker-hermes + method="${basename#deploy-}" # docker-hermes + method="${method%%-*}" # docker or local + service="${basename#deploy-${method}-}" # hermes + + case "$method" in + docker) DOCKER_SCRIPTS["$service"]="$script" ;; + local) LOCAL_SCRIPTS["$service"]="$script" ;; + esac + done + + # Build sorted unique service list + local -A seen=() + for s in "${!DOCKER_SCRIPTS[@]}" "${!LOCAL_SCRIPTS[@]}"; do + seen["$s"]=1 + done + IFS=$'\n' read -r -d '' -a ALL_SERVICES < <(printf '%s\n' "${!seen[@]}" | sort && printf '\0') || true +} + +# ── Usage / list ───────────────────────────────────────────────────────────── + +print_usage() { + echo "" + echo -e "${CYAN}══════════════════════════════════════════════${NC}" + echo -e "${CYAN} Deploy — Unified Wrapper${NC}" + echo -e "${CYAN}══════════════════════════════════════════════${NC}" + echo "" + echo -e " ${BOLD}Usage:${NC} deploy.sh [flags] " + echo "" + echo -e " ${BOLD}Flags:${NC}" + echo -e " ${GREEN}--local, -l${NC} Use bare-metal (local) deploy instead of Docker" + echo -e " ${GREEN}--remove, -r${NC} Remove the service (keep data)" + echo -e " ${GREEN}--purge, -p${NC} Delete all data (requires --remove)" + echo -e " ${GREEN}--yes, -y${NC} Skip confirmations" + echo -e " ${GREEN}--domain ${NC} Set the service domain" + echo -e " ${GREEN}--dir ${NC} Instance directory (for web)" + echo -e " ${GREEN}--cold${NC} Cold backup mode (passed through)" + echo "" + echo -e " ${BOLD}Available services:${NC}" + echo "" + + # Table header + printf " ${DIM}%-16s %-8s %-8s${NC}\n" "SERVICE" "DOCKER" "LOCAL" + printf " ${DIM}%-16s %-8s %-8s${NC}\n" "───────────────" "────────" "────────" + + for service in "${ALL_SERVICES[@]}"; do + local d_mark l_mark + if [[ -n "${DOCKER_SCRIPTS[$service]+x}" ]]; then + d_mark="${GREEN} ✓${NC}" + else + d_mark="${DIM} —${NC}" + fi + if [[ -n "${LOCAL_SCRIPTS[$service]+x}" ]]; then + l_mark="${GREEN} ✓${NC}" + else + l_mark="${DIM} —${NC}" + fi + printf " %-16s %b %b\n" "$service" "$d_mark" "$l_mark" + done + + echo "" + echo -e " ${BOLD}Examples:${NC}" + echo -e " ${DIM}deploy.sh mattermost${NC} # Docker deploy" + echo -e " ${DIM}deploy.sh -l paperclip --domain clip.an2.io${NC} # Local deploy" + echo -e " ${DIM}deploy.sh -r -p gitea${NC} # Remove + purge" + echo -e " ${DIM}deploy.sh web --domain w.an2.io --dir acme${NC} # Multi-instance" + echo "" +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +discover_scripts + +# Parse wrapper flags — separate them from pass-through flags +USE_LOCAL=0 +SERVICE="" +PASSTHROUGH=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --local|-l) + USE_LOCAL=1 + shift + ;; + --remove|-r) + PASSTHROUGH+=("--remove") + shift + ;; + --purge|-p) + PASSTHROUGH+=("--purge") + shift + ;; + --yes|-y) + PASSTHROUGH+=("--yes") + shift + ;; + --domain) + PASSTHROUGH+=("--domain" "$2") + shift 2 + ;; + --dir) + PASSTHROUGH+=("--dir" "$2") + shift 2 + ;; + --cold) + PASSTHROUGH+=("--cold") + shift + ;; + -*) + # Unknown flag — pass through + PASSTHROUGH+=("$1") + shift + ;; + *) + if [[ -z "$SERVICE" ]]; then + SERVICE="$1" + else + # Extra positional — pass through + PASSTHROUGH+=("$1") + fi + shift + ;; + esac +done + +# No service specified — show usage +if [[ -z "$SERVICE" ]]; then + print_usage + exit 0 +fi + +# Resolve script +TARGET_SCRIPT="" +if (( USE_LOCAL )); then + if [[ -n "${LOCAL_SCRIPTS[$SERVICE]+x}" ]]; then + TARGET_SCRIPT="${LOCAL_SCRIPTS[$SERVICE]}" + else + echo -e "${RED}[FAIL]${NC} No local deploy script for '${SERVICE}'." + if [[ -n "${DOCKER_SCRIPTS[$SERVICE]+x}" ]]; then + echo -e " A Docker variant exists — run without ${GREEN}-l${NC}." + fi + exit 1 + fi +else + if [[ -n "${DOCKER_SCRIPTS[$SERVICE]+x}" ]]; then + TARGET_SCRIPT="${DOCKER_SCRIPTS[$SERVICE]}" + elif [[ -n "${LOCAL_SCRIPTS[$SERVICE]+x}" ]]; then + echo -e "${YELLOW}[WARN]${NC} No Docker variant for '${SERVICE}' — using local." + TARGET_SCRIPT="${LOCAL_SCRIPTS[$SERVICE]}" + else + echo -e "${RED}[FAIL]${NC} Unknown service '${SERVICE}'." + echo "" + echo -e " Available: ${ALL_SERVICES[*]}" + exit 1 + fi +fi + +# Dispatch +exec bash "$TARGET_SCRIPT" "${PASSTHROUGH[@]}" diff --git a/deploy-docker-agenticseek.sh b/deploy-docker-agenticseek.sh new file mode 100755 index 0000000..2538d85 --- /dev/null +++ b/deploy-docker-agenticseek.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# ============================================================================== +# AgenticSeek Deployment Script (Docker Compose + Traefik) +# ============================================================================== +# Clones and deploys AgenticSeek (multi-container) behind Traefik. +# Source: https://github.com/Fosowl/agenticSeek +# +# Usage: deploy-docker-agenticseek.sh +# deploy-docker-agenticseek.sh --domain agent.an2.io +# deploy-docker-agenticseek.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="agenticseek" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}-docker" +AGENTICSEEK_REPO="${AGENTICSEEK_REPO:-https://github.com/Fosowl/agenticSeek.git}" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Domain resolution --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="seek.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying AgenticSeek (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +# AgenticSeek uses repo compose — check for frontend container +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker compose -f "${BASE_DIR}/docker-compose.yml" ps --format '{{.State}}' 2>/dev/null | grep -q 'running'; then + echo -e "${GREEN} AgenticSeek is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: cd ${BASE_DIR} && docker compose logs -f" + echo -e " Backup: ${SCRIPT_DIR}/backup-agenticseek.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install / update --- + + # Clone or update repo + if [[ -d "${BASE_DIR}/.git" ]]; then + info "Updating AgenticSeek source..." + (cd "$BASE_DIR" && git pull) + else + info "Cloning AgenticSeek from ${AGENTICSEEK_REPO}..." + git clone "$AGENTICSEEK_REPO" "$BASE_DIR" + fi + + # Set up .env from example if fresh + if [[ ! -f "${BASE_DIR}/.env" ]] && [[ -f "${BASE_DIR}/.env.example" ]]; then + cp "${BASE_DIR}/.env.example" "${BASE_DIR}/.env" + fi + + # Ensure our DOMAIN is in .env + if grep -q '^DOMAIN=' "${BASE_DIR}/.env" 2>/dev/null; then + sed -i "s|^DOMAIN=.*|DOMAIN=${DOMAIN}|" "${BASE_DIR}/.env" + else + echo "DOMAIN=${DOMAIN}" >> "${BASE_DIR}/.env" + fi + chmod 600 "${BASE_DIR}/.env" + + mkdir -p "${BASE_DIR}/data" + + # Create override to add traefik-public network to frontend + cat > "${BASE_DIR}/docker-compose.override.yml" <<'OVERRIDE' +services: + frontend: + networks: + - default + - traefik-public + +networks: + traefik-public: + external: true +OVERRIDE + ok "docker-compose.override.yml written (traefik-public network)." +fi + +# --- Always do these (fresh or restored) --- +write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://frontend:3000" + +# AgenticSeek uses repo compose + our override — no custom systemd unit for compose +# since the compose file is the repo's own, install unit pointing to BASE_DIR +install_systemd_unit "$UNIT_NAME" "$BASE_DIR" +(cd "$BASE_DIR" && docker compose up -d --build) + +info "Waiting for AgenticSeek frontend (up to 120s)..." +elapsed=0 +while (( elapsed < 120 )); do + if curl -sf "http://localhost:3000" > /dev/null 2>&1; then + ok "AgenticSeek frontend is responding." + break + fi + sleep 5 + (( elapsed += 5 )) +done +if (( elapsed >= 120 )); then + warn "Frontend did not respond within 120s. Check: cd ${BASE_DIR} && docker compose logs" +fi + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} AgenticSeek deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " URL: https://${DOMAIN}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: cd ${BASE_DIR} && docker compose logs -f" +echo "" +echo -e " ${YELLOW}Configure API keys in ${BASE_DIR}/.env${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-gitea.sh b/deploy-docker-gitea.sh new file mode 100755 index 0000000..b9fe677 --- /dev/null +++ b/deploy-docker-gitea.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# ============================================================================== +# Gitea Deployment Script (Docker Compose + Traefik) +# ============================================================================== +# Deploys Gitea + PostgreSQL behind the shared Traefik reverse proxy. +# +# Usage: deploy-docker-gitea.sh +# deploy-docker-gitea.sh --domain git.us.an2.io +# deploy-docker-gitea.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="gitea" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}-docker" +SSH_PORT="${SSH_PORT:-222}" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Domain resolution: --domain > existing .env > default --- +# Read domain from .env without sourcing (avoids clobbering other vars) +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="git.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Gitea (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " SSH Port: ${SSH_PORT}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^gitea$'; then + echo -e "${GREEN} Gitea is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f gitea" + echo -e " Backup: ${SCRIPT_DIR}/backup-gitea.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall "${SSH_PORT}/tcp" + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + # Backup restored — source .env for credentials, but keep our resolved domain + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install --- + + # Preserve credentials from existing .env on re-run + if [[ -f "${BASE_DIR}/.env" ]]; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + else + POSTGRES_USER="gitea" + POSTGRES_PASSWORD="$(generate_password)" + POSTGRES_DB="gitea" + fi + + info "Creating directory layout..." + mkdir -p "${BASE_DIR}"/{postgres/data,gitea/data} + + # --- Generate .env --- + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" <<'COMPOSE' +services: + postgres: + image: postgres:16-alpine + container_name: gitea-postgres + restart: unless-stopped + security_opt: + - no-new-privileges:true + mem_limit: 2G + read_only: true + tmpfs: + - /tmp + - /var/run/postgresql + volumes: + - ./postgres/data:/var/lib/postgresql/data + environment: + - POSTGRES_USER + - POSTGRES_PASSWORD + - POSTGRES_DB + networks: + - gitea-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + gitea: + image: docker.gitea.com/gitea:1.25.5 + container_name: gitea + restart: unless-stopped + depends_on: + postgres: + condition: service_started + security_opt: + - no-new-privileges:true + mem_limit: 2G + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=gitea-postgres:5432 + - GITEA__database__NAME=${POSTGRES_DB} + - GITEA__database__USER=${POSTGRES_USER} + - GITEA__database__PASSWD=${POSTGRES_PASSWORD} + - GITEA__server__ROOT_URL=https://${DOMAIN} + - GITEA__server__SSH_DOMAIN=${DOMAIN} + - GITEA__server__SSH_PORT=${SSH_PORT} + volumes: + - ./gitea/data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "${SSH_PORT}:22" + networks: + - gitea-internal + - traefik-public + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +networks: + gitea-internal: + driver: bridge + traefik-public: + external: true +COMPOSE + ok "docker-compose.yml written." +fi + +# --- Always do these (fresh or restored) --- +write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://gitea:3000" +install_systemd_unit "$UNIT_NAME" "$BASE_DIR" +(cd "$BASE_DIR" && docker compose up -d) +wait_for_healthy "gitea" "http://localhost:3000/" 120 + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Gitea deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " URL: https://${DOMAIN}" +echo -e " SSH: ssh://git@${DOMAIN}:${SSH_PORT}" +echo -e " Data: ${BASE_DIR}/" +echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: docker logs -f gitea" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-hermes.sh b/deploy-docker-hermes.sh new file mode 100755 index 0000000..6bcd907 --- /dev/null +++ b/deploy-docker-hermes.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# ============================================================================== +# Hermes Agent Deployment Script (Docker — Gateway Mode) +# ============================================================================== +# Deploys Hermes Agent as a gateway connecting to Telegram/Discord/Slack/WhatsApp. +# No web UI — does NOT use Traefik. +# +# First run requires interactive setup for API keys: +# docker run -it --rm -v /opt/hermes/data:/opt/data nousresearch/hermes-agent setup +# +# Usage: deploy-docker-hermes.sh +# deploy-docker-hermes.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="hermes" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="" # No Traefik +UNIT_NAME="${SERVICE_NAME}-docker" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Detect current state for banner --- +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Hermes Agent (Docker/Gateway)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Traefik: Not used (no web UI)" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^hermes$'; then + echo -e "${GREEN} Hermes is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f hermes" + echo -e " Backup: ${SCRIPT_DIR}/backup-hermes.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Infrastructure (Docker only, no Traefik) --- +require_root +detect_os +install_prerequisites +ensure_docker_network + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + ok "Backup restored." +else + # --- Fresh install --- + mkdir -p "${BASE_DIR}/data" + + if [[ ! -f "${BASE_DIR}/.env" ]]; then + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" <<'COMPOSE' +services: + hermes: + image: nousresearch/hermes-agent:latest + container_name: hermes + restart: unless-stopped + command: gateway run + security_opt: + - no-new-privileges:true + mem_limit: 4G + shm_size: 1g + volumes: + - ./data:/opt/data + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" +COMPOSE + ok "docker-compose.yml written." +fi + +# --- Start --- +install_systemd_unit "$UNIT_NAME" "$BASE_DIR" +(cd "$BASE_DIR" && docker compose up -d) + +# Health check — hermes doesn't have HTTP, use version command +info "Checking Hermes is running..." +sleep 5 +if docker exec hermes hermes version > /dev/null 2>&1; then + ok "Hermes is running." +else + warn "Hermes container started but 'hermes version' failed. May need initial setup." +fi + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Hermes Agent deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: docker logs -f hermes" +echo "" +echo -e " ${YELLOW}First run? Set up API keys:${NC}" +echo -e " docker run -it --rm -v ${BASE_DIR}/data:/opt/data nousresearch/hermes-agent setup" +echo -e " Then: cd ${BASE_DIR} && docker compose restart" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-mattermost.sh b/deploy-docker-mattermost.sh new file mode 100755 index 0000000..687b962 --- /dev/null +++ b/deploy-docker-mattermost.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# ============================================================================== +# Mattermost Deployment Script (Docker Compose + Traefik) +# ============================================================================== +# Deploys Mattermost + PostgreSQL behind the shared Traefik reverse proxy. +# ChatOps-optimized: bot accounts, webhooks, personal access tokens enabled. +# +# Usage: deploy-docker-mattermost.sh +# deploy-docker-mattermost.sh --domain chat.us.an2.io +# deploy-docker-mattermost.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="mattermost" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}-docker" +CALLS_PORT="${CALLS_PORT:-8443}" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Domain resolution: --domain > existing .env > default --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="mm.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Mattermost (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " Calls Port: ${CALLS_PORT}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^mm-app$'; then + echo -e "${GREEN} Mattermost is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f mm-app" + echo -e " Backup: ${SCRIPT_DIR}/backup-mattermost.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall "${CALLS_PORT}/udp" "${CALLS_PORT}/tcp" + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install --- + + if [[ -f "${BASE_DIR}/.env" ]]; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + else + POSTGRES_USER="mmuser" + POSTGRES_PASSWORD="$(generate_password)" + POSTGRES_DB="mattermost" + fi + + info "Creating directory layout..." + mkdir -p "${BASE_DIR}"/{postgres/data,mattermost/{config,data,logs,plugins,client-plugins,bleve-indexes}} + chown -R 2000:2000 "${BASE_DIR}/mattermost" + + # --- Generate .env --- + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" <<'COMPOSE' +# Mattermost + PostgreSQL +# Based on https://github.com/mattermost/docker + +services: + postgres: + image: postgres:16-alpine + container_name: mm-postgres + restart: unless-stopped + security_opt: + - no-new-privileges:true + mem_limit: 4G + read_only: true + tmpfs: + - /tmp + - /var/run/postgresql + volumes: + - ./postgres/data:/var/lib/postgresql + environment: + - TZ + - POSTGRES_USER + - POSTGRES_PASSWORD + - POSTGRES_DB + networks: + - mm-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + mattermost: + depends_on: + postgres: + condition: service_started + image: mattermost/mattermost-enterprise-edition:release-11.0 + container_name: mm-app + restart: unless-stopped + security_opt: + - no-new-privileges:true + mem_limit: 4G + tmpfs: + - /tmp + volumes: + - ./mattermost/config:/mattermost/config:rw + - ./mattermost/data:/mattermost/data:rw + - ./mattermost/logs:/mattermost/logs:rw + - ./mattermost/plugins:/mattermost/plugins:rw + - ./mattermost/client-plugins:/mattermost/client/plugins:rw + - ./mattermost/bleve-indexes:/mattermost/bleve-indexes:rw + environment: + - TZ + - MM_SQLSETTINGS_DRIVERNAME + - MM_SQLSETTINGS_DATASOURCE + - MM_BLEVESETTINGS_INDEXDIR + - MM_SERVICESETTINGS_SITEURL + - MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION + - MM_SERVICESETTINGS_ENABLEOAUTHSERVICEPROVIDER + - MM_SERVICESETTINGS_ENABLEINCOMINGWEBHOOKS + - MM_SERVICESETTINGS_ENABLEOUTGOINGWEBHOOKS + - MM_SERVICESETTINGS_ENABLEPOSTUSERNAMEOVERRIDE + - MM_SERVICESETTINGS_ENABLEPOSTICONOVERRIDE + - MM_SERVICESETTINGS_ENABLECUSTOMEMOJI + - MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS + - MM_PLUGINSETTINGS_ENABLEUPLOADS + - MM_TEAMSETTINGS_ENABLEOPENSERVER + ports: + - "${CALLS_PORT}:${CALLS_PORT}/udp" + - "${CALLS_PORT}:${CALLS_PORT}/tcp" + networks: + - mm-internal + - traefik-public + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +networks: + mm-internal: + driver: bridge + traefik-public: + external: true +COMPOSE + ok "docker-compose.yml written." +fi + +# --- Always do these (fresh or restored) --- +write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://mm-app:8065" +install_systemd_unit "$UNIT_NAME" "$BASE_DIR" +(cd "$BASE_DIR" && docker compose up -d) +wait_for_healthy "mm-app" "http://localhost:8443/api/v4/system/ping" 120 + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Mattermost deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " URL: https://${DOMAIN}" +echo -e " Calls: Port ${CALLS_PORT} (UDP+TCP)" +echo -e " Data: ${BASE_DIR}/" +echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: docker logs -f mm-app" +echo "" +echo -e " ${YELLOW}Next: Open https://${DOMAIN} and create your admin account.${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-open-webui.sh b/deploy-docker-open-webui.sh new file mode 100755 index 0000000..dc93cba --- /dev/null +++ b/deploy-docker-open-webui.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# ============================================================================== +# Open WebUI Deployment Script (Docker Compose + Traefik) +# ============================================================================== +# Deploys Open WebUI behind the shared Traefik reverse proxy. +# +# Usage: deploy-docker-open-webui.sh +# deploy-docker-open-webui.sh --domain chat.us.an2.io +# deploy-docker-open-webui.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="open-webui" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}-docker" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Domain resolution: --domain > existing .env > default --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="ai.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Open WebUI (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^open-webui$'; then + echo -e "${GREEN} Open WebUI is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f open-webui" + echo -e " Backup: ${SCRIPT_DIR}/backup-open-webui.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install --- + + if [[ -f "${BASE_DIR}/.env" ]]; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + else + WEBUI_SECRET_KEY="$(openssl rand -hex 32)" + fi + + info "Creating directory layout..." + mkdir -p "${BASE_DIR}/data" + + # --- Generate .env --- + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" <<'COMPOSE' +services: + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + restart: unless-stopped + security_opt: + - no-new-privileges:true + mem_limit: 4G + volumes: + - ./data:/app/backend/data + environment: + - WEBUI_SECRET_KEY + - WEBUI_AUTH=False + # Use host gateway so container can reach host-side Ollama + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - traefik-public + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +networks: + traefik-public: + external: true +COMPOSE + ok "docker-compose.yml written." +fi + +# --- Always do these (fresh or restored) --- +write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://open-webui:8080" +install_systemd_unit "$UNIT_NAME" "$BASE_DIR" +(cd "$BASE_DIR" && docker compose up -d) +wait_for_healthy "open-webui" "http://localhost:8080" 120 + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Open WebUI deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " URL: https://${DOMAIN}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Auth: disabled (single-user mode)" +echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: docker logs -f open-webui" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-openclaw.sh b/deploy-docker-openclaw.sh new file mode 100755 index 0000000..4223648 --- /dev/null +++ b/deploy-docker-openclaw.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# ============================================================================== +# OpenClaw Deployment Script (Docker Compose + Traefik) +# ============================================================================== +# Deploys OpenClaw gateway behind the shared Traefik reverse proxy. +# Source: https://docs.openclaw.ai/install/docker +# +# Usage: deploy-docker-openclaw.sh +# deploy-docker-openclaw.sh --domain claw.an2.io +# deploy-docker-openclaw.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="openclaw" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}-docker" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Domain resolution --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="openclaw.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying OpenClaw (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^openclaw$'; then + echo -e "${GREEN} OpenClaw is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f openclaw" + echo -e " Backup: ${SCRIPT_DIR}/backup-openclaw.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install --- + + if [[ -f "${BASE_DIR}/.env" ]]; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + fi + + mkdir -p "${BASE_DIR}/data" + chown -R 1000:1000 "${BASE_DIR}/data" + + # --- Generate .env --- + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" <<'COMPOSE' +services: + openclaw: + image: ghcr.io/openclaw/openclaw:latest + container_name: openclaw + restart: unless-stopped + security_opt: + - no-new-privileges:true + mem_limit: 2G + user: "1000:1000" + environment: + - OPENCLAW_CONFIG_DIR=/home/node/.openclaw + volumes: + - ./data:/home/node/.openclaw + networks: + - traefik-public + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +networks: + traefik-public: + external: true +COMPOSE + ok "docker-compose.yml written." +fi + +# --- Always do these (fresh or restored) --- +write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://openclaw:18789" +install_systemd_unit "$UNIT_NAME" "$BASE_DIR" +(cd "$BASE_DIR" && docker compose up -d) +wait_for_healthy "openclaw" "http://localhost:18789/healthz" 120 + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} OpenClaw deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " URL: https://${DOMAIN}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: docker logs -f openclaw" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-paperclip.sh b/deploy-docker-paperclip.sh new file mode 100755 index 0000000..bb3bbaf --- /dev/null +++ b/deploy-docker-paperclip.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# ============================================================================== +# Paperclip Deployment Script (Docker Compose + Traefik) +# ============================================================================== +# Builds and deploys Paperclip from source behind the shared Traefik reverse proxy. +# Source: https://github.com/paperclipai/paperclip +# +# Usage: deploy-docker-paperclip.sh +# deploy-docker-paperclip.sh --domain clip.us.an2.io +# deploy-docker-paperclip.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="paperclip" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}-docker" +PAPERCLIP_REPO="${PAPERCLIP_REPO:-https://github.com/paperclipai/paperclip.git}" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Domain resolution --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="paperclip.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Paperclip (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^paperclip$'; then + echo -e "${GREEN} Paperclip is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f paperclip" + echo -e " Backup: ${SCRIPT_DIR}/backup-paperclip.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install --- + + if [[ -f "${BASE_DIR}/.env" ]]; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + fi + + mkdir -p "${BASE_DIR}/data" "${BASE_DIR}/postgres/data" + + # Clone or update source + if [[ -d "${BASE_DIR}/repo/.git" ]]; then + info "Updating source..." + (cd "${BASE_DIR}/repo" && git pull) + else + info "Cloning Paperclip from ${PAPERCLIP_REPO}..." + git clone "$PAPERCLIP_REPO" "${BASE_DIR}/repo" + fi + + # --- Generate .env (preserve existing passwords on re-run) --- + if [[ -z "${DB_PASS:-}" ]]; then + DB_PASS="$(generate_password)" + fi + if [[ -z "${BETTER_AUTH_SECRET:-}" ]]; then + BETTER_AUTH_SECRET="$(generate_password)" + fi + + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" </dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Traefik${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Data: ${TRAEFIK_DIR}" +echo -e " Dynamic: ${TRAEFIK_DYNAMIC_DIR}/" +echo -e " ACME Email: ${ACME_EMAIL}" +echo -e " Status: ${traefik_status}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + echo -e "${GREEN} Traefik is already running — nothing to do.${NC}" + echo "" + echo -e " Logs: docker logs -f traefik" + echo -e " Backup: ${SCRIPT_DIR}/backup-traefik.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Deploy --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Traefik deployed successfully${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " Dynamic: ${TRAEFIK_DYNAMIC_DIR}/" +echo -e " Certs: ${TRAEFIK_DIR}/acme.json" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: docker logs -f traefik" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-docker-web.sh b/deploy-docker-web.sh new file mode 100755 index 0000000..69e878c --- /dev/null +++ b/deploy-docker-web.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# ============================================================================== +# Static Web (nginx) Deployment Script — Multi-Instance +# ============================================================================== +# Deploys an nginx container serving static files behind Traefik. +# Each instance is identified by --dir. Multiple instances can coexist. +# +# Usage: deploy-docker-web.sh --domain web1.an2.io --dir acme +# deploy-docker-web.sh --domain web2.an2.io --dir acme-dev +# deploy-docker-web.sh --remove --dir acme [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Validate --dir --- +[[ -n "$ARG_DIR" ]] || fatal "--dir is required. Example: $0 --domain web.an2.io --dir mysite" + +if ! [[ "$ARG_DIR" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]]; then + fatal "--dir value must be alphanumeric (hyphens/underscores ok, no spaces/slashes): ${ARG_DIR}" +fi + +# --- Derive names from --dir --- +DIR_NAME="$ARG_DIR" +SERVICE_NAME="web-${DIR_NAME}" +BASE_DIR="/opt/${DIR_NAME}" +CONFIG_NAME="web-${DIR_NAME}" +UNIT_NAME="web-${DIR_NAME}-docker" +CONTAINER_NAME="web-${DIR_NAME}" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + do_remove "$SERVICE_NAME" "$CONFIG_NAME" "$UNIT_NAME" "$BASE_DIR" + exit 0 +fi + +# --- Validate --domain (required for deploy) --- +if [[ -z "$ARG_DOMAIN" ]] && ! [[ -f "${BASE_DIR}/.env" ]]; then + fatal "--domain is required for deploy. Example: $0 --domain web.an2.io --dir ${DIR_NAME}" +fi + +# --- Domain resolution --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + fatal "--domain is required for deploy." +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Web: ${DIR_NAME} (Docker/nginx)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}/html/" +echo -e " Container: ${CONTAINER_NAME}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/docker-compose.yml" ]] && \ + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER_NAME}$"; then + echo -e "${GREEN} ${CONTAINER_NAME} is already running — nothing to do.${NC}" + echo "" + echo -e " Document root: ${BASE_DIR}/html/" + echo -e " Backup: ${SCRIPT_DIR}/backup-web.sh --dir ${DIR_NAME}" + echo -e " Remove: $0 --remove --dir ${DIR_NAME} [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Backup restore check --- +if check_and_restore_backup "$DIR_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" +else + # --- Fresh install --- + + if [[ -f "${BASE_DIR}/.env" ]]; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + fi + + mkdir -p "${BASE_DIR}/html" + + # Default placeholder page + if [[ ! -f "${BASE_DIR}/html/index.html" ]]; then + cat > "${BASE_DIR}/html/index.html" < + +${DOMAIN} +

${DOMAIN}

Replace this file at ${BASE_DIR}/html/index.html

+ +HTML + fi + + # --- Generate .env --- + cat > "${BASE_DIR}/.env" < "${BASE_DIR}/docker-compose.yml" < /dev/null 2>&1 || true + systemctl disable "${UNIT_NAME}.service" > /dev/null 2>&1 || true + rm -f "$unit_file" + systemctl daemon-reload + ok "Removed systemd unit: ${UNIT_NAME}.service" + fi + + if [[ "$ARG_PURGE" == "1" ]]; then + echo "" + warn "This will permanently delete: $BASE_DIR" + if [[ "$ARG_YES" != "1" ]]; then + read -rp "Are you sure? [y/N] " confirm + if [[ "${confirm,,}" != "y" ]]; then + info "Purge cancelled." + exit 0 + fi + fi + rm -rf "$BASE_DIR" + rm -rf "$HOME/.hermes" 2>/dev/null + ok "Purged: $BASE_DIR" + fi + + echo "" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo -e "${GREEN} ${SERVICE_NAME} removed${NC}" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + if [[ "$ARG_PURGE" != "1" ]]; then + echo -e " Data preserved at ${BASE_DIR}" + echo -e " To purge: $0 --remove --purge" + fi + echo -e " To redeploy: $0" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo "" + exit 0 +fi + +# --- Detect current state for banner --- +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +service_status="Not running" +if systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then + service_status="Running" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Hermes Agent (Local/Gateway)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Traefik: Not used (no web UI)" +echo -e " Service: ${service_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/.env" ]] && \ + systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then + echo -e "${GREEN} Hermes is already running — nothing to do.${NC}" + echo "" + echo -e " Health: hermes doctor" + echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f" + echo -e " Backup: ${SCRIPT_DIR}/backup-hermes.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Infrastructure --- +require_root +detect_os + +# --- Install prerequisites --- +info "Installing prerequisites..." +case "$DISTRO_FAMILY" in + debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq curl git openssl ca-certificates \ + python3 python3-venv python3-pip > /dev/null 2>&1 + ;; + fedora) + dnf install -y -q curl git openssl ca-certificates \ + python3 python3-pip > /dev/null 2>&1 + ;; +esac +ok "System prerequisites installed." + +# Install uv (Python package manager) +if ! command -v uv &>/dev/null; then + info "Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh > /dev/null 2>&1 + export PATH="$HOME/.local/bin:$PATH" +fi +ok "uv $(uv --version 2>/dev/null || echo 'installed')." + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + ok "Backup restored." +else + # --- Fresh install --- + if [[ -d "${BASE_DIR}/.git" ]]; then + info "Updating Hermes source..." + (cd "$BASE_DIR" && git pull) + else + info "Cloning Hermes from ${HERMES_REPO}..." + git clone --recurse-submodules "$HERMES_REPO" "$BASE_DIR" + fi + + info "Creating Python venv and installing dependencies..." + (cd "$BASE_DIR" && uv venv venv --python 3.11 2>/dev/null || uv venv venv) + export VIRTUAL_ENV="${BASE_DIR}/venv" + (cd "$BASE_DIR" && uv pip install -e ".[all]" 2>/dev/null) || \ + (cd "$BASE_DIR" && "${BASE_DIR}/venv/bin/pip" install -e ".[all]") + ok "Hermes installed." + + # Symlink binary + mkdir -p /usr/local/bin + ln -sf "${BASE_DIR}/venv/bin/hermes" /usr/local/bin/hermes + + # Data directory — redirect ~/.hermes here + mkdir -p "${BASE_DIR}/data" + rm -rf "$HOME/.hermes" 2>/dev/null + ln -sfn "${BASE_DIR}/data" "$HOME/.hermes" + + # Create data subdirs if fresh + if [[ ! -d "${BASE_DIR}/data/sessions" ]]; then + mkdir -p "${BASE_DIR}/data"/{cron,sessions,logs,memories,skills,pairing,hooks,image_cache,audio_cache} + fi + + if [[ ! -f "${BASE_DIR}/.env" ]]; then + cat > "${BASE_DIR}/.env" < "/etc/systemd/system/${UNIT_NAME}.service" < /dev/null 2>&1 +systemctl restart "${UNIT_NAME}.service" +ok "Systemd unit installed and started: ${UNIT_NAME}.service" + +# --- Health check --- +sleep 3 +if hermes doctor > /dev/null 2>&1; then + ok "Hermes is healthy." +else + warn "hermes doctor failed — may need initial setup (API keys)." +fi + +# --- Summary --- +echo "" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e "${GREEN} Hermes Agent deployed successfully (local)${NC}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f" +echo "" +echo -e " ${YELLOW}First run? Set up API keys:${NC}" +echo -e " hermes setup" +echo -e " Then: systemctl restart ${UNIT_NAME}" +echo -e "${GREEN}══════════════════════════════════════════════${NC}" +echo "" diff --git a/deploy-local-paperclip.sh b/deploy-local-paperclip.sh new file mode 100755 index 0000000..bfe9e02 --- /dev/null +++ b/deploy-local-paperclip.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# ============================================================================== +# Paperclip Local (Bare-Metal) Deployment Script +# ============================================================================== +# Installs Paperclip directly on the host with Node.js + pnpm. +# Uses embedded PostgreSQL — no external database needed. +# Does NOT auto-bootstrap Traefik (prints instructions if needed). +# +# Source: https://github.com/paperclipai/paperclip +# +# Usage: deploy-local-paperclip.sh +# deploy-local-paperclip.sh --domain clip.us.an2.io +# deploy-local-paperclip.sh --remove [--purge] [--yes] +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/common.sh" + +# --- Parse arguments --- +parse_args "$@" + +# --- Service config --- +SERVICE_NAME="paperclip" +BASE_DIR="/opt/${SERVICE_NAME}" +CONFIG_NAME="${SERVICE_NAME}" +UNIT_NAME="${SERVICE_NAME}" + +# --- Handle --remove --- +if [[ "$ARG_REMOVE" == "1" ]]; then + require_root + + echo "" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + echo -e "${YELLOW} Removing ${SERVICE_NAME} (local)${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + if [[ "$ARG_PURGE" == "1" ]]; then + echo -e " Mode: ${RED}Purge (delete all data)${NC}" + else + echo -e " Mode: Safe (keep data)" + fi + echo -e " Data: ${BASE_DIR}" + echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" + echo -e " Systemd: ${UNIT_NAME}.service" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + echo "" + + if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$BASE_DIR" ]]; then + backup_script="${SCRIPT_DIR}/backup-${SERVICE_NAME}.sh" + if [[ -f "$backup_script" ]]; then + warn "Back up first? ${backup_script}" + fi + echo "" + fi + + # Stop and remove systemd unit + unit_file="/etc/systemd/system/${UNIT_NAME}.service" + if [[ -f "$unit_file" ]]; then + systemctl stop "${UNIT_NAME}.service" > /dev/null 2>&1 || true + systemctl disable "${UNIT_NAME}.service" > /dev/null 2>&1 || true + rm -f "$unit_file" + systemctl daemon-reload + ok "Removed systemd unit: ${UNIT_NAME}.service" + fi + + # Remove traefik config if it exists (user may have set it up manually) + remove_traefik_dynamic_config "$SERVICE_NAME" + + if [[ "$ARG_PURGE" == "1" ]]; then + echo "" + warn "This will permanently delete: $BASE_DIR" + if [[ "$ARG_YES" != "1" ]]; then + read -rp "Are you sure? [y/N] " confirm + if [[ "${confirm,,}" != "y" ]]; then + info "Purge cancelled." + exit 0 + fi + fi + rm -rf "$BASE_DIR" + ok "Purged: $BASE_DIR" + fi + + echo "" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo -e "${GREEN} ${SERVICE_NAME} removed${NC}" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + if [[ "$ARG_PURGE" != "1" ]]; then + echo -e " Data preserved at ${BASE_DIR}" + echo -e " To purge: $0 --remove --purge" + fi + echo -e " To redeploy: $0" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo "" + exit 0 +fi + +# --- Domain resolution --- +if [[ -n "$ARG_DOMAIN" ]]; then + DOMAIN="$ARG_DOMAIN" +elif [[ -f "${BASE_DIR}/.env" ]] && grep -q '^DOMAIN=' "${BASE_DIR}/.env"; then + DOMAIN="$(grep '^DOMAIN=' "${BASE_DIR}/.env" | cut -d= -f2)" +else + DOMAIN="paperclip.an2.io" +fi + +# --- Detect current state for banner --- +traefik_status="Not found — will deploy" +if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^traefik$'; then + traefik_status="Running" +elif [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then + traefik_status="Stopped — will start" +fi + +install_mode="Fresh install" +if [[ -f "${BASE_DIR}/.env" ]]; then + install_mode="Re-run (preserving data)" +fi + +service_status="Not running" +if systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then + service_status="Running" +fi + +# --- Print deployment plan --- +echo "" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e "${CYAN} Deploying Paperclip (Local)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo -e " Domain: ${DOMAIN}" +echo -e " Data: ${BASE_DIR}" +echo -e " Service: ${service_status}" +echo -e " Traefik: ${traefik_status}" +echo -e " Mode: ${install_mode}" +echo -e "${CYAN}══════════════════════════════════════════════${NC}" +echo "" + +# --- Check if already deployed and running --- +if [[ -f "${BASE_DIR}/.env" ]] && \ + systemctl is-active --quiet "${UNIT_NAME}.service" 2>/dev/null; then + echo -e "${GREEN} Paperclip is already running — nothing to do.${NC}" + echo "" + echo -e " Health: curl http://localhost:3100/api/health" + echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f" + echo -e " Backup: ${SCRIPT_DIR}/backup-paperclip.sh" + echo -e " Remove: $0 --remove [--purge]" + echo "" + exit 0 +fi + +# --- Shared infrastructure --- +require_root +detect_os +install_prerequisites +ensure_docker_network +ensure_traefik +configure_firewall + +# --- Install Node.js + pnpm --- +install_nodejs() { + if command -v node &>/dev/null; then + local node_major + node_major="$(node --version | sed 's/^v//' | cut -d. -f1)" + if (( node_major >= 20 )); then + ok "Node.js $(node --version) already installed." + return 0 + fi + warn "Node.js $(node --version) is too old (need 20+). Upgrading..." + fi + + info "Installing Node.js 20..." + case "$DISTRO_FAMILY" in + debian) + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 + apt-get install -y -qq nodejs > /dev/null 2>&1 + ;; + fedora) + curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 + dnf install -y -q nodejs > /dev/null 2>&1 + ;; + esac + ok "Node.js $(node --version) installed." +} + +install_pnpm() { + if command -v pnpm &>/dev/null; then + local pnpm_major + pnpm_major="$(pnpm --version | cut -d. -f1)" + if (( pnpm_major >= 9 )); then + ok "pnpm $(pnpm --version) already installed." + return 0 + fi + fi + + info "Installing pnpm 9+..." + corepack enable 2>/dev/null || npm install -g corepack + corepack prepare pnpm@latest --activate 2>/dev/null || npm install -g pnpm@latest + ok "pnpm $(pnpm --version) installed." +} + +# Install base prerequisites (curl, openssl, etc.) +case "$DISTRO_FAMILY" in + debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq curl openssl jq git ca-certificates > /dev/null 2>&1 + ;; + fedora) + dnf install -y -q curl openssl jq git ca-certificates > /dev/null 2>&1 + ;; +esac + +install_nodejs +install_pnpm + +# --- Ensure system-wide node exists --- +# nvm installs node under /root/ which other users cannot access. +# The paperclip service user needs a system-wide node binary. +SYS_NODE="" +for _p in /usr/bin/node /usr/local/bin/node; do + [[ -x "$_p" ]] && SYS_NODE="$_p" && break +done +if [[ -z "$SYS_NODE" ]]; then + warn "Node.js only available via nvm (under /root/) — installing system-wide..." + case "$DISTRO_FAMILY" in + debian) + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 + apt-get install -y -qq nodejs > /dev/null 2>&1 + ;; + fedora) + curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 + dnf install -y -q nodejs > /dev/null 2>&1 + ;; + esac + for _p in /usr/bin/node /usr/local/bin/node; do + [[ -x "$_p" ]] && SYS_NODE="$_p" && break + done + [[ -x "$SYS_NODE" ]] || fatal "Failed to install system-wide Node.js" + ok "System-wide Node.js installed: ${SYS_NODE}" +fi + +# --- Create service user (embedded Postgres refuses to run as root) --- +if ! id -u paperclip &>/dev/null; then + useradd -r -s /usr/sbin/nologin -d "$BASE_DIR" paperclip + ok "Created system user: paperclip" +fi + +# --- Backup restore check --- +if check_and_restore_backup "$SERVICE_NAME"; then + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + ok "Backup restored. Skipping fresh install." +else + # --- Fresh install --- + if [[ -f "${BASE_DIR}/.env" ]]; then + # Re-run: update dependencies only + saved_domain="$DOMAIN" + # shellcheck source=/dev/null + source "${BASE_DIR}/.env" + DOMAIN="$saved_domain" + info "Existing installation found. Updating dependencies..." + (cd "$BASE_DIR" && pnpm install --frozen-lockfile 2>/dev/null || pnpm install) + info "Rebuilding project..." + (cd "$BASE_DIR" && pnpm build) + else + # Fresh clone + PAPERCLIP_REPO="${PAPERCLIP_REPO:-https://github.com/paperclipai/paperclip.git}" + + info "Cloning Paperclip from ${PAPERCLIP_REPO}..." + if [[ -d "${BASE_DIR}/.git" ]]; then + (cd "$BASE_DIR" && git pull) + else + git clone "$PAPERCLIP_REPO" "$BASE_DIR" + fi + + info "Installing dependencies..." + (cd "$BASE_DIR" && pnpm install) + + info "Building project (this may take a minute)..." + (cd "$BASE_DIR" && pnpm build) + + mkdir -p "${BASE_DIR}/data" + + # --- Generate .env --- + # authenticated mode + HOST=0.0.0.0 so Traefik (in Docker) can reach the service + BETTER_AUTH_SECRET="$(generate_password)" + cat > "${BASE_DIR}/.env" </dev/null; wait "$_onboard_pid" 2>/dev/null || true + pkill -u paperclip -f "cli/src/index" 2>/dev/null || true + sleep 2 + + # Switch config to authenticated mode (onboard defaults to local_trusted) + # Key is nested: server.deploymentMode (not top-level) + PAPERCLIP_CONFIG="${BASE_DIR}/data/instances/default/config.json" + if [[ -f "$PAPERCLIP_CONFIG" ]] && command -v jq &>/dev/null; then + jq '.server.deploymentMode = "authenticated"' "$PAPERCLIP_CONFIG" > "${PAPERCLIP_CONFIG}.tmp" && \ + mv "${PAPERCLIP_CONFIG}.tmp" "$PAPERCLIP_CONFIG" + chown paperclip:paperclip "$PAPERCLIP_CONFIG" + ok "Config set to authenticated mode." + fi +fi + +# --- Register domain as allowed hostname --- +info "Registering allowed hostname: ${DOMAIN}" +sudo -u paperclip bash -c "cd '${BASE_DIR}' && PAPERCLIP_HOME='${BASE_DIR}/data' ${PAPERCLIP_CMD} allowed-hostname '${DOMAIN}'" || \ + warn "Could not register hostname — you may need to run: pnpm paperclipai allowed-hostname ${DOMAIN}" + +# --- Create paperclipai symlink --- +cat > /usr/local/bin/paperclipai <&1)" || true + +# --- Install systemd unit (process, not Docker) --- +cat > "/etc/systemd/system/${UNIT_NAME}.service" < /dev/null 2>&1 +systemctl restart "${UNIT_NAME}.service" +ok "Systemd unit installed and started: ${UNIT_NAME}.service" + +# --- Ensure Traefik can reach host services --- +# Traefik runs in Docker; it needs host.docker.internal to reach bare-metal services. +# New deployments get this via ensure_traefik. Patch existing ones if missing. +if ! grep -q "host.docker.internal" "${TRAEFIK_DIR}/docker-compose.yml" 2>/dev/null; then + info "Updating Traefik with host.docker.internal mapping..." + cat > "${TRAEFIK_DIR}/docker-compose.override.yml" <<'OVERRIDE' +services: + traefik: + extra_hosts: + - "host.docker.internal:host-gateway" +OVERRIDE + (cd "$TRAEFIK_DIR" && docker compose up -d) + ok "Traefik updated." +fi + +# --- Write Traefik dynamic config --- +write_traefik_dynamic_config "$CONFIG_NAME" "$DOMAIN" "http://host.docker.internal:3100" + +# --- Wait for health --- +info "Waiting for Paperclip to become healthy (up to 90s)..." +healthy=0 +elapsed=0 +while (( elapsed < 90 )); do + if curl -sf http://localhost:3100/api/health > /dev/null 2>&1; then + ok "Paperclip is healthy." + healthy=1 + break + fi + sleep 5 + (( elapsed += 5 )) +done + +# --- Summary --- +echo "" +if (( healthy )); then + echo -e "${GREEN}══════════════════════════════════════════════${NC}" + echo -e "${GREEN} Paperclip deployed successfully (local)${NC}" + echo -e "${GREEN}══════════════════════════════════════════════${NC}" +else + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" + echo -e "${YELLOW} Paperclip installed but not yet healthy${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" +fi +echo -e " URL: https://${DOMAIN}" +echo -e " Local: http://localhost:3100" +echo -e " Data: ${BASE_DIR}/data/" +echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${CONFIG_NAME}.yml" +echo -e " Systemd: systemctl status ${UNIT_NAME}" +echo -e " Logs: journalctl -u ${UNIT_NAME}.service -f" +# Show invite URL if bootstrap-ceo produced one +INVITE_URL="$(echo "$INVITE_OUTPUT" | grep -oE 'https?://[^ ]+' | head -1)" || true +if [[ -n "$INVITE_URL" ]]; then + echo "" + echo -e " ${CYAN}Admin invite:${NC} ${INVITE_URL}" +fi +if (( healthy )); then + echo -e "${GREEN}══════════════════════════════════════════════${NC}" +else + echo -e "${YELLOW}══════════════════════════════════════════════${NC}" +fi +echo "" diff --git a/how-to/gitea-openclaw-bootstrap.md b/how-to/gitea-openclaw-bootstrap.md deleted file mode 100644 index 5d7f482..0000000 --- a/how-to/gitea-openclaw-bootstrap.md +++ /dev/null @@ -1,42 +0,0 @@ -# OpenClaw Gitea bootstrap -======================= - -Created user -- username: openclaw -- fullname: OpenClaw -- email: openclaw@git.an2.io - -SSH key -- private key: /home/user/.ssh/id_gitea_openclaw -- public key: /home/user/.ssh/id_gitea_openclaw.pub -- Gitea key title: openclaw@xeon - -SSH config -- host alias: gitea -- hostname: git.an2.io -- port: 222 -- user: git -- identity: /home/user/.ssh/id_gitea_openclaw - -Verified -- ssh -T gitea -- result: authenticated successfully as openclaw - -Access token -- created for user: openclaw -- token name: openclaw-bootstrap -- scopes: all -- token value was generated during bootstrap and should be stored securely if you want API repo management later - -Recommended usage -- clone/pull/push via SSH -- repo create/delete via Gitea API token - -Example remote forms -- ssh://git@git.an2.io:222/openclaw/repo.git -- gitea:openclaw/repo.git (via SSH config alias if your git tooling uses it explicitly) - -Suggested next steps -1. Store the generated Gitea token somewhere safe for future API use -2. Create a first test repo under openclaw -3. Verify clone, commit, and push with the SSH identity diff --git a/how-to/mattermost-openclaw.md b/how-to/mattermost-openclaw.md deleted file mode 100644 index 8f7b4b3..0000000 --- a/how-to/mattermost-openclaw.md +++ /dev/null @@ -1,98 +0,0 @@ -# OpenClaw + Mattermost notes -=========================== - -Current working state -- OpenClaw gateway is reachable on port 18789 -- Mattermost channel connects as @claw -- Native slash commands are working -- Slash commands were registered successfully for 1 team -- Inline buttons are enabled in channel config -- Button callback handling is working - -What was wrong -1. A manual Mattermost plugin install was present even though Mattermost is bundled in current OpenClaw. -2. The bot account was not a member of any Mattermost team. -3. Because the bot had zero teams, OpenClaw had nowhere to register native slash commands. - -Observed failure mode -- Logs showed: - - mattermost: registered slash command callback at /api/channels/mattermost/command - - mattermost: connected as @claw - - mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive - -Actual root cause -- Mattermost API check showed the bot user belonged to zero teams. -- Native slash command registration is team-scoped, so no team membership means no commands can be created. - -What was cleaned up -- Moved manual override plugin out of the active extensions path: - - /home/user/.openclaw/extensions/mattermost - - -> /home/user/.openclaw/extensions/mattermost.disabled-20260410-230312 -- Removed stale plugin install metadata from /home/user/.openclaw/openclaw.json: - - plugins.installs.mattermost -- Removed stale disabled bundled plugin config warning: - - plugins.entries.huggingface - -Mattermost config in use -- baseUrl: https://mm.an2.io -- commands.native: true -- commands.nativeSkills: true -- callbackPath: /api/channels/mattermost/command -- callbackUrl: http://159.69.76.190:18789/api/channels/mattermost/command -- interactions.allowedSourceIps: ["172.20.0.3", "172.20.0.0/16", "127.0.0.1", "::1"] - -What fixed it -- Added the @claw bot to a Mattermost team -- After reconnect/restart, OpenClaw successfully registered slash commands - -Successful log indicators -- mattermost: connected as @claw -- mattermost: registered command /oc_status -- mattermost: registered command /oc_model -- mattermost: registered command /oc_models -- mattermost: slash commands activated for account default (20 commands) -- mattermost: slash commands registered (20 commands across 1 teams, callback=http://159.69.76.190:18789/api/channels/mattermost/command) - -Important reminder -- The bot must belong to at least one team for native slash command registration to work. -- The bot should also be added to the channels where you want it to interact. -- If native slash commands fail again, first check team membership before debugging callback URLs. - -Useful checks -- curl -sS -H 'Authorization: Bearer ' https://mm.an2.io/api/v4/users/me -- curl -sS -H 'Authorization: Bearer ' https://mm.an2.io/api/v4/users//teams -- grep -Ei 'mattermost|slash|callback' /tmp/openclaw-gateway-run.log - -If slash commands break again -1. Confirm the bot is still in a team -2. Confirm callbackUrl is reachable from the Mattermost server -3. Check for old leftover manually installed overrides in ~/.openclaw/extensions/ -4. Restart gateway and re-read Mattermost startup logs - -Button callback note -- Native slash commands now work. -- Button callback test initially failed with: mattermost interaction: rejected callback source remote=172.20.0.3 -- Updated channels.mattermost.interactions.allowedSourceIps to include: - - 172.20.0.3 - - 172.20.0.0/16 - - 127.0.0.1 - - ::1 -- After the allowlist update, button callbacks worked end-to-end. - -Elevated exec from Mattermost -- Added tools.elevated.enabled = true -- Added tools.elevated.allowFrom.mattermost = ["63rzn4hbijnrmjbomxxugntg9h"] -- This allows elevated exec only from the specified Mattermost source/account instead of opening it broadly. -- Gateway restart is required for this change to take full effect. - -Provider routing note for elevated exec -- Elevated exec checks for this session path were evaluated under provider=webchat, not provider=mattermost. -- Because of that, tools.elevated.allowFrom needed both: - - mattermost: ["63rzn4hbijnrmjbomxxugntg9h"] - - webchat: ["63rzn4hbijnrmjbomxxugntg9h"] -- Gateway restart is required after changing these allowFrom rules. - -Elevated exec verification -- After adding both provider allowlists and restarting the gateway, elevated exec from the Mattermost session path worked. -- Confirmed in-session test: sudo -n true -> SUDO_OK -- This verified that elevated exec was allowed for the effective provider path used by that session. diff --git a/how-to/traefik-openclaw.md b/how-to/traefik-openclaw.md deleted file mode 100644 index 16e7d64..0000000 --- a/how-to/traefik-openclaw.md +++ /dev/null @@ -1,46 +0,0 @@ -# OpenClaw behind Traefik -======================= - -What I changed -- Changed OpenClaw gateway bind in /home/user/.openclaw/openclaw.json from loopback to lan -- Added Traefik dynamic config at /opt/traefik/dynamic/openclaw.yml -- Target upstream is http://host.docker.internal:18789 -- Added gateway.controlUi.allowedOrigins entry for https://oc.an2.io - -Why -- Traefik runs in Docker. -- The Traefik container already has host.docker.internal mapped to the Docker host gateway. -- OpenClaw was only listening on 127.0.0.1:18789, which the container could not reach. -- Binding OpenClaw to lan makes it listen on the host network so Traefik can reach it through the host gateway. -- The Control UI loaded through Traefik uses browser origin https://oc.an2.io, so that exact origin must be listed in gateway.controlUi.allowedOrigins. - -Traefik config summary -- Router name: openclaw -- Entry point: websecure -- TLS cert resolver: letsencrypt -- Host rule: oc.an2.io -- Service upstream: http://host.docker.internal:18789 -- Dynamic file: /opt/traefik/dynamic/openclaw.yml - -OpenClaw config summary -- File: /home/user/.openclaw/openclaw.json -- gateway.bind: lan -- gateway.controlUi.allowedOrigins includes: - - http://localhost:18789 - - http://127.0.0.1:18789 - - https://oc.an2.io - -Notes -- https://localhost:18789 is not the correct public URL because OpenClaw on port 18789 speaks plain HTTP. -- TLS is terminated by Traefik on port 443 and then proxied to http://host.docker.internal:18789. -- If the browser shows "origin not allowed", check gateway.controlUi.allowedOrigins first. - -Useful checks -- ss -ltnp | grep 18789 -- curl http://localhost:18789/ -- curl -k https://oc.an2.io/ -- sudo docker logs --tail 100 traefik - -If needed -- Restart OpenClaw gateway after config changes -- Traefik dynamic config should hot-reload automatically, no Traefik restart is usually needed