diff --git a/playbooks/backup-homarr.yml b/playbooks/backup-homarr.yml new file mode 100644 index 0000000..471b488 --- /dev/null +++ b/playbooks/backup-homarr.yml @@ -0,0 +1,228 @@ +--- +# ============================================================================ +# backup-homarr.yml — snapshot Homarr appdata + compose, upload to Cloudflare R2 +# ============================================================================ +# Weekly (Sunday night) backup. Produces: homarr--MM-DD-YY.tar +# and uploads it to the R2 bucket (pbs-offsite) under homarr/. +# +# 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--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:/homarr/) + 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