Add Infisical precheck (fail-fast) to bootstrap + homarr; add ansible.posix + infisical.vault reqs

This commit is contained in:
Samuel James 2026-08-26 10:42:29 -04:00
parent 998e0cbad5
commit 36215b6c51
3 changed files with 194 additions and 97 deletions

View File

@ -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: # PLAY 1 (precheck, on the Semaphore runner): authenticate to Infisical, pull
# 1. Connection test (as root) # secrets, validate them, and test SSH reachability to the target.
# 2. Clean up old-deployment clutter (explicit allowlist only — safe by default) # If ANYTHING fails, the run stops here with a clear reason — BEFORE
# 3. Create the `ansible` user with SSH key + password # any change is made to the target host.
# 4. Install the public key to /home/ansible/.ssh/authorized_keys # PLAY 2 (bootstrap, on the target as root): cleanup (allowlist) -> create the
# 5. Verify the ansible user can log in and sudo # `ansible` user + install SSH key -> verify.
# #
# Secrets come from Infisical (injected by Semaphore as env vars): # Infisical auth (Universal Auth) — provide these to the runner as env vars
# SSH_PUBLIC_KEY - OpenSSH public key for the ansible user # (Semaphore Environment / Variable Group), NOT committed:
# ANSIBLE_USER_PASS - password for the ansible user # INFISICAL_CLIENT_ID
# ANSIBLE_BECOME_ROOT_PASS - current root/sudo password on the target # 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): # Usage (Semaphore Task Template):
# playbook: playbooks/bootstrap.yml # playbook: playbooks/bootstrap.yml
# inventory: inventory/hosts.yml # inventory: inventory/hosts.yml
# limit: racknerd1 # extra vars: target=racknerd1 connect_user=root
# Extra vars (optional): cleanup_paths='["/opt/old-thing","/opt/nuclio"]' # (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') }}" hosts: "{{ target | default('racknerd1') }}"
become: true
gather_facts: true gather_facts: true
become: true
vars: vars:
managed_user: ansible managed_user: ansible
# Secrets pulled from Infisical env injection: ansible_user: "{{ connect_user | default('root') }}"
ansible_user_password: "{{ lookup('env', 'ANSIBLE_USER_PASS') }}" ansible_password: "{{ hostvars['localhost'].pc_root_pass }}"
managed_ssh_pubkey: "{{ lookup('env', 'SSH_PUBLIC_KEY') }}" ansible_become_password: "{{ hostvars['localhost'].pc_root_pass }}"
# SAFETY: cleanup removes ONLY paths explicitly listed here. Empty = deletes nothing. ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"
# Override per-host from Semaphore (survey/extra-vars). NEVER a blind sweep. managed_ssh_pubkey: "{{ hostvars['localhost'].pc_ssh_pubkey }}"
cleanup_paths: [] managed_user_password: "{{ hostvars['localhost'].pc_user_pass }}"
cleanup_paths: [] # SAFETY: only removes paths explicitly listed. Empty = nothing.
tasks: tasks:
# ---------- STEP 1: connection test ---------- - name: "[1] Connection confirmed as root"
- name: "[1] Connection test — confirm we can reach the host and run as root"
ansible.builtin.command: id ansible.builtin.command: id
register: idcheck register: idc
changed_when: false changed_when: false
failed_when: "'uid=0' not in idc.stdout"
- name: "[1] Report connection" - name: "[2] Cleanup — remove ONLY explicitly-listed paths"
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"
ansible.builtin.file: ansible.builtin.file:
path: "{{ item }}" path: "{{ item }}"
state: absent state: absent
loop: "{{ cleanup_paths | default([]) }}" loop: "{{ cleanup_paths | default([]) }}"
when: cleanup_paths | length > 0 when: cleanup_paths | length > 0
# ---------- STEP 3 + 4: create ansible user + install key ---------- - name: "[3] Create '{{ managed_user }}' user"
- 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"
ansible.builtin.user: ansible.builtin.user:
name: "{{ managed_user }}" name: "{{ managed_user }}"
password: "{{ ansible_user_password | password_hash('sha512') }}" password: "{{ managed_user_password | password_hash('sha512') }}"
shell: /bin/bash shell: /bin/bash
groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}" groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}"
append: true append: true
create_home: 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: ansible.builtin.file:
path: "/home/{{ managed_user }}/.ssh" path: "/home/{{ managed_user }}/.ssh"
state: directory state: directory
@ -100,27 +158,18 @@
state: present state: present
exclusive: false exclusive: false
- name: "[3] Allow passwordless-or-password sudo for {{ managed_user }}" - name: "[3] sudoers entry for {{ managed_user }}"
ansible.builtin.copy: ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ managed_user }}" dest: "/etc/sudoers.d/{{ managed_user }}"
content: "{{ managed_user }} ALL=(ALL) ALL\n" content: "{{ managed_user }} ALL=(ALL) ALL\n"
mode: "0440" mode: "0440"
validate: "visudo -cf %s" validate: "visudo -cf %s"
# ---------- STEP 5: verify ---------- - name: "[5] Verify user + key"
- name: "[5] Verify {{ managed_user }} exists and is in sudo group"
ansible.builtin.command: "id {{ managed_user }}" ansible.builtin.command: "id {{ managed_user }}"
register: userverify register: uv
changed_when: false changed_when: false
- name: "[5] Verify authorized_keys installed" - name: "[5] Bootstrap complete"
ansible.builtin.stat:
path: "/home/{{ managed_user }}/.ssh/authorized_keys"
register: akfile
- name: "[5] Bootstrap result"
ansible.builtin.debug: ansible.builtin.debug:
msg: msg: "{{ inventory_hostname }} ready — {{ uv.stdout }}. Deploys can now run as '{{ managed_user }}'."
- "User: {{ userverify.stdout }}"
- "authorized_keys present: {{ akfile.stat.exists }}"
- "Host {{ inventory_hostname }} is ready. Deploys can now run as '{{ managed_user }}'."

View File

@ -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` # PLAY 1 (precheck, runner): pull HOMARR_SECRET_ENCRYPTION_KEY from Infisical,
# user exists with the SSH key installed. This playbook connects AS ansible, # validate it. Stops with a reason if missing/invalid — no target change.
# creates ~/docker/homarr/, drops the compose file, and brings it up. # PLAY 2: connect as `ansible`, create ~/docker/homarr/, write compose, start it.
# #
# Secret from Infisical (Semaphore env injection): # Prereq: host already bootstrapped (bootstrap.yml) so `ansible` user + key exist.
# HOMARR_SECRET_ENCRYPTION_KEY - 64-char hex (openssl rand -hex 32) # Infisical auth env vars: same as bootstrap.yml (INFISICAL_CLIENT_ID/SECRET/PROJECT_ID).
# #
# Usage (Semaphore Task Template): # Usage: playbook playbooks/deploy-homarr.yml, extra vars: target=racknerd1
# playbook: playbooks/deploy-homarr.yml
# inventory: inventory/hosts.yml
# limit: 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') }}" hosts: "{{ target | default('racknerd1') }}"
become: true # sudo for docker
gather_facts: true gather_facts: true
become: true
vars: vars:
deploy_user: ansible deploy_user: ansible
homarr_dir: "/home/{{ deploy_user }}/docker/homarr" homarr_dir: "/home/{{ deploy_user }}/docker/homarr"
homarr_port: 7575 homarr_port: 7575
homarr_image: ghcr.io/homarr-labs/homarr:latest 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: tasks:
- name: Validate encryption key present (64-hex) - name: Docker available?
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
ansible.builtin.command: docker version ansible.builtin.command: docker version
register: dv register: dv
changed_when: false changed_when: false
failed_when: dv.rc != 0 failed_when: dv.rc != 0
- name: Create ~/docker/homarr directory tree (owned by {{ deploy_user }}) - name: Create ~/docker/homarr tree
ansible.builtin.file: ansible.builtin.file:
path: "{{ item }}" path: "{{ item }}"
state: directory state: directory
@ -70,15 +109,15 @@
- SECRET_ENCRYPTION_KEY={{ homarr_encryption_key }} - SECRET_ENCRYPTION_KEY={{ homarr_encryption_key }}
ports: ports:
- "{{ homarr_port }}:7575" - "{{ homarr_port }}:7575"
no_log: true
- name: Pull and start Homarr - name: Pull + start Homarr
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ homarr_dir }}" project_src: "{{ homarr_dir }}"
pull: always pull: always
state: present state: present
register: compose_up
- name: Wait for Homarr to respond - name: Wait for Homarr
ansible.builtin.uri: ansible.builtin.uri:
url: "http://127.0.0.1:{{ homarr_port }}/" url: "http://127.0.0.1:{{ homarr_port }}/"
status_code: [200, 302, 307] status_code: [200, 302, 307]

View File

@ -1,2 +1,11 @@
---
# Ansible collections needed by the playbooks.
# Semaphore installs these before running (Task Template -> "install requirements").
collections: 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).