Add backup-homarr.yml — weekly snapshot to Cloudflare R2
This commit is contained in:
parent
8fb2824158
commit
734935708a
228
playbooks/backup-homarr.yml
Normal file
228
playbooks/backup-homarr.yml
Normal file
@ -0,0 +1,228 @@
|
||||
---
|
||||
# ============================================================================
|
||||
# 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
|
||||
Loading…
Reference in New Issue
Block a user