269 lines
11 KiB
YAML
269 lines
11 KiB
YAML
---
|
|
# ============================================================================
|
|
# backup-homarr.yml — snapshot Homarr appdata + compose, upload to Cloudflare R2
|
|
# ============================================================================
|
|
# Weekly (Sunday night) backup. Produces: homarr-<host>-MM-DD-YY.tar
|
|
# and uploads it to the R2 bucket (pbs-offsite) under homarr/<file>.
|
|
#
|
|
# Consistency: the container is STOPPED for the tar (Homarr uses sqlite in
|
|
# appdata; hot-copying a live sqlite file risks a corrupt backup). Downtime is
|
|
# only the few seconds it takes to tar, then it's restarted + health-checked.
|
|
#
|
|
# Secrets (pulled from Infisical at runtime, never hardcoded):
|
|
# SSH_PRIVATE_KEY - deploy key (root /) to reach the ansible user
|
|
# R2_ACCESS_KEY_ID - R2 S3 access key (path /homarr)
|
|
# R2_SECRET_ACCESS_KEY - R2 S3 secret (path /homarr)
|
|
# R2_BUCKET - e.g. pbs-offsite (path /homarr)
|
|
# R2_ENDPOINT - R2 S3 endpoint URL (path /homarr)
|
|
#
|
|
# Usage: playbook playbooks/backup-homarr.yml, extra vars: target=racknerd1
|
|
# ============================================================================
|
|
|
|
- name: "PRECHECK — fetch secrets from Infisical"
|
|
hosts: localhost
|
|
gather_facts: false
|
|
vars:
|
|
infisical_url: "{{ lookup('env','INFISICAL_URL') | default('http://192.168.122.41:8080', true) }}"
|
|
infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default('dev', true) }}"
|
|
tasks:
|
|
- name: "[precheck] Infisical auth vars present?"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- lookup('env','INFISICAL_CLIENT_ID') | length > 0
|
|
- lookup('env','INFISICAL_CLIENT_SECRET') | length > 0
|
|
- lookup('env','INFISICAL_PROJECT_ID') | length > 0
|
|
fail_msg: "Missing INFISICAL_CLIENT_ID / _SECRET / _PROJECT_ID in Semaphore Environment."
|
|
|
|
- name: "[precheck] Login to Infisical"
|
|
infisical.vault.login:
|
|
url: "{{ infisical_url }}"
|
|
auth_method: universal_auth
|
|
universal_auth_client_id: "{{ lookup('env','INFISICAL_CLIENT_ID') }}"
|
|
universal_auth_client_secret: "{{ lookup('env','INFISICAL_CLIENT_SECRET') }}"
|
|
register: infisical_login
|
|
|
|
- name: "[precheck] Read SSH key from root path"
|
|
infisical.vault.read_secrets:
|
|
login_data: "{{ infisical_login.login_data }}"
|
|
project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}"
|
|
env_slug: "{{ infisical_env_slug }}"
|
|
path: "/"
|
|
as_dict: true
|
|
register: infisical_root
|
|
|
|
- name: "[precheck] Read R2 creds from /homarr path (prod)"
|
|
infisical.vault.read_secrets:
|
|
login_data: "{{ infisical_login.login_data }}"
|
|
project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}"
|
|
env_slug: "prod"
|
|
path: "/homarr"
|
|
as_dict: true
|
|
register: infisical_homarr
|
|
|
|
- name: "[precheck] Validate required secrets present"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- infisical_root.secrets.SSH_PRIVATE_KEY is defined
|
|
- infisical_homarr.secrets.R2_ACCESS_KEY_ID is defined
|
|
- infisical_homarr.secrets.R2_SECRET_ACCESS_KEY is defined
|
|
- infisical_homarr.secrets.R2_BUCKET is defined
|
|
- infisical_homarr.secrets.R2_ENDPOINT is defined
|
|
fail_msg: "Missing SSH_PRIVATE_KEY (root /) or R2_* secrets (prod /homarr)."
|
|
|
|
- name: "[precheck] stash secrets"
|
|
ansible.builtin.set_fact:
|
|
pc_ssh_privkey: "{{ infisical_root.secrets.SSH_PRIVATE_KEY }}"
|
|
pc_r2_akid: "{{ infisical_homarr.secrets.R2_ACCESS_KEY_ID }}"
|
|
pc_r2_sak: "{{ infisical_homarr.secrets.R2_SECRET_ACCESS_KEY }}"
|
|
pc_r2_bucket: "{{ infisical_homarr.secrets.R2_BUCKET }}"
|
|
pc_r2_endpoint: "{{ infisical_homarr.secrets.R2_ENDPOINT }}"
|
|
no_log: true
|
|
|
|
- name: "[precheck] write deploy private key to runner temp (0600)"
|
|
ansible.builtin.copy:
|
|
dest: "/tmp/homarr_deploy_key"
|
|
content: "{{ pc_ssh_privkey }}\n"
|
|
mode: "0600"
|
|
no_log: true
|
|
|
|
- name: "Backup Homarr + upload to R2 (as ansible user)"
|
|
hosts: "{{ target | default('racknerd1') }}"
|
|
gather_facts: true
|
|
vars:
|
|
deploy_user: ansible
|
|
homarr_dir: "/home/{{ deploy_user }}/docker/homarr"
|
|
homarr_port: 7575
|
|
backup_dir: "/home/{{ deploy_user }}/backups/homarr"
|
|
keep_local: 2 # keep this many most-recent local tars
|
|
r2_akid: "{{ hostvars['localhost'].pc_r2_akid }}"
|
|
r2_sak: "{{ hostvars['localhost'].pc_r2_sak }}"
|
|
r2_bucket: "{{ hostvars['localhost'].pc_r2_bucket }}"
|
|
r2_endpoint: "{{ hostvars['localhost'].pc_r2_endpoint }}"
|
|
ansible_user: ansible
|
|
ansible_ssh_private_key_file: "/tmp/homarr_deploy_key"
|
|
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes"
|
|
tasks:
|
|
- name: Reset connection so docker group membership is active
|
|
ansible.builtin.meta: reset_connection
|
|
|
|
- name: Gather facts (after connection reset)
|
|
ansible.builtin.setup:
|
|
|
|
- name: Docker available?
|
|
ansible.builtin.command: docker version
|
|
register: dv
|
|
changed_when: false
|
|
failed_when: dv.rc != 0
|
|
|
|
- name: Compose stack present?
|
|
ansible.builtin.stat:
|
|
path: "{{ homarr_dir }}/docker-compose.yml"
|
|
register: compose_file
|
|
|
|
- name: Fail early if stack not deployed
|
|
ansible.builtin.assert:
|
|
that: [compose_file.stat.exists]
|
|
fail_msg: "No docker-compose.yml at {{ homarr_dir }} — nothing to back up. Run deploy-homarr.yml first."
|
|
|
|
- name: Compute backup filename homarr-<host>-MM-DD-YY.tar
|
|
ansible.builtin.set_fact:
|
|
backup_name: "homarr-{{ inventory_hostname }}-{{ '%m-%d-%y' | strftime(ansible_date_time.epoch | int) }}.tar"
|
|
|
|
- name: Ensure backup dir exists
|
|
ansible.builtin.file:
|
|
path: "{{ backup_dir }}"
|
|
state: directory
|
|
owner: "{{ deploy_user }}"
|
|
group: "{{ deploy_user }}"
|
|
mode: "0750"
|
|
|
|
- name: Ensure rclone is installed (ROOTLESS — no sudo; installs to ~/.local/bin)
|
|
block:
|
|
- name: rclone present on PATH or in ~/.local/bin?
|
|
ansible.builtin.shell: 'command -v rclone || command -v "$HOME/.local/bin/rclone"'
|
|
register: rc_ver
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Install rclone rootless (download static binary to ~/.local/bin)
|
|
when: rc_ver.rc != 0
|
|
block:
|
|
- name: Ensure ~/.local/bin exists
|
|
ansible.builtin.file:
|
|
path: "/home/{{ deploy_user }}/.local/bin"
|
|
state: directory
|
|
mode: "0755"
|
|
|
|
- name: Download + unpack latest rclone linux-amd64 into ~/.local/bin (rootless, python unzip)
|
|
ansible.builtin.shell: |
|
|
set -e
|
|
D="$(mktemp -d)"; cd "$D"
|
|
curl -fsSL https://downloads.rclone.org/rclone-current-linux-amd64.zip -o rclone.zip
|
|
python3 -c "import zipfile,glob,shutil,os,stat; z=zipfile.ZipFile('rclone.zip'); z.extractall('.'); b=glob.glob('rclone-*-linux-amd64/rclone')[0]; dst=os.path.expanduser('~/.local/bin/rclone'); shutil.copy(b,dst); os.chmod(dst, 0o755)"
|
|
changed_when: true
|
|
|
|
- name: Resolve rclone binary path (PATH or ~/.local/bin)
|
|
ansible.builtin.shell: 'command -v rclone || echo "/home/{{ deploy_user }}/.local/bin/rclone"'
|
|
register: rclone_bin_res
|
|
changed_when: false
|
|
|
|
- name: Set rclone binary fact
|
|
ansible.builtin.set_fact:
|
|
rclone_bin: "{{ rclone_bin_res.stdout_lines[0] }}"
|
|
|
|
- name: "Stop Homarr (consistent sqlite snapshot)"
|
|
community.docker.docker_compose_v2:
|
|
project_src: "{{ homarr_dir }}"
|
|
state: stopped
|
|
|
|
- name: Create tar (compose + appdata)
|
|
ansible.builtin.command:
|
|
cmd: tar -cf "{{ backup_dir }}/{{ backup_name }}" -C "{{ homarr_dir }}" docker-compose.yml appdata
|
|
register: tarres
|
|
changed_when: true
|
|
|
|
- name: "Restart Homarr immediately"
|
|
community.docker.docker_compose_v2:
|
|
project_src: "{{ homarr_dir }}"
|
|
state: present
|
|
|
|
- name: Wait for Homarr healthy
|
|
ansible.builtin.uri:
|
|
url: "http://127.0.0.1:{{ homarr_port }}/"
|
|
status_code: [200, 302, 307]
|
|
register: health
|
|
retries: 15
|
|
delay: 5
|
|
until: health.status is defined and health.status in [200, 302, 307]
|
|
|
|
- name: Backup size
|
|
ansible.builtin.stat:
|
|
path: "{{ backup_dir }}/{{ backup_name }}"
|
|
register: bkstat
|
|
|
|
- name: Configure rclone R2 remote (env, this play only)
|
|
ansible.builtin.set_fact:
|
|
rclone_env:
|
|
RCLONE_CONFIG_R2_TYPE: s3
|
|
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
|
|
RCLONE_CONFIG_R2_ACCESS_KEY_ID: "{{ r2_akid }}"
|
|
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: "{{ r2_sak }}"
|
|
RCLONE_CONFIG_R2_ENDPOINT: "{{ r2_endpoint }}"
|
|
RCLONE_CONFIG_R2_ACL: private
|
|
no_log: true
|
|
|
|
- name: Upload tar to R2 (r2:<bucket>/homarr/<file>)
|
|
ansible.builtin.command:
|
|
cmd: "{{ rclone_bin }} copyto \"{{ backup_dir }}/{{ backup_name }}\" \"r2:{{ r2_bucket }}/homarr/{{ backup_name }}\" --s3-no-check-bucket"
|
|
environment: "{{ rclone_env }}"
|
|
register: upload
|
|
changed_when: true
|
|
no_log: true
|
|
|
|
- name: Verify object exists in R2
|
|
ansible.builtin.command:
|
|
cmd: "{{ rclone_bin }} lsf \"r2:{{ r2_bucket }}/homarr/{{ backup_name }}\""
|
|
environment: "{{ rclone_env }}"
|
|
register: verify
|
|
changed_when: false
|
|
failed_when: backup_name not in verify.stdout
|
|
|
|
- name: List existing Homarr backups in R2 for this host
|
|
ansible.builtin.command:
|
|
cmd: "{{ rclone_bin }} lsf \"r2:{{ r2_bucket }}/homarr/\""
|
|
environment: "{{ rclone_env }}"
|
|
register: r2_listing
|
|
changed_when: false
|
|
|
|
- name: Delete OLDER R2 backups for this host (keep only the one just uploaded)
|
|
ansible.builtin.command:
|
|
cmd: "{{ rclone_bin }} deletefile \"r2:{{ r2_bucket }}/homarr/{{ item }}\""
|
|
environment: "{{ rclone_env }}"
|
|
loop: "{{ r2_listing.stdout_lines }}"
|
|
when:
|
|
- item is match('homarr-' ~ inventory_hostname ~ '-.*\.tar$')
|
|
- item != backup_name
|
|
changed_when: true
|
|
|
|
- name: Prune old LOCAL tars (keep {{ keep_local }} newest)
|
|
ansible.builtin.shell: >-
|
|
ls -1t "{{ backup_dir }}"/homarr-*.tar 2>/dev/null | tail -n +{{ keep_local + 1 }} | xargs -r rm -f
|
|
changed_when: true
|
|
failed_when: false
|
|
|
|
- name: Result
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
Backed up {{ backup_name }} ({{ (bkstat.stat.size / 1048576) | round(1) }} MB)
|
|
-> r2:{{ r2_bucket }}/homarr/ (older versions for {{ inventory_hostname }} deleted) ;
|
|
Homarr healthy (HTTP {{ health.status }}).
|
|
|
|
- name: "Cleanup runner temp key"
|
|
hosts: localhost
|
|
gather_facts: false
|
|
tasks:
|
|
- name: remove temp deploy key
|
|
ansible.builtin.file:
|
|
path: /tmp/homarr_deploy_key
|
|
state: absent
|