semaphore/playbooks/bootstrap.yml

176 lines
7.6 KiB
YAML

---
# ============================================================================
# bootstrap.yml — prepare a fresh host for managed deployments
# ============================================================================
# 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.
#
# 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
# extra vars: target=racknerd1 connect_user=root
# (optional) cleanup_paths='["/opt/old-thing"]'
# ============================================================================
# ---------------------------------------------------------------------------
# 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') }}"
gather_facts: true
become: true
vars:
managed_user: ansible
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:
- name: "[1] Connection confirmed as root"
ansible.builtin.command: id
register: idc
changed_when: false
failed_when: "'uid=0' not in idc.stdout"
- name: "[2] Cleanup — remove ONLY explicitly-listed paths"
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop: "{{ cleanup_paths | default([]) }}"
when: cleanup_paths | length > 0
- name: "[3] Create '{{ managed_user }}' user"
ansible.builtin.user:
name: "{{ managed_user }}"
password: "{{ managed_user_password | password_hash('sha512') }}"
shell: /bin/bash
groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}"
append: true
create_home: true
no_log: true
- name: "[4] Ensure ~/.ssh 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] 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"
- name: "[5] Verify user + key"
ansible.builtin.command: "id {{ managed_user }}"
register: uv
changed_when: false
- name: "[5] Bootstrap complete"
ansible.builtin.debug:
msg: "{{ inventory_hostname }} ready — {{ uv.stdout }}. Deploys can now run as '{{ managed_user }}'."