diff --git a/.gitignore b/.gitignore index 1c7274f..4f02397 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *env +!template/example_server-env + rclone.conf # SSH key pair diff --git a/actual_server-backup b/actual_server-backup index d6b1d93..63a9806 100644 --- a/actual_server-backup +++ b/actual_server-backup @@ -1,40 +1,261 @@ #!/bin/bash +# +# Backup script for Actual Budget service +# Stops the service, creates a backup, uploads to remote storage, and restarts the service +# + +set -euo pipefail # shellcheck source=actual_server-env . "${HOME}"/"${USER}"-env +#====================================== +# Constants +#====================================== +readonly SERVICE_NAME="Actual Server" +readonly PRIORITY_INFO=2 +readonly PRIORITY_WARNING=3 +readonly PRIORITY_ERROR=4 +readonly EXIT_SUCCESS=0 +readonly EXIT_VALIDATION_ERROR=1 +readonly EXIT_DOCKER_ERROR=2 +readonly EXIT_BACKUP_ERROR=3 +readonly EXIT_UPLOAD_ERROR=4 + +#====================================== +# Global Variables +#====================================== +backupDir="/tmp/${USER}-backup" +composeFile="${HOME}/${USER}-compose.yaml" +logFile="${HOME}/backup_logs/$(date +%y_%m).log" +serviceRestartFailed=false + +#====================================== +# Helper Functions +#====================================== + +# Log a message with timestamp +# Output to stderr to avoid buffering issues with stdout +log() { + local level="$1" + shift + local message="$*" + echo "$(date '+%Y-%m-%d %H:%M:%S') - [${level}] ${message}" >&2 +} + +# Log info message +log_info() { + log "INFO" "$@" +} + +# Log warning message +log_warn() { + log "WARN" "$@" +} + +# Log error message +log_error() { + log "ERROR" "$@" +} + +# Cleanup function - ensures temporary files are removed +# shellcheck disable=SC2329 +cleanup() { + local exit_code=$? + + if [[ -d "${backupDir}" ]]; then + log_info "Cleaning up backup directory" + rm -rf "${backupDir}" + fi + + if [[ ${exit_code} -ne 0 ]]; then + log_error "Backup failed with exit code ${exit_code}" + fi +} + +# Set trap to ensure cleanup on exit +trap cleanup EXIT + +# Send ntfy notification via curl +# Arguments: priority, tags, message +send_notification() { + local priority="$1" + local tags="$2" + local message="$3" + + if ! curl -Ss \ + -H "Title: ${SERVICE_NAME}" \ + -H "Priority: ${priority}" \ + -H "Tags: ${tags}" \ + -d "${message}" \ + "${NOTIF_URL}"; then + log_warn "Failed to send notification" + return 1 + fi + + return 0 +} + +# Validate required environment variables +validate_environment() { + log_info "Validating environment variables" + + : "${USER:?USER environment variable is required}" + : "${VOLUME_PATH:?VOLUME_PATH environment variable is required}" + : "${BUCKET_PATH:?BUCKET_PATH environment variable is required}" + : "${NOTIF_URL:?NOTIF_URL environment variable is required}" +} + +# Validate required dependencies and paths +validate_dependencies() { + log_info "Validating dependencies and paths" + + # Check for rclone + if ! command -v rclone &>/dev/null; then + log_error "rclone command not found" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: rclone not found" + return "${EXIT_VALIDATION_ERROR}" + fi + + # Check docker compose file + if [[ ! -f "${composeFile}" ]]; then + log_error "Docker compose file not found: ${composeFile}" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: compose file not found" + return "${EXIT_VALIDATION_ERROR}" + fi + + # Check volume path + if [[ ! -d "${VOLUME_PATH}" ]]; then + log_error "Volume path not found: ${VOLUME_PATH}" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: volume path not found" + return "${EXIT_VALIDATION_ERROR}" + fi + + return "${EXIT_SUCCESS}" +} + +# Stop docker compose services +stop_services() { + log_info "Stopping docker compose services" + + if ! sudo docker compose -f "${composeFile}" stop; then + log_error "Failed to stop docker compose services" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: could not stop services" + return "${EXIT_DOCKER_ERROR}" + fi + + return "${EXIT_SUCCESS}" +} + +# Start docker compose services +start_services() { + log_info "Starting docker compose services" + + if ! sudo docker compose -f "${composeFile}" start; then + log_warn "Failed to start docker compose services" + serviceRestartFailed=true + return 1 + fi + + return "${EXIT_SUCCESS}" +} + +# Create backup of volume data +create_backup() { + log_info "Creating backup directory" + mkdir -p "${backupDir}" + + # Special handling for Actual Budget directory structure + # Pre-create subdirectories for backward compatibility + mkdir -p "${backupDir}"/{user,server}-files + + log_info "Copying files from ${VOLUME_PATH}" + if ! cp -pr "${VOLUME_PATH}"/* "${backupDir}"/; then + log_error "Failed to copy files to backup directory" + return "${EXIT_BACKUP_ERROR}" + fi + + # Verify backup has content + if [[ -z "$(ls -A "${backupDir}")" ]]; then + log_error "Backup directory is empty - no files to backup" + return "${EXIT_BACKUP_ERROR}" + fi + + log_info "Backup created successfully" + return "${EXIT_SUCCESS}" +} + +# Upload backup to remote storage +upload_backup() { + local backup_size + + log_info "Uploading backup to ${BUCKET_PATH}" + + if ! rclone copy "${backupDir}" "${BUCKET_PATH}" -v --retries 3; then + log_error "Failed to upload backup to remote storage" + return "${EXIT_UPLOAD_ERROR}" + fi + + # Calculate and report backup size + backup_size=$(du -sh "${backupDir}" | cut -f1) + log_info "Backup uploaded successfully (${backup_size})" + + echo "${backup_size}" + return "${EXIT_SUCCESS}" +} + +#====================================== +# Main Execution +#====================================== +main() { + log_info "Starting ${SERVICE_NAME} backup" + + # Validate environment + validate_environment || exit "${EXIT_VALIDATION_ERROR}" + + # Validate dependencies and paths + validate_dependencies || exit "$?" + + # Stop services to ensure data consistency + stop_services || exit "$?" + + # Create backup + if ! create_backup; then + start_services # Attempt to restart services even if backup failed + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: could not copy files" + exit "${EXIT_BACKUP_ERROR}" + fi + + # Restart services immediately to minimize downtime + start_services + + # Upload backup to remote storage + local backup_size + if ! backup_size=$(upload_backup); then + if [[ "${serviceRestartFailed}" == "true" ]]; then + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup upload failed AND services failed to restart" + else + send_notification "${PRIORITY_WARNING}" "warning,backup" "Backup not completed: upload failed" + fi + exit "${EXIT_UPLOAD_ERROR}" + fi + + # Send success notification + if [[ "${serviceRestartFailed}" == "true" ]]; then + send_notification "${PRIORITY_WARNING}" "warning,backup" "Backup completed (${backup_size}) but services failed to restart" + log_warn "Backup completed but services failed to restart" + exit "${EXIT_DOCKER_ERROR}" + else + send_notification "${PRIORITY_INFO}" "heavy_check_mark,backup" "Backup completed (${backup_size})" + log_info "Backup completed successfully" + exit "${EXIT_SUCCESS}" + fi +} + +# Setup logging directory mkdir -p "${HOME}"/backup_logs -logFile=${HOME}/backup_logs/$(date +%y_%m).log +# Execute main function with output redirected to log file { - echo -e "\n[+] actual backup\n" - - mkdir -p /tmp/"${USER}"-backup/{user,server}-files - - sudo docker compose -f "${HOME}"/"${USER}"-compose.yaml stop - - cp -pr "${VOLUME_PATH}"/* /tmp/"${USER}"-backup - - sudo docker compose -f "${HOME}"/"${USER}"-compose.yaml start - - rclone copy /tmp/"${USER}"-backup "${BUCKET_PATH}" -v - if [ $? -ne 0 ]; then - curl -Ss \ - -H "Title: Actual Server" \ - -H "Priority: 3" \ - -H "Tags: warning,backup" \ - -d "Backup not completed" \ - "${NOTIF_URL}" - rm -r /tmp/"${USER}"-backup - exit 1 - fi - - curl -Ss \ - -H "Title: Actual Server" \ - -H "Priority: 2" \ - -H "Tags: heavy_check_mark,backup" \ - -d "Backup completed" \ - "${NOTIF_URL}" - rm -r /tmp/"${USER}"-backup - -} &>>"$logFile" + main +} &>>"${logFile}" diff --git a/template/example.knravish.me.conf b/template/example.knravish.me.conf new file mode 100644 index 0000000..315b6bf --- /dev/null +++ b/template/example.knravish.me.conf @@ -0,0 +1,18 @@ +server { + server_name example.knravish.me; + index index.html index.htm; + + location / { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $http_host; + proxy_pass http://127.0.0.1:; + proxy_redirect off; + proxy_set_header Access-Control-Allow-Origin *; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + listen 80; +} diff --git a/template/example_authed.knravish.me.conf b/template/example_authed.knravish.me.conf new file mode 100644 index 0000000..f5bf8a5 --- /dev/null +++ b/template/example_authed.knravish.me.conf @@ -0,0 +1,16 @@ +server { + server_name example_authed.knravish.me; + index index.html index.htm; + + include /etc/nginx/snippets/authelia-location.conf; + + set $upstream http://127.0.0.1:; + + location / { + include /etc/nginx/snippets/proxy.conf; + include /etc/nginx/snippets/authelia-authrequest.conf; + proxy_pass $upstream; + } + + listen 80; +} diff --git a/template/example_server-backup b/template/example_server-backup new file mode 100644 index 0000000..1cf9bb9 --- /dev/null +++ b/template/example_server-backup @@ -0,0 +1,257 @@ +#!/bin/bash +# +# Backup script for example service +# Stops the service, creates a backup, uploads to remote storage, and restarts the service +# + +set -euo pipefail + +# shellcheck source=example_server-env +. "${HOME}"/"${USER}"-env + +#====================================== +# Constants +#====================================== +readonly SERVICE_NAME="Example" +readonly PRIORITY_INFO=2 +readonly PRIORITY_WARNING=3 +readonly PRIORITY_ERROR=4 +readonly EXIT_SUCCESS=0 +readonly EXIT_VALIDATION_ERROR=1 +readonly EXIT_DOCKER_ERROR=2 +readonly EXIT_BACKUP_ERROR=3 +readonly EXIT_UPLOAD_ERROR=4 + +#====================================== +# Global Variables +#====================================== +backupDir="/tmp/${USER}-backup" +composeFile="${HOME}/${USER}-compose.yaml" +logFile="${HOME}/backup_logs/$(date +%y_%m).log" +serviceRestartFailed=false + +#====================================== +# Helper Functions +#====================================== + +# Log a message with timestamp +# Output to stderr to avoid buffering issues with stdout +log() { + local level="$1" + shift + local message="$*" + echo "$(date '+%Y-%m-%d %H:%M:%S') - [${level}] ${message}" >&2 +} + +# Log info message +log_info() { + log "INFO" "$@" +} + +# Log warning message +log_warn() { + log "WARN" "$@" +} + +# Log error message +log_error() { + log "ERROR" "$@" +} + +# Cleanup function - ensures temporary files are removed +# shellcheck disable=SC2329 +cleanup() { + local exit_code=$? + + if [[ -d "${backupDir}" ]]; then + log_info "Cleaning up backup directory" + rm -rf "${backupDir}" + fi + + if [[ ${exit_code} -ne 0 ]]; then + log_error "Backup failed with exit code ${exit_code}" + fi +} + +# Set trap to ensure cleanup on exit +trap cleanup EXIT + +# Send ntfy notification via curl +# Arguments: priority, tags, message +send_notification() { + local priority="$1" + local tags="$2" + local message="$3" + + if ! curl -Ss \ + -H "Title: ${SERVICE_NAME}" \ + -H "Priority: ${priority}" \ + -H "Tags: ${tags}" \ + -d "${message}" \ + "${NOTIF_URL}"; then + log_warn "Failed to send notification" + return 1 + fi + + return 0 +} + +# Validate required environment variables +validate_environment() { + log_info "Validating environment variables" + + : "${USER:?USER environment variable is required}" + : "${VOLUME_PATH:?VOLUME_PATH environment variable is required}" + : "${BUCKET_PATH:?BUCKET_PATH environment variable is required}" + : "${NOTIF_URL:?NOTIF_URL environment variable is required}" +} + +# Validate required dependencies and paths +validate_dependencies() { + log_info "Validating dependencies and paths" + + # Check for rclone + if ! command -v rclone &>/dev/null; then + log_error "rclone command not found" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: rclone not found" + return "${EXIT_VALIDATION_ERROR}" + fi + + # Check docker compose file + if [[ ! -f "${composeFile}" ]]; then + log_error "Docker compose file not found: ${composeFile}" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: compose file not found" + return "${EXIT_VALIDATION_ERROR}" + fi + + # Check volume path + if [[ ! -d "${VOLUME_PATH}" ]]; then + log_error "Volume path not found: ${VOLUME_PATH}" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: volume path not found" + return "${EXIT_VALIDATION_ERROR}" + fi + + return "${EXIT_SUCCESS}" +} + +# Stop docker compose services +stop_services() { + log_info "Stopping docker compose services" + + if ! sudo docker compose -f "${composeFile}" stop; then + log_error "Failed to stop docker compose services" + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: could not stop services" + return "${EXIT_DOCKER_ERROR}" + fi + + return "${EXIT_SUCCESS}" +} + +# Start docker compose services +start_services() { + log_info "Starting docker compose services" + + if ! sudo docker compose -f "${composeFile}" start; then + log_warn "Failed to start docker compose services" + serviceRestartFailed=true + return 1 + fi + + return "${EXIT_SUCCESS}" +} + +# Create backup of volume data +create_backup() { + log_info "Creating backup directory" + mkdir -p "${backupDir}" + + log_info "Copying files from ${VOLUME_PATH}" + if ! cp -pr "${VOLUME_PATH}"/* "${backupDir}"/; then + log_error "Failed to copy files to backup directory" + return "${EXIT_BACKUP_ERROR}" + fi + + # Verify backup has content + if [[ -z "$(ls -A "${backupDir}")" ]]; then + log_error "Backup directory is empty - no files to backup" + return "${EXIT_BACKUP_ERROR}" + fi + + log_info "Backup created successfully" + return "${EXIT_SUCCESS}" +} + +# Upload backup to remote storage +upload_backup() { + local backup_size + + log_info "Uploading backup to ${BUCKET_PATH}" + + if ! rclone copy "${backupDir}" "${BUCKET_PATH}" -v --retries 3; then + log_error "Failed to upload backup to remote storage" + return "${EXIT_UPLOAD_ERROR}" + fi + + # Calculate and report backup size + backup_size=$(du -sh "${backupDir}" | cut -f1) + log_info "Backup uploaded successfully (${backup_size})" + + echo "${backup_size}" + return "${EXIT_SUCCESS}" +} + +#====================================== +# Main Execution +#====================================== +main() { + log_info "Starting ${SERVICE_NAME} backup" + + # Validate environment + validate_environment || exit "${EXIT_VALIDATION_ERROR}" + + # Validate dependencies and paths + validate_dependencies || exit "$?" + + # Stop services to ensure data consistency + stop_services || exit "$?" + + # Create backup + if ! create_backup; then + start_services # Attempt to restart services even if backup failed + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup failed: could not copy files" + exit "${EXIT_BACKUP_ERROR}" + fi + + # Restart services immediately to minimize downtime + start_services + + # Upload backup to remote storage + local backup_size + if ! backup_size=$(upload_backup); then + if [[ "${serviceRestartFailed}" == "true" ]]; then + send_notification "${PRIORITY_ERROR}" "x,backup" "Backup upload failed AND services failed to restart" + else + send_notification "${PRIORITY_WARNING}" "warning,backup" "Backup not completed: upload failed" + fi + exit "${EXIT_UPLOAD_ERROR}" + fi + + # Send success notification + if [[ "${serviceRestartFailed}" == "true" ]]; then + send_notification "${PRIORITY_WARNING}" "warning,backup" "Backup completed (${backup_size}) but services failed to restart" + log_warn "Backup completed but services failed to restart" + exit "${EXIT_DOCKER_ERROR}" + else + send_notification "${PRIORITY_INFO}" "heavy_check_mark,backup" "Backup completed (${backup_size})" + log_info "Backup completed successfully" + exit "${EXIT_SUCCESS}" + fi +} + +# Setup logging directory +mkdir -p "${HOME}"/backup_logs + +# Execute main function with output redirected to log file +{ + main +} &>>"${logFile}" diff --git a/template/example_server-compose_template.yaml b/template/example_server-compose_template.yaml new file mode 100644 index 0000000..7fd92b0 --- /dev/null +++ b/template/example_server-compose_template.yaml @@ -0,0 +1,73 @@ +--- +services: + example: + image: example/example:latest + container_name: example + pull_policy: always + restart: unless-stopped + ports: + - 127.0.0.1:${PORT}:999999 + volumes: + - type: bind + source: ${VOLUME_PATH} + target: /path/to/data/used/by/app + bind: + create_host_path: true + environment: + PUID: ${PUID} + PGID: ${PGID} + user: ${PUID}:${PGID} # remove if running as root + # depends_on: + # postgres: + # condition: service_healthy + # restart: true + # redis: + # condition: service_healthy + # restart: true + + postgres: + image: postgres:18 + container_name: example-postgres + pull_policy: always + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql + user: ${PUID}:${PGID} + healthcheck: + test: ['CMD-SHELL', 'psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -c "select version();"'] + interval: 1s + retries: 5 + timeout: 5s + + redis: + image: redis:alpine + container_name: example-redis + command: redis-server --save 60 1 --loglevel warning + pull_policy: always + restart: unless-stopped + volumes: + - redis_data:/data + user: ${PUID}:${PGID} + healthcheck: + test: ['CMD-SHELL', 'redis-cli ping | grep PONG'] + interval: 1s + retries: 5 + timeout: 3s + +volumes: + postgres_data: + driver: local + driver_opts: + type: none + o: bind + device: ${VOLUME_PATH}/postgres + redis_data: + driver: local + driver_opts: + type: none + o: bind + device: ${VOLUME_PATH}/redis diff --git a/template/example_server-cronjob b/template/example_server-cronjob new file mode 100644 index 0000000..e69de29 diff --git a/template/example_server-env b/template/example_server-env new file mode 100644 index 0000000..b1378e5 --- /dev/null +++ b/template/example_server-env @@ -0,0 +1,10 @@ +#!/bin/bash + +export BUCKET_PATH="${BACKUP_BUCKET}/example" + +export VOLUME_PATH="${HOME}/${USER}-data" +export PORT=999999 +PUID=$(id -u "$USER") +export PUID +PGID=$(id -g "$USER") +export PGID diff --git a/template/example_server-setup b/template/example_server-setup new file mode 100644 index 0000000..e69de29 diff --git a/template/example_server-teardown b/template/example_server-teardown new file mode 100644 index 0000000..a77e16a --- /dev/null +++ b/template/example_server-teardown @@ -0,0 +1,15 @@ +#!/bin/bash + +username=example_server + +# application +sudo docker compose -f /home/${username}/${username}-compose.yaml down -v + +uid_num=$(id -u $username) +sudo killall -9 -v -g -u $username +sudo crontab -r -u $username +sudo deluser --remove-all-files $username + +# clean-up +sudo find / -user "$uid_num" -delete + diff --git a/template/example_server-update b/template/example_server-update new file mode 100644 index 0000000..22d09ef --- /dev/null +++ b/template/example_server-update @@ -0,0 +1,11 @@ +#!/bin/bash + +mkdir -p "${HOME}"/update_logs +logFile=${HOME}/update_logs/$(date +%y_%m).log +{ + echo -e "\n[+] updating example\n" + + sudo docker compose -f "${HOME}"/"${USER}"-compose.yaml pull && + sudo docker compose -f "${HOME}"/"${USER}"-compose.yaml up -d --always-recreate-deps --remove-orphans && + yes | sudo docker image prune -af +} &>>"$logFile"