From 5625ff89a19dfffbe63d024d542157ff614788bd Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 7 Sep 2026 09:42:27 -0400 Subject: [PATCH] Add preflight-homarr (read-only reachability gate) + update-homarr-checked (preflight -> update -> verify back up) --- playbooks/preflight-homarr.yml | 139 +++++++++++++++++++++ playbooks/update-homarr-checked.yml | 180 ++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 playbooks/preflight-homarr.yml create mode 100644 playbooks/update-homarr-checked.yml diff --git a/playbooks/preflight-homarr.yml b/playbooks/preflight-homarr.yml new file mode 100644 index 0000000..8b285d1 --- /dev/null +++ b/playbooks/preflight-homarr.yml @@ -0,0 +1,139 @@ +--- +# ============================================================================ +# preflight-homarr.yml — reachability pre-flight for Homarr (NO host changes) +# ============================================================================ +# Read-only gate. Confirms, in order: +# 1. Infisical auth + deploy SSH key retrievable (runner). +# 2. Target host reachable over SSH as the `ansible` user. +# 3. Rootless docker is usable (docker version). +# 4. The `homarr` container exists and is running. +# 5. Homarr answers HTTP on its port. +# Fails with a clear reason at the first broken link. Changes nothing on the host. +# +# Run this before update-homarr-checked.yml, or any time to confirm Homarr health. +# +# Connects AS the `ansible` user via the Infisical-injected deploy key (same +# pattern as deploy-homarr.yml / update-homarr.yml). +# +# Usage: playbook playbooks/preflight-homarr.yml, extra vars: target=racknerd1 +# ============================================================================ + +- name: "PRECHECK — fetch deploy SSH key 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 secrets" + 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_secrets + + - name: "[precheck] stash SSH key for preflight play" + ansible.builtin.set_fact: + pc_ssh_privkey: "{{ infisical_secrets.secrets.SSH_PRIVATE_KEY }}" + 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: "PRE-FLIGHT — reachability + Homarr health (read-only)" + hosts: "{{ target | default('racknerd1') }}" + gather_facts: false + vars: + deploy_user: ansible + homarr_dir: "/home/{{ deploy_user }}/docker/homarr" + homarr_port: 7575 + 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: "[1/5] SSH reachable as ansible user?" + ansible.builtin.ping: + + - name: Reset connection so docker group membership is active in this session + ansible.builtin.meta: reset_connection + + - name: "[2/5] Rootless docker usable?" + ansible.builtin.command: docker version + register: dv + changed_when: false + failed_when: dv.rc != 0 + + - name: "[3/5] Homarr container present + running?" + ansible.builtin.command: + cmd: docker inspect --format '{{ '{{' }}.State.Running{{ '}}' }}' homarr + register: homarr_state + changed_when: false + failed_when: false + + - name: "Assert homarr container is running" + ansible.builtin.assert: + that: + - homarr_state.rc == 0 + - homarr_state.stdout | trim == 'true' + fail_msg: >- + Homarr container is not running on {{ inventory_hostname }} + (docker inspect rc={{ homarr_state.rc }}, state='{{ homarr_state.stdout | trim }}'). + Run deploy-homarr.yml. + + - name: "[4/5] Compose stack file present?" + ansible.builtin.stat: + path: "{{ homarr_dir }}/docker-compose.yml" + register: compose_file + + - name: "Warn if compose file missing (container running without repo compose)" + ansible.builtin.debug: + msg: >- + NOTE: no docker-compose.yml at {{ homarr_dir }} — container is running but + update-homarr-checked.yml expects the compose stack. Deploy first if updating. + when: not compose_file.stat.exists + + - name: "[5/5] Homarr answers HTTP?" + ansible.builtin.uri: + url: "http://127.0.0.1:{{ homarr_port }}/" + status_code: [200, 302, 307] + register: health + retries: 6 + delay: 5 + until: health.status is defined and health.status in [200, 302, 307] + + - name: "PRE-FLIGHT PASSED" + ansible.builtin.debug: + msg: >- + Homarr reachable on {{ inventory_hostname }}:{{ homarr_port }} + (HTTP {{ health.status }}) — safe to update. + +- name: "Cleanup runner temp key" + hosts: localhost + gather_facts: false + tasks: + - name: "remove temp deploy key from runner" + ansible.builtin.file: + path: /tmp/homarr_deploy_key + state: absent diff --git a/playbooks/update-homarr-checked.yml b/playbooks/update-homarr-checked.yml new file mode 100644 index 0000000..01669cb --- /dev/null +++ b/playbooks/update-homarr-checked.yml @@ -0,0 +1,180 @@ +--- +# ============================================================================ +# update-homarr-checked.yml — gated update: pre-flight → update → verify back up +# ============================================================================ +# End-to-end safe updater: +# PLAY 1 (runner) fetch deploy SSH key from Infisical. +# PLAY 2 (target) PRE-FLIGHT — SSH + docker + homarr running + HTTP OK. +# Aborts before any change if the stack is already broken. +# PLAY 3 (target) UPDATE — pull latest image, recreate, verify HTTP back up, +# prune the old dangling image. +# PLAY 4 (runner) cleanup temp key. +# +# Differs from update-homarr.yml by adding an explicit reachability GATE before +# the pull (won't touch a host that's already down) and a firm post-update +# health confirmation with the before/after image digest reported. +# +# Prereq: host deployed via deploy-homarr.yml (compose stack exists). +# Connects AS the `ansible` user via the Infisical-injected deploy key. +# +# Usage: playbook playbooks/update-homarr-checked.yml, extra vars: target=racknerd1 +# ============================================================================ + +- name: "PRECHECK — fetch deploy SSH key 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 secrets" + 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_secrets + + - name: "[precheck] stash SSH key" + ansible.builtin.set_fact: + pc_ssh_privkey: "{{ infisical_secrets.secrets.SSH_PRIVATE_KEY }}" + 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: "PRE-FLIGHT — verify reachable + healthy BEFORE touching anything" + hosts: "{{ target | default('racknerd1') }}" + gather_facts: false + vars: + deploy_user: ansible + homarr_dir: "/home/{{ deploy_user }}/docker/homarr" + homarr_port: 7575 + 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: "[pre] SSH reachable as ansible user?" + ansible.builtin.ping: + + - name: Reset connection so docker group membership is active in this session + ansible.builtin.meta: reset_connection + + - name: "[pre] Rootless docker usable?" + ansible.builtin.command: docker version + register: dv + changed_when: false + failed_when: dv.rc != 0 + + - name: "[pre] Compose stack present?" + ansible.builtin.stat: + path: "{{ homarr_dir }}/docker-compose.yml" + register: compose_file + + - name: "[pre] Fail early if stack not deployed" + ansible.builtin.assert: + that: + - compose_file.stat.exists + fail_msg: >- + No docker-compose.yml at {{ homarr_dir }} — run deploy-homarr.yml first. + + - name: "[pre] Homarr answering HTTP before update?" + ansible.builtin.uri: + url: "http://127.0.0.1:{{ homarr_port }}/" + status_code: [200, 302, 307] + register: pre_health + retries: 6 + delay: 5 + until: pre_health.status is defined and pre_health.status in [200, 302, 307] + + - name: "[pre] PRE-FLIGHT PASSED" + ansible.builtin.debug: + msg: "Reachable + healthy (HTTP {{ pre_health.status }}). Proceeding to update." + +- name: "UPDATE — pull latest image + recreate, then confirm back up" + hosts: "{{ target | default('racknerd1') }}" + gather_facts: false + vars: + deploy_user: ansible + homarr_dir: "/home/{{ deploy_user }}/docker/homarr" + homarr_port: 7575 + 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 in this session + ansible.builtin.meta: reset_connection + + - name: Gather facts (after connection reset) + ansible.builtin.setup: + + - name: Record image digest before update + ansible.builtin.command: + cmd: docker inspect --format '{{ '{{' }}.Image{{ '}}' }}' homarr + register: img_before + changed_when: false + failed_when: false + + - name: Pull latest image + recreate (compose preserved as-is) + community.docker.docker_compose_v2: + project_src: "{{ homarr_dir }}" + pull: always + state: present + recreate: auto + + - name: Record image digest after update + ansible.builtin.command: + cmd: docker inspect --format '{{ '{{' }}.Image{{ '}}' }}' homarr + register: img_after + changed_when: false + + - name: "POST-CHECK — Homarr back up + reachable?" + ansible.builtin.uri: + url: "http://127.0.0.1:{{ homarr_port }}/" + status_code: [200, 302, 307] + register: post_health + retries: 18 + delay: 5 + until: post_health.status is defined and post_health.status in [200, 302, 307] + + - name: Prune the now-dangling old image (best effort) + ansible.builtin.command: docker image prune -f + register: prune + changed_when: "'Total reclaimed space: 0B' not in prune.stdout" + failed_when: false + + - name: Result + ansible.builtin.debug: + msg: >- + Homarr on {{ inventory_hostname }}:{{ homarr_port }} back up — HTTP {{ post_health.status }}. + {{ 'Image updated.' if img_before.stdout != img_after.stdout else 'Already up to date (no image change).' }} + +- name: "Cleanup runner temp key" + hosts: localhost + gather_facts: false + tasks: + - name: "remove temp deploy key from runner" + ansible.builtin.file: + path: /tmp/homarr_deploy_key + state: absent