Add Infisical precheck (fail-fast) to bootstrap + homarr; add ansible.posix + infisical.vault reqs
This commit is contained in:
parent
998e0cbad5
commit
36215b6c51
@ -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 }}'."
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user