176 lines
7.6 KiB
YAML
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 }}'."
|