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:
# 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 }}'."

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`
# 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]

View File

@ -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).