Add bootstrap playbook + Homarr deploy (ansible user, Infisical secrets)

This commit is contained in:
Samuel James 2026-08-26 10:29:44 -04:00
parent 4c337f6d08
commit 998e0cbad5
3 changed files with 184 additions and 34 deletions

View File

@ -1,18 +1,26 @@
# Semaphore deployment inventory
# Hosts reached over the NetBird mesh (Semaphore LXC .99 is on the mesh).
# Reached over the NetBird mesh (Semaphore LXC .99 is on the mesh).
#
# Two connection styles:
# - bootstrap.yml connects as root (ansible_user=root) using ANSIBLE_BECOME_ROOT_PASS
# - post-bootstrap playbooks connect as the `ansible` user via the deploy SSH key
#
# Semaphore injects the SSH private key (SSH_PRIVATE_KEY) and passwords from Infisical.
all:
vars:
ansible_python_interpreter: /usr/bin/python3
children:
vps:
hosts:
racknerd1:
ansible_host: 100.96.198.46 # NetBird mesh IP (public: 172.245.72.108)
ansible_user: root
ansible_host: 100.96.198.46 # mesh IP (public: 172.245.72.108)
racknerd2:
ansible_host: 100.96.217.250
ansible_user: root
racknerd3:
ansible_host: 67.215.255.203
ansible_user: root
vars:
# default connection user AFTER bootstrap; override to root for bootstrap.yml
ansible_user: "{{ connect_user | default('ansible') }}"
dashboards:
hosts:
racknerd1:

126
playbooks/bootstrap.yml Normal file
View File

@ -0,0 +1,126 @@
---
# ============================================================================
# bootstrap.yml — prepare a fresh VPS/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
#
# 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
#
# Usage (Semaphore Task Template):
# playbook: playbooks/bootstrap.yml
# inventory: inventory/hosts.yml
# limit: racknerd1
# Extra vars (optional): cleanup_paths='["/opt/old-thing","/opt/nuclio"]'
# ============================================================================
- name: Bootstrap managed host
hosts: "{{ target | default('racknerd1') }}"
become: true
gather_facts: 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: []
tasks:
# ---------- STEP 1: connection test ----------
- name: "[1] Connection test — confirm we can reach the host and run as root"
ansible.builtin.command: id
register: idcheck
changed_when: false
- 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"
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"
ansible.builtin.user:
name: "{{ managed_user }}"
password: "{{ ansible_user_password | password_hash('sha512') }}"
shell: /bin/bash
groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}"
append: true
create_home: true
state: present
- name: "[4] Ensure ~/.ssh exists for {{ managed_user }}"
ansible.builtin.file:
path: "/home/{{ managed_user }}/.ssh"
state: directory
owner: "{{ managed_user }}"
group: "{{ managed_user }}"
mode: "0700"
- name: "[4] Install public key to {{ managed_user }} authorized_keys"
ansible.posix.authorized_key:
user: "{{ managed_user }}"
key: "{{ managed_ssh_pubkey }}"
state: present
exclusive: false
- name: "[3] Allow passwordless-or-password sudo 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"
ansible.builtin.command: "id {{ managed_user }}"
register: userverify
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"
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 }}'."

View File

@ -1,45 +1,61 @@
---
# Deploy Homarr dashboard via Docker Compose.
# Usage (Semaphore Task Template): playbook = playbooks/deploy-homarr.yml, inventory = inventory/hosts.yml, limit = racknerd1
# ============================================================================
# deploy-homarr.yml — deploy Homarr dashboard 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.
#
# REQUIRED extra var (set in Semaphore -> Variable Groups / Survey, NOT committed):
# homarr_secret_encryption_key : 64-char hex (openssl rand -hex 32). Homarr v1 refuses to start without it.
- name: Deploy Homarr
hosts: racknerd1
become: true
# Secret from Infisical (Semaphore env injection):
# HOMARR_SECRET_ENCRYPTION_KEY - 64-char hex (openssl rand -hex 32)
#
# Usage (Semaphore Task Template):
# playbook: playbooks/deploy-homarr.yml
# inventory: inventory/hosts.yml
# limit: racknerd1
# ============================================================================
- name: Deploy Homarr (as ansible user)
hosts: "{{ target | default('racknerd1') }}"
become: true # sudo for docker
gather_facts: true
vars:
homarr_dir: /opt/homarr
deploy_user: ansible
homarr_dir: "/home/{{ deploy_user }}/docker/homarr"
homarr_port: 7575
homarr_image: ghcr.io/homarr-labs/homarr:latest
# homarr_secret_encryption_key: "<injected by Semaphore>"
tasks:
- name: Ensure Docker is present
ansible.builtin.command: docker version
register: docker_check
changed_when: false
failed_when: docker_check.rc != 0
homarr_encryption_key: "{{ lookup('env', 'HOMARR_SECRET_ENCRYPTION_KEY') }}"
- name: Fail early if encryption key not provided
tasks:
- name: Validate encryption key present (64-hex)
ansible.builtin.assert:
that:
- homarr_secret_encryption_key is defined
- homarr_secret_encryption_key | length == 64
fail_msg: >-
homarr_secret_encryption_key must be a 64-char hex string.
Generate with `openssl rand -hex 32` and set it as a Semaphore variable.
- homarr_encryption_key | length == 64
fail_msg: "HOMARR_SECRET_ENCRYPTION_KEY must be 64 hex chars (from Infisical)."
- name: Create Homarr directories
- name: Confirm Docker is 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 }})
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0755"
loop:
- "/home/{{ deploy_user }}/docker"
- "{{ homarr_dir }}"
- "{{ homarr_dir }}/appdata"
- name: Write docker-compose.yml
ansible.builtin.copy:
dest: "{{ homarr_dir }}/docker-compose.yml"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0644"
content: |
services:
@ -51,7 +67,7 @@
- /var/run/docker.sock:/var/run/docker.sock
- {{ homarr_dir }}/appdata:/appdata
environment:
- SECRET_ENCRYPTION_KEY={{ homarr_secret_encryption_key }}
- SECRET_ENCRYPTION_KEY={{ homarr_encryption_key }}
ports:
- "{{ homarr_port }}:7575"
@ -60,17 +76,17 @@
project_src: "{{ homarr_dir }}"
pull: always
state: present
register: compose_result
register: compose_up
- name: Wait for Homarr to answer
- name: Wait for Homarr to respond
ansible.builtin.uri:
url: "http://127.0.0.1:{{ homarr_port }}/"
status_code: [200, 302, 307]
register: health
retries: 12
retries: 15
delay: 5
until: health.status is defined
until: health.status is defined and health.status in [200, 302, 307]
- name: Report
- name: Result
ansible.builtin.debug:
msg: "Homarr is up on {{ inventory_hostname }}:{{ homarr_port }} (status {{ health.status | default('n/a') }})"
msg: "Homarr up on {{ inventory_hostname }}:{{ homarr_port }} (HTTP {{ health.status }})"