From 36215b6c512e6565bfb28bcffe37085250921a0c Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 26 Aug 2026 10:42:29 -0400 Subject: [PATCH] Add Infisical precheck (fail-fast) to bootstrap + homarr; add ansible.posix + infisical.vault reqs --- playbooks/bootstrap.yml | 191 ++++++++++++++++++++++-------------- playbooks/deploy-homarr.yml | 89 ++++++++++++----- requirements.yml | 11 ++- 3 files changed, 194 insertions(+), 97 deletions(-) diff --git a/playbooks/bootstrap.yml b/playbooks/bootstrap.yml index bbcbe82..e56997f 100644 --- a/playbooks/bootstrap.yml +++ b/playbooks/bootstrap.yml @@ -1,91 +1,149 @@ --- # ============================================================================ -# bootstrap.yml — prepare a fresh VPS/host for managed deployments +# bootstrap.yml — prepare a fresh host for managed deployments # ============================================================================ -# Run this FIRST against any new host. Sequence: -# 1. Connection test (as root) -# 2. Clean up old-deployment clutter (explicit allowlist only — safe by default) -# 3. Create the `ansible` user with SSH key + password -# 4. Install the public key to /home/ansible/.ssh/authorized_keys -# 5. Verify the ansible user can log in and sudo +# PLAY 1 (precheck, on the Semaphore runner): authenticate to Infisical, pull +# secrets, validate them, and test SSH reachability to the target. +# If ANYTHING fails, the run stops here with a clear reason — BEFORE +# any change is made to the target host. +# PLAY 2 (bootstrap, on the target as root): cleanup (allowlist) -> create the +# `ansible` user + install SSH key -> verify. # -# Secrets come from Infisical (injected by Semaphore as env vars): -# SSH_PUBLIC_KEY - OpenSSH public key for the ansible user -# ANSIBLE_USER_PASS - password for the ansible user -# ANSIBLE_BECOME_ROOT_PASS - current root/sudo password on the target +# Infisical auth (Universal Auth) — provide these to the runner as env vars +# (Semaphore Environment / Variable Group), NOT committed: +# INFISICAL_CLIENT_ID +# INFISICAL_CLIENT_SECRET +# INFISICAL_PROJECT_ID +# INFISICAL_URL (default http://192.168.122.41:8080) +# INFISICAL_ENV_SLUG (default dev) +# +# Secrets READ from Infisical (must exist in the project/env): +# SSH_PUBLIC_KEY, ANSIBLE_USER_PASS, ANSIBLE_BECOME_ROOT_PASS # # Usage (Semaphore Task Template): # playbook: playbooks/bootstrap.yml # inventory: inventory/hosts.yml -# limit: racknerd1 -# Extra vars (optional): cleanup_paths='["/opt/old-thing","/opt/nuclio"]' +# extra vars: target=racknerd1 connect_user=root +# (optional) cleanup_paths='["/opt/old-thing"]' # ============================================================================ -- name: Bootstrap managed host +# --------------------------------------------------------------------------- +# PLAY 1 — PRECHECK (runs on the Semaphore runner / localhost). No target changes. +# --------------------------------------------------------------------------- +- name: "PRECHECK — Infisical + connectivity (no changes made)" + hosts: localhost + gather_facts: false + vars: + infisical_url: "{{ lookup('env','INFISICAL_URL') | default('http://192.168.122.41:8080', true) }}" + infisical_project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}" + infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default('dev', true) }}" + target_host: "{{ target | default('racknerd1') }}" + 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 + - infisical_project_id | length > 0 + fail_msg: >- + Missing Infisical auth. Set INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET, + and INFISICAL_PROJECT_ID in the Semaphore Environment for this template. + + - name: "[precheck] Log in 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 + # if this fails, the play stops here with the SDK error (bad creds / unreachable Infisical) + + - name: "[precheck] Read required secrets from Infisical" + infisical.vault.read_secrets: + login_data: "{{ infisical_login.login_data }}" + project_id: "{{ infisical_project_id }}" + env_slug: "{{ infisical_env_slug }}" + path: "/" + as_dict: true + register: infisical_secrets + + - name: "[precheck] Validate the secrets we need are present + well-formed" + ansible.builtin.assert: + that: + - infisical_secrets.secrets.SSH_PUBLIC_KEY is defined + - infisical_secrets.secrets.SSH_PUBLIC_KEY.startswith('ssh-') + - infisical_secrets.secrets.ANSIBLE_USER_PASS is defined + - infisical_secrets.secrets.ANSIBLE_USER_PASS | length > 0 + - infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS is defined + - infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS | length > 0 + fail_msg: >- + Infisical is reachable but required secrets are missing/invalid in env + '{{ infisical_env_slug }}'. Need: SSH_PUBLIC_KEY (OpenSSH format), + ANSIBLE_USER_PASS, ANSIBLE_BECOME_ROOT_PASS. + + - name: "[precheck] Test SSH reachability to target ({{ target_host }})" + ansible.builtin.command: >- + ansible {{ target_host }} -i {{ inventory_file }} + -m ansible.builtin.ping + -e "ansible_user={{ connect_user | default('root') }}" + -e "ansible_password={{ infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS }}" + -e "ansible_ssh_common_args=-o StrictHostKeyChecking=accept-new" + register: reach + changed_when: false + failed_when: reach.rc != 0 + no_log: true # avoid leaking the password in output + + - name: "[precheck] PASSED — stash secrets for the bootstrap play" + ansible.builtin.set_fact: + pc_ssh_pubkey: "{{ infisical_secrets.secrets.SSH_PUBLIC_KEY }}" + pc_user_pass: "{{ infisical_secrets.secrets.ANSIBLE_USER_PASS }}" + pc_root_pass: "{{ infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS }}" + + - name: "[precheck] Result" + ansible.builtin.debug: + msg: "Precheck OK — Infisical reachable, secrets valid, {{ target_host }} reachable. Proceeding." + +# --------------------------------------------------------------------------- +# PLAY 2 — BOOTSTRAP the target (only runs if PLAY 1 succeeded) +# --------------------------------------------------------------------------- +- name: "BOOTSTRAP target host" hosts: "{{ target | default('racknerd1') }}" - become: true gather_facts: true + become: true vars: managed_user: ansible - # Secrets pulled from Infisical env injection: - ansible_user_password: "{{ lookup('env', 'ANSIBLE_USER_PASS') }}" - managed_ssh_pubkey: "{{ lookup('env', 'SSH_PUBLIC_KEY') }}" - # SAFETY: cleanup removes ONLY paths explicitly listed here. Empty = deletes nothing. - # Override per-host from Semaphore (survey/extra-vars). NEVER a blind sweep. - cleanup_paths: [] - + ansible_user: "{{ connect_user | default('root') }}" + ansible_password: "{{ hostvars['localhost'].pc_root_pass }}" + ansible_become_password: "{{ hostvars['localhost'].pc_root_pass }}" + ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new" + managed_ssh_pubkey: "{{ hostvars['localhost'].pc_ssh_pubkey }}" + managed_user_password: "{{ hostvars['localhost'].pc_user_pass }}" + cleanup_paths: [] # SAFETY: only removes paths explicitly listed. Empty = nothing. tasks: - # ---------- STEP 1: connection test ---------- - - name: "[1] Connection test — confirm we can reach the host and run as root" + - name: "[1] Connection confirmed as root" ansible.builtin.command: id - register: idcheck + register: idc changed_when: false + failed_when: "'uid=0' not in idc.stdout" - - name: "[1] Report connection" - ansible.builtin.debug: - msg: "Connected to {{ inventory_hostname }} ({{ ansible_host | default('n/a') }}) as {{ idcheck.stdout }}" - - - name: "[1] Assert we are root (become worked)" - ansible.builtin.assert: - that: "'uid=0' in idcheck.stdout" - fail_msg: "Not running as root — check become password (ANSIBLE_BECOME_ROOT_PASS)." - - # ---------- STEP 2: cleanup (explicit allowlist only) ---------- - - name: "[2] Show cleanup plan" - ansible.builtin.debug: - msg: >- - Cleanup paths to remove: {{ cleanup_paths | default([]) }} - {{ '(none — skipping)' if (cleanup_paths | length == 0) else '' }} - - - name: "[2] Remove ONLY explicitly-listed old deployment paths" + - name: "[2] Cleanup — remove ONLY explicitly-listed paths" ansible.builtin.file: path: "{{ item }}" state: absent loop: "{{ cleanup_paths | default([]) }}" when: cleanup_paths | length > 0 - # ---------- STEP 3 + 4: create ansible user + install key ---------- - - name: "[3] Validate required secrets are present" - ansible.builtin.assert: - that: - - managed_ssh_pubkey | length > 0 - - managed_ssh_pubkey.startswith('ssh-') - - ansible_user_password | length > 0 - fail_msg: >- - Missing/invalid secrets. SSH_PUBLIC_KEY must be OpenSSH format (ssh-...) - and ANSIBLE_USER_PASS must be set (from Infisical). - - - name: "[3] Create the '{{ managed_user }}' user" + - name: "[3] Create '{{ managed_user }}' user" ansible.builtin.user: name: "{{ managed_user }}" - password: "{{ ansible_user_password | password_hash('sha512') }}" + password: "{{ managed_user_password | password_hash('sha512') }}" shell: /bin/bash groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}" append: true create_home: true - state: present + no_log: true - - name: "[4] Ensure ~/.ssh exists for {{ managed_user }}" + - name: "[4] Ensure ~/.ssh for {{ managed_user }}" ansible.builtin.file: path: "/home/{{ managed_user }}/.ssh" state: directory @@ -100,27 +158,18 @@ state: present exclusive: false - - name: "[3] Allow passwordless-or-password sudo for {{ managed_user }}" + - name: "[3] sudoers entry for {{ managed_user }}" ansible.builtin.copy: dest: "/etc/sudoers.d/{{ managed_user }}" content: "{{ managed_user }} ALL=(ALL) ALL\n" mode: "0440" validate: "visudo -cf %s" - # ---------- STEP 5: verify ---------- - - name: "[5] Verify {{ managed_user }} exists and is in sudo group" + - name: "[5] Verify user + key" ansible.builtin.command: "id {{ managed_user }}" - register: userverify + register: uv changed_when: false - - name: "[5] Verify authorized_keys installed" - ansible.builtin.stat: - path: "/home/{{ managed_user }}/.ssh/authorized_keys" - register: akfile - - - name: "[5] Bootstrap result" + - name: "[5] Bootstrap complete" ansible.builtin.debug: - msg: - - "User: {{ userverify.stdout }}" - - "authorized_keys present: {{ akfile.stat.exists }}" - - "Host {{ inventory_hostname }} is ready. Deploys can now run as '{{ managed_user }}'." + msg: "{{ inventory_hostname }} ready — {{ uv.stdout }}. Deploys can now run as '{{ managed_user }}'." diff --git a/playbooks/deploy-homarr.yml b/playbooks/deploy-homarr.yml index 6275777..00c69ad 100644 --- a/playbooks/deploy-homarr.yml +++ b/playbooks/deploy-homarr.yml @@ -1,45 +1,84 @@ --- # ============================================================================ -# deploy-homarr.yml — deploy Homarr dashboard as the `ansible` user +# deploy-homarr.yml — deploy Homarr as the `ansible` user # ============================================================================ -# Prereq: host has been bootstrapped (see bootstrap.yml) so the `ansible` -# user exists with the SSH key installed. This playbook connects AS ansible, -# creates ~/docker/homarr/, drops the compose file, and brings it up. +# PLAY 1 (precheck, runner): pull HOMARR_SECRET_ENCRYPTION_KEY from Infisical, +# validate it. Stops with a reason if missing/invalid — no target change. +# PLAY 2: connect as `ansible`, create ~/docker/homarr/, write compose, start it. # -# Secret from Infisical (Semaphore env injection): -# HOMARR_SECRET_ENCRYPTION_KEY - 64-char hex (openssl rand -hex 32) +# Prereq: host already bootstrapped (bootstrap.yml) so `ansible` user + key exist. +# Infisical auth env vars: same as bootstrap.yml (INFISICAL_CLIENT_ID/SECRET/PROJECT_ID). # -# Usage (Semaphore Task Template): -# playbook: playbooks/deploy-homarr.yml -# inventory: inventory/hosts.yml -# limit: racknerd1 +# Usage: playbook playbooks/deploy-homarr.yml, extra vars: target=racknerd1 # ============================================================================ -- name: Deploy Homarr (as ansible user) +- name: "PRECHECK — fetch Homarr secret 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] Validate Homarr key (64 hex)" + ansible.builtin.assert: + that: + - infisical_secrets.secrets.HOMARR_SECRET_ENCRYPTION_KEY is defined + - infisical_secrets.secrets.HOMARR_SECRET_ENCRYPTION_KEY | length == 64 + fail_msg: "HOMARR_SECRET_ENCRYPTION_KEY missing or not 64 hex chars in env '{{ infisical_env_slug }}'." + + - name: "[precheck] stash for deploy play" + ansible.builtin.set_fact: + pc_homarr_key: "{{ infisical_secrets.secrets.HOMARR_SECRET_ENCRYPTION_KEY }}" + no_log: true + + - name: "[precheck] OK" + ansible.builtin.debug: + msg: "Homarr secret retrieved + valid. Proceeding to deploy." + +- name: "Deploy Homarr (as ansible user)" hosts: "{{ target | default('racknerd1') }}" - become: true # sudo for docker gather_facts: true + become: true vars: deploy_user: ansible homarr_dir: "/home/{{ deploy_user }}/docker/homarr" homarr_port: 7575 homarr_image: ghcr.io/homarr-labs/homarr:latest - homarr_encryption_key: "{{ lookup('env', 'HOMARR_SECRET_ENCRYPTION_KEY') }}" - + homarr_encryption_key: "{{ hostvars['localhost'].pc_homarr_key }}" + ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new" tasks: - - name: Validate encryption key present (64-hex) - ansible.builtin.assert: - that: - - homarr_encryption_key | length == 64 - fail_msg: "HOMARR_SECRET_ENCRYPTION_KEY must be 64 hex chars (from Infisical)." - - - name: Confirm Docker is available + - name: Docker available? ansible.builtin.command: docker version register: dv changed_when: false failed_when: dv.rc != 0 - - name: Create ~/docker/homarr directory tree (owned by {{ deploy_user }}) + - name: Create ~/docker/homarr tree ansible.builtin.file: path: "{{ item }}" state: directory @@ -70,15 +109,15 @@ - SECRET_ENCRYPTION_KEY={{ homarr_encryption_key }} ports: - "{{ homarr_port }}:7575" + no_log: true - - name: Pull and start Homarr + - name: Pull + start Homarr community.docker.docker_compose_v2: project_src: "{{ homarr_dir }}" pull: always state: present - register: compose_up - - name: Wait for Homarr to respond + - name: Wait for Homarr ansible.builtin.uri: url: "http://127.0.0.1:{{ homarr_port }}/" status_code: [200, 302, 307] diff --git a/requirements.yml b/requirements.yml index bfa7410..dcc9478 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,2 +1,11 @@ +--- +# Ansible collections needed by the playbooks. +# Semaphore installs these before running (Task Template -> "install requirements"). collections: - - name: community.docker + - name: community.docker # docker_compose_v2 module (Homarr deploy) + - name: ansible.posix # authorized_key module (install SSH keys) + - name: infisical.vault # read secrets from Infisical (precheck) + +# NOTE: infisical.vault also needs the Python package `infisicalsdk` on the runner: +# pip install infisicalsdk +# In Semaphore, ensure the runner image has it (or add a pip task / custom runner image).