semaphore/playbooks/backup-homarr.yml

229 lines
8.9 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 via user bin if needed)
block:
- name: rclone present?
ansible.builtin.command: rclone version
register: rc_ver
changed_when: false
failed_when: false
- name: Install rclone via official script (needs sudo)
ansible.builtin.shell: curl -fsSL https://rclone.org/install.sh | sudo bash
when: rc_ver.rc != 0
changed_when: true
- 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 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 lsf "r2:{{ r2_bucket }}/homarr/{{ backup_name }}"
environment: "{{ rclone_env }}"
register: verify
changed_when: false
failed_when: backup_name not in verify.stdout
- 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/ ; 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