diff --git a/Dockerfile b/Dockerfile index d4b526e..051862a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,48 @@ # Use LinuxServer.io Duplicati base FROM linuxserver/duplicati:2.1.0 -# Install Docker CLI, bash, python3 -RUN apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +# Install Docker CLI, bash, python3, btrfs support and all the app directories +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ bash \ python3 \ python3-pip \ - docker.io \ btrfs-progs \ - ca-certificates curl && \ - rm -rf /var/lib/apt/lists/* + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg" \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \ + $(lsb_release -cs) stable" \ + | tee /etc/apt/sources.list.d/docker.list > /dev/null \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + docker-ce-cli \ + && groupadd -f docker \ + && usermod -aG docker abc \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /usr/local/bin /config /etc/services.d/backupbot -# Create directories for backup scripts and logs -RUN mkdir -p /usr/local/bin /config/log /config/web /etc/services.d/backupbot - -# Copy backup script +# Copy the backup script COPY backup.sh /usr/local/bin/backup.sh RUN chmod +x /usr/local/bin/backup.sh -# Copy the environment variables for the config -COPY backupbot.env /defaults/backupbot.env +# Copy the environment variables for backupbot +COPY backupbot.conf /defaults/backupbot.conf +RUN chown www-data:www-data /defaults/backupbot.conf \ + && chmod 644 /defaults/backupbot.conf # Copy s6 service for backupbot COPY services/backupbot/run /etc/services.d/backupbot/run RUN chmod +x /etc/services.d/backupbot/run # Copy web frontend -COPY web /defaults/web -RUN chmod +x /defaults/web/cgi-bin/backupbot.cgi +COPY web /app +RUN chmod +x /app/cgi-bin/backupbot.cgi # Expose web frontend port EXPOSE 8080 diff --git a/backup.sh b/backup.sh index 9ecaaa2..d5baa34 100644 --- a/backup.sh +++ b/backup.sh @@ -4,7 +4,6 @@ # Author: Calahil Studios # === CONFIGURATION === -LOG_FILE="$1" BACKUP_DIR="/backups/postgres_dumps" RETENTION_DAYS="${RETENTION_DAYS:-7}" # Keep 7 days of backups @@ -19,12 +18,12 @@ ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 EOF ) -echo "[BACKUPBOT_INFO] Starting PostgreSQL backup service..." | tee -a "$LOG_FILE" +echo "[BACKUPBOT_INFO] Starting PostgreSQL backup service..." mkdir -p "$BACKUP_DIR" TIMESTAMP=$(date +'%Y-%m-%d_%H-%M-%S') -echo "[BACKUPBOT_INFO] $(date) - Starting backup cycle ($TIMESTAMP)" | tee -a "$LOG_FILE" -echo "[BACKUPBOT_INFO] Checking for running Postgres containers..." | tee -a "$LOG_FILE" +echo "[BACKUPBOT_INFO] $(date) - Starting backup cycle ($TIMESTAMP)" +echo "[BACKUPBOT_INFO] Checking for running Postgres containers..." # Find running containers matching known image names MATCHING_CONTAINERS=$( @@ -41,7 +40,7 @@ MATCHING_CONTAINERS=$( ) if [ -z "$MATCHING_CONTAINERS" ]; then - echo "[BACKUPBOT_WARN] No Postgres containers found." | tee -a "$LOG_FILE" + echo "[BACKUPBOT_WARN] No Postgres containers found." else for container in $MATCHING_CONTAINERS; do NAME=$(docker inspect --format '{{.Name}}' "$container" | sed 's#^/##') @@ -54,16 +53,16 @@ else PG_USER=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container" | grep POSTGRES_USER | cut -d= -f2) PG_PASS=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container" | grep POSTGRES_PASSWORD | cut -d= -f2) if docker exec -e PGPASSWORD="$PG_PASS" "$container" pg_dumpall -U "$PG_USER" -h 127.0.0.1 >"$FILE" 2>/tmp/pg_backup_error.log; then - echo "[BACKUPBOT_SUCCESS] Backup complete for $NAME -> $FILE" | tee -a "$LOG_FILE" + echo "[BACKUPBOT_SUCCESS] Backup complete for $NAME -> $FILE" else - echo "[BACKUPBOT_ERROR] Backup failed for $NAME (check /tmp/pg_backup_error.log)" | tee -a "$LOG_FILE" + echo "[BACKUPBOT_ERROR] Backup failed for $NAME (check /tmp/pg_backup_error.log)" fi # Retention cleanup find "$CONTAINER_BACKUP_DIR" -type f -mtime +$RETENTION_DAYS -name '*.sql' -delete done fi -echo "[BACKUPBOT_INFO] Creating a snapshot of /srv/appdata" | tee -a "$LOG_FILE" +echo "[BACKUPBOT_INFO] Creating a snapshot of /srv/appdata" btrfs subvolume snapshot -r /source/appdata /backups/snapshots/$(hostname)-$(date +%F) -echo "[BACKUPBOT_INFO] Backup cycle complete." | tee -a "$LOG_FILE" +echo "[BACKUPBOT_INFO] Backup cycle complete." diff --git a/backupbot.env b/backupbot.conf similarity index 87% rename from backupbot.env rename to backupbot.conf index 9e7130d..483d3c2 100644 --- a/backupbot.env +++ b/backupbot.conf @@ -6,3 +6,4 @@ GOTIFY_URL=http://gotify.example.com GOTIFY_TOKEN=your_gotify_token_here BACKUP_HOUR=03 BACKUP_MINUTE=00 +BACKUPBOT_WEB_LOGGING=DEBUG diff --git a/docker-compose.yml b/docker-compose.yml index 4a6e88d..a1cd073 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: container_name: backupbot privileged: true environment: - - PUID=0 - - PGID=0 + - PUID=1000 + - PGID=1000 - TZ=Etc/UTC - SETTINGS_ENCRYPTION_KEY=${KEY} - CLI_ARGS= #optional diff --git a/services/backupbot/run b/services/backupbot/run index 3f9b0c5..6d82a03 100644 --- a/services/backupbot/run +++ b/services/backupbot/run @@ -1,37 +1,25 @@ #!/usr/bin/with-contenv bash set -e # Source env if available -if [[ -f /config/backupbot.env ]]; then +if [[ -f /config/backupbot.conf ]]; then set -a - source /config/backupbot.env + source /config/backupbot.conf set +a else - echo "[INFO] copying env vars from defaults..." - cp -r /defaults/backupbot.env /config/ + echo "[INFO] copying config vars from defaults..." + cp -r /defaults/backupbot.conf /config/ set -a - source /config/backupbot.env + source /config/backupbot.conf set +a fi -# Initialize default web interface if missing -if [ ! -d /config/web ]; then - echo "[INFO] Populating /config/web from defaults..." - cp -r /defaults/web /config/ -fi # Start Python HTTP server for web config in background -cd /config/web +cd /app -if [ ! -f /config/log/web.log ]; then - mkdir -p /config/log - touch /config/log/web.log -fi - -nohup python3 -m http.server 8080 --cgi >/config/log/web.log 2>&1 & +nohup python3 -m http.server 8080 --cgi 2>&1 & # Start backup scheduler STATE_FILE="/config/last_backup_date" -LOG_FILE="/config/log/pgbackup.log" -mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$LOG_FILE")" # TZ : "${TZ:=UTC}" @@ -56,7 +44,7 @@ run_backup() { local attempt=1 while ((attempt <= RETRIES)); do echo "[INFO] Backup attempt $attempt" - if /usr/local/bin/backup.sh "$LOG_FILE"; then + if /usr/local/bin/backup.sh; then echo "[SUCCESS] Backup completed" return 0 else diff --git a/web/cgi-bin/backupbot.cgi b/web/cgi-bin/backupbot.cgi index 39bd935..699eb76 100644 --- a/web/cgi-bin/backupbot.cgi +++ b/web/cgi-bin/backupbot.cgi @@ -3,41 +3,81 @@ import cgi import cgitb import os import json -import glob +import sys +import traceback +import tempfile cgitb.enable() print("Content-Type: application/json\n") -ENV_FILE = "/config/backupbot.env" +ENV_FILE = "/config/backupbot.conf" ZONEINFO_DIR = "/usr/share/zoneinfo" +# Logging level from environment +LOG_LEVEL = os.environ.get("BACKUPBOT_WEB_LOGGING", "info").lower() +LOG_LEVELS = {"debug": 3, "info": 2, "warn": 1} + + +def log(level, message, exc=None): + """ + Docker-friendly logging. + level: "debug", "info", "warn" + exc: exception object (only used in debug) + """ + if LOG_LEVELS.get(level, 0) <= LOG_LEVELS.get(LOG_LEVEL, 0): + timestamp = ( + __import__("datetime") + .datetime.now() + .strftime( + "%Y-%m-%d \ + %H:%M:%S" + ) + ) + msg = f"[{timestamp}] [{level.upper()}] {message}" + print(msg, file=sys.stderr) + if exc and LOG_LEVEL == "debug": + traceback.print_exception( + type(exc), exc, exc.__traceback__, file=sys.stderr + ) + def read_env(): env = {} if os.path.exists(ENV_FILE): - with open(ENV_FILE) as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, val = line.split("=", 1) - key = key.strip() - val = val.strip().split("#")[0].strip() - env[key] = val + try: + with open(ENV_FILE) as f: + for line in f: + line = line.strip() + if not line or "=" not in line: + continue + key, val = line.split("=", 1) + env[key.strip()] = val.strip() + except Exception as e: + log("warn", f"Failed to read config: {e}", e) return env def write_env(env): - with open(ENV_FILE, "w") as f: - for key, val in env.items(): - f.write(f"{key}={val}\n") + try: + dir_name = os.path.dirname(ENV_FILE) + os.makedirs(dir_name, exist_ok=True) + # Write atomically to temp file + with tempfile.NamedTemporaryFile("w", dir=dir_name, delete=False) as tmp: + for key, val in env.items(): + tmp.write(f"{key}={val}\n") + temp_name = tmp.name + os.replace(temp_name, ENV_FILE) + log("info", f"Configuration saved to {ENV_FILE}") + except Exception as e: + log("warn", f"Failed to write config: {e}", e) + raise def list_timezones(): zones = [] for root, _, files in os.walk(ZONEINFO_DIR): rel_root = os.path.relpath(root, ZONEINFO_DIR) - if rel_root.startswith("posix") or rel_root.startswith("right"): + if rel_root.startswith(("posix", "right")): continue for file in files: if file.startswith(".") or file.endswith((".tab", ".zi")): @@ -49,18 +89,27 @@ def list_timezones(): form = cgi.FieldStorage() action = form.getvalue("action") -if action == "get": - print(json.dumps(read_env())) -elif action == "set": - try: - raw = os.environ.get("CONTENT_LENGTH") - length = int(raw) if raw else 0 +try: + if action == "get": + env = read_env() + log("debug", f"Returning configuration: {env}") + print(json.dumps(env)) + elif action == "set": + raw_len = os.environ.get("CONTENT_LENGTH") + length = int(raw_len) if raw_len else 0 data = json.loads(os.read(0, length)) - write_env(data) + log("debug", f"Received new configuration: {data}") + env = read_env() + env.update(data) # update existing keys, add new keys + write_env(env) print(json.dumps({"status": "ok", "message": "Configuration saved."})) - except Exception as e: - print(json.dumps({"status": "error", "message": str(e)})) -elif action == "get_timezones": - print(json.dumps({"timezones": list_timezones()})) -else: - print(json.dumps({"status": "error", "message": "Invalid action"})) + elif action == "get_timezones": + zones = list_timezones() + log("debug", f"Returning {len(zones)} timezones") + print(json.dumps({"timezones": zones})) + else: + log("warn", f"Invalid action requested: {action}") + print(json.dumps({"status": "error", "message": "Invalid action"})) +except Exception as e: + log("warn", f"Unhandled exception: {e}", e) + print(json.dumps({"status": "error", "message": str(e)})) diff --git a/web/index.html b/web/index.html index 56e50cd..dd8591f 100644 --- a/web/index.html +++ b/web/index.html @@ -37,10 +37,12 @@