diff --git a/.drone.yaml b/.drone.yaml deleted file mode 100644 index 02a603b..0000000 --- a/.drone.yaml +++ /dev/null @@ -1,28 +0,0 @@ -kind: pipeline -type: docker - -name: build-and-push-container - -triggers: - branch: - - main - event: - - push - -steps: - - name: build - image: docker:20 - commands: - - docker build -t gitea.t000-n.de/t.behrendt/backupsidecar:${DRONE_COMMIT:0:8} . - - - name: push - image: docker:20 - settings: - username: - from_secret: REGISTRY_USERNAME - password: - from_secret: REGISTRY_PASSWORD - repo: gitea.t000-n.de/t.behrendt/backupsidecar - tags: - - latest - - ${DRONE_COMMIT:0:8} diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml new file mode 100644 index 0000000..485e3c1 --- /dev/null +++ b/.gitea/workflows/cd.yaml @@ -0,0 +1,83 @@ +name: Build and Release Docker Image + +on: + push: + branches: + - main + +jobs: + build_amd64: + name: Build and Push amd64 + runs-on: [ubuntu-latest, linux_amd64] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: gitea.t000-n.de + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build Docker image for amd64 + run: | + docker build \ + --platform linux/amd64 \ + -f Dockerfile \ + -t gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-amd64 \ + . + + - name: Push Docker image for amd64 + run: | + docker push gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-amd64 + + build_arm64: + name: Build and Push arm64 + runs-on: [ubuntu-latest, linux_arm64] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: gitea.t000-n.de + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build Docker image for arm64 + run: | + docker build \ + --platform linux/arm64 \ + -f Dockerfile \ + -t gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-arm64 \ + . + + - name: Push Docker image for arm64 + run: | + docker push gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-arm64 + + manifest: + name: Create and Push Multi-Arch Manifest + needs: [build_amd64, build_arm64] + runs-on: ubuntu-latest + steps: + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: gitea.t000-n.de + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Create and push manifest with git SHA + run: | + docker manifest create gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }} \ + gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-amd64 \ + gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-arm64 + docker manifest push gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }} + + docker manifest create gitea.t000-n.de/t.behrendt/backupsidecar:latest \ + gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-amd64 \ + gitea.t000-n.de/t.behrendt/backupsidecar:${{ github.sha }}-arm64 + docker manifest push gitea.t000-n.de/t.behrendt/backupsidecar:latest diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3398630..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dockerBuildAndPush.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ba53d74..2b2328c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,16 @@ -FROM alpine:3.17 +FROM alpine:3.21 -# Setup correct TZ -RUN apk add alpine-conf -RUN /sbin/setup-timezone -z UTC - -RUN apk add restic curl +RUN apk update && apk add --no-cache \ + bash \ + curl \ + restic \ + postgresql-client \ + jq WORKDIR /app -COPY ./src/entry.sh /app/ -COPY ./src/backup.sh /app/ +COPY src/backup.sh /app/backup.sh -CMD [ "/bin/sh", "entry.sh" ] +RUN chmod +x /app/backup.sh + +ENTRYPOINT ["/app/backup.sh"] diff --git a/README.md b/README.md index f5529bd..3f385f4 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,113 @@ -# backupsidecar +# BackupSidecar -Backup sidecar that automatically creates backups of one PVC and saves it to another PVC via restic +BackupSidecar is a lightweight backup solution designed to run as a cron job in Kubernetes. It automates backups using Restic and supports both directory and PostgreSQL database backups. Notifications are sent via Gotify to keep you informed of backup results. -## Function -A cronjob inside the container runs in the configured interval creating the backup and purging old backups. -A notification is sent to gotify on completion of the backup or on error of either the backup or purge. +## Configuration -## Environment Variables -| ENV Variable | Required |Description| Example Value -|--------------|----------|--------------|- -|INTERVAL|yes|cronjob interval string|15 14 * * *| -|RESTIC_REPOSITORY|yes|path of the restic repository|/mnt/backups/gitea| -|SOURCEDIR |yes|path of the path to backup|/mnt/toBackup/| -|KEEPLAST|yes|number of increments to keep (keep in mind that the number of backups to keep correlates with the interval in which they are created) |10| -|RESTIC_PASSWORD|yes|password for the restic repository|******| -|RUNONSTART|no|set to true to force a backup at the start of the container|true| -|GOTIFYHOST|yes|URL of the gotify server (without trailing slash)|https://gotify.example.com| -|GOTIFYTOKEN|yes|gotify app token|******| -|GOTIFYTOPIC|yes|gotify topic to include in the notification|gotify| \ No newline at end of file +BackupSidecar is configured through environment variables. Below is a breakdown of the available settings. + +### General Settings + +These variables apply to both directory and PostgreSQL backups. + +- **`BACKUP_MODE`** _(optional)_ - Defines the backup type (`directory` or `postgres`). Defaults to `directory`. +- **`RESTIC_PASSWORD`** _(required)_ - The encryption password for Restic. +- **`RESTIC_REPOSITORY`** _(required)_ - The URI of the Restic repository (e.g., `rest:http://your-rest-server:8000/backup`). +- **`RESTIC_REST_USERNAME`** _(optional)_ - The username for REST server authentication. +- **`RESTIC_REST_PASSWORD`** _(optional)_ - The password for REST server authentication. +- **`GOTIFYHOST`** _(required)_ - The Gotify server URL. +- **`GOTIFYTOKEN`** _(required)_ - The API token for Gotify. +- **`GOTIFYTOPIC`** _(required)_ - The topic under which backup notifications will be sent. + +### Directory Backup + +When running in `directory` mode, the following variable must be set: + +- **`SOURCEDIR`** _(required)_ - The path of the directory to be backed up. + +### PostgreSQL Backup + +For `postgres` mode, the following database-related variables are required: + +- **`PGHOST`** _(required)_ - The hostname of the PostgreSQL server. +- **`PGDATABASE`** _(required)_ - The name of the database to back up. +- **`PGUSER`** _(required)_ - The PostgreSQL username. +- **`PGPORT`** _(optional)_ - The port for PostgreSQL (defaults to `5432`). +- **`PGPASSWORD`** _(optional)_ - The password for authentication. Setting this prevents interactive prompts. +- **`PG_DUMP_ARGS`** _(optional)_ - Additional flags for `pg_dump`. + +## Dependencies + +Ensure the following commands are available in the container: + +- `restic` +- `curl` +- `jq` +- `pg_dump` _(only required for `postgres` mode)_ + +## Usage + +Example Kubernetes CronJob manifest for running BackupSidecar as a cron job for directory backups in minimal configuration: + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: backupsidecar-cron + namespace: authentik +spec: + schedule: "0 7 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 5 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 3 + activeDeadlineSeconds: 300 + template: + spec: + restartPolicy: OnFailure + containers: + - name: backupsidecar + image: backupsidecar:latest + env: + - name: RESTIC_REPOSITORY + value: "rest:http://rest-server:8000/backup" + - name: RESTIC_PASSWORD + valueFrom: + secretKeyRef: + name: backupsidecar-secret + key: restic_password + - name: BACKUP_MODE + value: "directory" # or "postgres" + - name: SOURCEDIR + value: "/data/source" + - name: GOTIFYHOST + value: "http://gotify.example.com" + - name: GOTIFYTOKEN + valueFrom: + secretKeyRef: + name: backupsidecar-secret + key: gotify_token + - name: GOTIFYTOPIC + value: "Backup Notification" + # (For PostgreSQL mode, add PGHOST, PGDATABASE, PGUSER, PGPORT, PGPASSWORD) + volumeMounts: + - name: source-data + mountPath: /data/source + restartPolicy: OnFailure + volumes: + - name: source-data + persistentVolumeClaim: + claimName: source-data-pvc +``` + +## Notifications + +The script sends success or failure notifications via Gotify. + +Example success notification: + +``` +Backup successful. Snapshot 56ff6a909a44e01f67d2d88f9a76aa713d437809d7ed14a2361e28893f38befb: files new: 1, files changed: 0, data added: 1019 bytes in 0.277535184 sec +``` diff --git a/example-deployment.yaml b/example-deployment.yaml deleted file mode 100644 index 49e9602..0000000 --- a/example-deployment.yaml +++ /dev/null @@ -1,62 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: backup-sidecar -spec: - selector: - matchLabels: - app: backup-sidecar - template: - metadata: - labels: - app: backup-sidecar - spec: - containers: - - name: backup-sidecar - image: gitea.t000-n.de/t.behrendt/backupsidecar:latest - resources: - requests: - cpu: 128m - memory: 128Mi - limits: - cpu: 256m - memory: 1024Mi - volumeMounts: - - mountPath: /mnt/toBackup - name: data - readOnly: true - - mountPath: /mnt/backups/gitea - name: nfs-backup - subPath: gitea - env: - - name: INTERVAL - value: "15 14 * * *" - - name: RESTIC_REPOSITORY - value: /mnt/backups/gitea - - name: SOURCEDIR - value: "/mnt/toBackup/" - - name: KEEPLAST - value: "100" - - name: RESTIC_PASSWORD - valueFrom: - secretKeyRef: - name: restic-credentials - key: password - - name: RUNONSTART - value: "false" - - name: GOTIFYHOST - value: "https://" - - name: GOTIFYTOKEN - valueFrom: - secretKeyRef: - name: gotify-credentials - key: token - - name: GOTIFYTOPIC - value: "gitea" - volumes: - - name: data - persistentVolumeClaim: - claimName: data - - name: nfs-backup - persistentVolumeClaim: - claimName: backup-nfs diff --git a/src/backup.sh b/src/backup.sh index 4e6722b..17d96c3 100644 --- a/src/backup.sh +++ b/src/backup.sh @@ -1,40 +1,188 @@ -#!/bin/sh +#!/bin/bash +set -euo pipefail -GOTIFYURL="$GOTIFYHOST/message?token=$GOTIFYTOKEN" +####################################### +# Determine backup mode from the environment only. +# Valid values: "directory" or "postgres". +# Default to "directory" if not provided. +####################################### +BACKUP_MODE="${BACKUP_MODE:-directory}" -echo "$(date +"%Y-%m-%dT%T") - Starting backup" - -restic backup \ - --verbose \ - $SOURCEDIR - -RESTIC_BACKUP_RETURN=$? - -if [ $RESTIC_BACKUP_RETURN -eq 0 ]; then - MSG_BACKUP_SUCCESS="Backup successful" - echo "$(date +"%Y-%m-%dT%T") - $MSG_BACKUP_SUCCESS" - curl -s -X POST "$GOTIFYURL" -F "title=$GOTIFYTOPIC" -F "message=$MSG_BACKUP_SUCCESS" -else - MSG_BACKUP_ERR="Backup failed with error code $RESTIC_BACKUP_RETURN" - echo "$(date +"%Y-%m-%dT%T") - MSG_BACKUP_ERR" - curl -s -X POST "$GOTIFYURL" -F "title=$GOTIFYTOPIC" -F "message=$MSG_BACKUP_ERR" - exit $RESTIC_BACKUP_RETURN +####################################### +# Check for required external commands. +####################################### +REQUIRED_CMDS=(restic curl jq) +if [ "$BACKUP_MODE" = "postgres" ]; then + REQUIRED_CMDS+=(pg_dump) fi +for cmd in "${REQUIRED_CMDS[@]}"; do + if ! command -v "$cmd" &>/dev/null; then + echo "Error: Required command '$cmd' is not installed." >&2 + exit 1 + fi +done -MSG_PURGE_START="$(date +"%Y-%m-%dT%T") - Removing old backups" -echo $MSG_PURGE_START +####################################### +# Validate common required environment variables. +####################################### +# Gotify notification settings. +: "${GOTIFYHOST:?Environment variable GOTIFYHOST is not set}" +: "${GOTIFYTOKEN:?Environment variable GOTIFYTOKEN is not set}" +: "${GOTIFYTOPIC:?Environment variable GOTIFYTOPIC is not set}" -restic forget --keep-last $KEEPLAST --prune -RESTIC_PURGE_RETURN=$? +# Restic encryption password. +: "${RESTIC_PASSWORD:?Environment variable RESTIC_PASSWORD is not set}" -if [ $RESTIC_PURGE_RETURN -eq 0 ]; then - echo "$(date +"%Y-%m-%dT%T") - Purge successful" -else - MSG_PURGE_ERR="Purge failed with error code $MSG_PURGE_ERR" - echo "$(date +"%Y-%m-%dT%T") - $MSG_PURGE_ERR" - curl -s -X POST "$GOTIFYURL" -F "title=$GOTIFYTOPIC" -F "message=$MSG_PURGE_ERR" - exit $RESTIC_PURGE_RETURN -fi +# Use the repository URI directly from the environment. +# Example: export RESTIC_REPOSITORY="rest:http://your-rest-server:8000/backup" +: "${RESTIC_REPOSITORY:?Environment variable RESTIC_REPOSITORY is not set}" -echo "$(date +"%Y-%m-%dT%T") - Going back to sleep..." +####################################### +# Validate mode-specific environment variables. +####################################### +case "$BACKUP_MODE" in + directory) + : "${SOURCEDIR:?Environment variable SOURCEDIR is not set (required for directory backup mode)}" + ;; + postgres) + : "${PGHOST:?Environment variable PGHOST is not set (required for PostgreSQL backup mode)}" + : "${PGDATABASE:?Environment variable PGDATABASE is not set (required for PostgreSQL backup mode)}" + : "${PGUSER:?Environment variable PGUSER is not set (required for PostgreSQL backup mode)}" + # Optional: default PGPORT to 5432. + : "${PGPORT:=5432}" + if [ -z "${PGPASSWORD:-}" ]; then + echo "Warning: Environment variable PGPASSWORD is not set. pg_dump may fail if authentication is required." + fi + ;; + *) + echo "Error: Unknown backup mode '$BACKUP_MODE'. Valid modes are 'directory' and 'postgres'." >&2 + exit 1 + ;; +esac + +####################################### +# Build the Gotify URL. +####################################### +GOTIFYURL="${GOTIFYHOST}/message?token=${GOTIFYTOKEN}" + +####################################### +# Date format for logging. +####################################### +LOG_DATE_FORMAT="%Y-%m-%dT%T" + +####################################### +# Log a message with a timestamp. +# Arguments: +# Message to log. +####################################### +log() { + echo "$(date +"$LOG_DATE_FORMAT") - $*" +} + +####################################### +# Send a notification via Gotify. +# Arguments: +# message: The message to send. +####################################### +send_notification() { + local message="$1" + if ! curl -s -X POST "$GOTIFYURL" -F "title=${GOTIFYTOPIC}" -F "message=${message}" >/dev/null; then + log "Warning: Failed to send notification with message: ${message}" + fi +} + +####################################### +# Run the backup using restic. +# The --no-cache flag disables local caching. +# Arguments: +# $1 - The source directory to back up. +####################################### +run_restic_backup() { + local source_dir="$1" + cd "${source_dir}" + log "Starting backup of '${source_dir}' to repository ${RESTIC_REPOSITORY}" + # Capture both stdout and stderr in a variable + backup_output=$(restic -r "${RESTIC_REPOSITORY}" backup --no-cache --json --verbose . 2>&1) + # Optionally, also print the output to the console: + echo "$backup_output" + # Parse the JSON lines output for the summary message + summary=$(echo "$backup_output" | jq -r 'select(.message_type=="summary") | "Snapshot " + (.snapshot_id // "none") + ": " + "files new: " + (.files_new|tostring) + ", files changed: " + (.files_changed|tostring) + ", data added: " + (.data_added|tostring) + " bytes in " + (.total_duration|tostring) + " sec"') + # Check exit code of restic backup (assuming restic exits non-zero on error) + if [ $? -eq 0 ]; then + msg="Backup successful. $summary" + log "$msg" + send_notification "$msg" + else + exit_code=$? + msg="Backup failed with error code ${exit_code}. $backup_output" + log "$msg" + send_notification "$msg" + exit "$exit_code" + fi +} + + +####################################### +# Backup a directory (regular mode). +####################################### +backup_directory() { + run_restic_backup "${SOURCEDIR}" +} + +####################################### +# Backup a PostgreSQL database. +# Dumps the database to a temporary directory and then backs it up. +####################################### +backup_postgres() { + log "Starting PostgreSQL backup for database '${PGDATABASE}' on host '${PGHOST}'" + + # Create a temporary directory for the database dump. + TEMP_BACKUP_DIR=$(mktemp -d) + log "Created temporary directory: ${TEMP_BACKUP_DIR}" + + local dump_file="${TEMP_BACKUP_DIR}/dump.sql" + log "Dumping PostgreSQL database to ${dump_file}..." + if pg_dump -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" ${PG_DUMP_ARGS:-} "${PGDATABASE}" > "${dump_file}"; then + log "Database dump created successfully." + else + local exit_code=$? + local msg="PostgreSQL dump failed with error code ${exit_code}" + log "$msg" + send_notification "$msg" + exit "$exit_code" + fi + + # Back up the directory containing the dump. + run_restic_backup "${TEMP_BACKUP_DIR}" +} + +####################################### +# Cleanup temporary resources. +####################################### +cleanup() { + if [ -n "${TEMP_BACKUP_DIR:-}" ] && [ -d "${TEMP_BACKUP_DIR}" ]; then + rm -rf "${TEMP_BACKUP_DIR}" + log "Removed temporary directory ${TEMP_BACKUP_DIR}" + fi +} +trap cleanup EXIT + +####################################### +# Main routine. +####################################### +main() { + case "$BACKUP_MODE" in + directory) + backup_directory + ;; + postgres) + backup_postgres + ;; + esac +} + +# Trap termination signals to log and exit cleanly. +trap 'log "Script interrupted. Exiting."; exit 1' SIGINT SIGTERM + +main diff --git a/src/entry.sh b/src/entry.sh deleted file mode 100644 index 6878b47..0000000 --- a/src/entry.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -mkdir /etc/cron.d -touch /etc/cron.d/backup -echo "$INTERVAL /bin/sh /app/backup.sh" > /etc/cron.d/backup - -# change ownership and make the cron known to crontab -chmod 0644 /etc/cron.d/backup && crontab /etc/cron.d/backup - - -if [ $RUNONSTART = 'true' ]; then - echo $(date +"%Y-%m-%dT%T") "- Running initial backup" - /bin/sh /app/backup.sh -fi - -# Wait until infinity -echo $(date +"%Y-%m-%dT%T") "- Starting cron" -crond -f \ No newline at end of file