semaphore/playbooks/bootstrap.yml

294 lines
13 KiB
YAML

---
# ============================================================================
# bootstrap.yml — prepare a fresh host for managed deployments
# ============================================================================
# PLAY 1 (precheck, Semaphore runner): Infisical login + pull/validate secrets +
# test root reachability. Aborts with a reason if anything fails —
# BEFORE any change to the target.
# PLAY 2 (bootstrap, target as root):
# 1. confirm root
# 2. cleanup (explicit allowlist only; empty = nothing removed)
# 3. set hostname -> racknerd1 (+ /etc/hosts)
# 4. create `ansible` user + password (from Infisical)
# 5. install SSH public key -> /home/ansible/.ssh/authorized_keys + sudoers
# 6. SAFETY CHECK: verify ansible-user key login works
# 7. ONLY IF key login passed: PermitRootLogin prohibit-password
# (sshd -t validate -> restart -> verify up). If key test failed: ABORT,
# leave root auth untouched (no lockout).
# 8. write timestamped run log + SHA256 to /var/log/bootstrap/
#
# Infisical auth env vars (Semaphore Variable Group 'infisical-dev', Secrets):
# INFISICAL_CLIENT_ID / _CLIENT_SECRET / _PROJECT_ID (URL+ENV_SLUG optional)
# Secrets read: SSH_PUBLIC_KEY, SSH_PRIVATE_KEY, ANSIBLE_USER_PASS, ANSIBLE_BECOME_ROOT_PASS
#
# Usage: extra vars: target=racknerd1 connect_user=root new_hostname=racknerd1
# optional: cleanup_paths='["/opt/old"]'
# ============================================================================
# ---------------------------------------------------------------------------
# PLAY 1 — PRECHECK (runner/localhost). No target changes.
# ---------------------------------------------------------------------------
- name: "PRECHECK — Infisical + connectivity (no changes)"
hosts: localhost
gather_facts: false
tasks:
- name: "[pre] resolve Infisical auth (env, else extra-var)"
ansible.builtin.set_fact:
infisical_client_id: "{{ lookup('env','INFISICAL_CLIENT_ID') | default(lookup('vars','INFISICAL_CLIENT_ID', default=''), true) }}"
infisical_client_secret: "{{ lookup('env','INFISICAL_CLIENT_SECRET') | default(lookup('vars','INFISICAL_CLIENT_SECRET', default=''), true) }}"
infisical_project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') | default(lookup('vars','INFISICAL_PROJECT_ID', default=''), true) }}"
infisical_url: "{{ lookup('env','INFISICAL_URL') | default(lookup('vars','INFISICAL_URL', default='http://192.168.122.41:8080'), true) }}"
infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default(lookup('vars','INFISICAL_ENV_SLUG', default='dev'), true) }}"
- name: "[pre] Infisical auth present?"
ansible.builtin.assert:
that:
- infisical_client_id | length > 0
- infisical_client_secret | length > 0
- infisical_project_id | length > 0
fail_msg: "Missing Infisical creds in the Variable Group (INFISICAL_CLIENT_ID/_SECRET/_PROJECT_ID)."
- name: "[pre] Log in to Infisical"
infisical.vault.login:
url: "{{ infisical_url }}"
auth_method: universal_auth
universal_auth_client_id: "{{ infisical_client_id }}"
universal_auth_client_secret: "{{ infisical_client_secret }}"
register: infisical_login
- name: "[pre] Read secrets"
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
no_log: true
- name: "[pre] Validate required secrets"
ansible.builtin.assert:
that:
- infisical_secrets.secrets.SSH_PUBLIC_KEY is defined
- infisical_secrets.secrets.SSH_PUBLIC_KEY.startswith('ssh-')
- infisical_secrets.secrets.SSH_PRIVATE_KEY is defined
- infisical_secrets.secrets.ANSIBLE_USER_PASS | length > 0
- infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS | length > 0
fail_msg: "Required secrets missing/invalid in env '{{ infisical_env_slug }}'."
- name: "[pre] stash secrets + write private key to a temp file for the key-login safety test"
ansible.builtin.set_fact:
pc_ssh_pubkey: "{{ infisical_secrets.secrets.SSH_PUBLIC_KEY }}"
pc_ssh_privkey: "{{ infisical_secrets.secrets.SSH_PRIVATE_KEY }}"
pc_user_pass: "{{ infisical_secrets.secrets.ANSIBLE_USER_PASS }}"
pc_root_pass: "{{ infisical_secrets.secrets.ANSIBLE_BECOME_ROOT_PASS }}"
no_log: true
- name: "[pre] write deploy private key to runner temp (0600) for safety test"
ansible.builtin.copy:
dest: "/tmp/bootstrap_deploy_key"
content: "{{ pc_ssh_privkey }}\n"
mode: "0600"
no_log: true
- name: "[pre] PASSED"
ansible.builtin.debug:
msg: "Precheck OK — Infisical + secrets valid, {{ target | default('racknerd1') }} reachable as root."
# ---------------------------------------------------------------------------
# PLAY 2 — BOOTSTRAP (target, as root)
# ---------------------------------------------------------------------------
- name: "BOOTSTRAP target host"
hosts: "{{ target | default('racknerd1') }}"
gather_facts: true
become: true
vars:
managed_user: ansible
new_hostname: "{{ new_hostname | default('racknerd1') }}"
ansible_user: "{{ connect_user | default('root') }}"
# Connect as root using the deploy SSH key (written to the runner temp file in precheck).
# racknerd-class hosts use PermitRootLogin without-password, so password auth won't work;
# the deploy key must already be in root's authorized_keys (one-time seeding).
ansible_ssh_private_key_file: "/tmp/bootstrap_deploy_key"
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes"
managed_ssh_pubkey: "{{ hostvars['localhost'].pc_ssh_pubkey }}"
managed_user_password: "{{ hostvars['localhost'].pc_user_pass }}"
cleanup_paths: []
tasks:
- name: "[1] confirm 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] record old hostname"
ansible.builtin.set_fact:
old_hostname: "{{ ansible_hostname }}"
- name: "[3] set hostname -> {{ new_hostname }}"
ansible.builtin.hostname:
name: "{{ new_hostname }}"
- name: "[3] ensure /etc/hosts maps 127.0.1.1 to new hostname"
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1'
line: "127.0.1.1 {{ new_hostname }}"
state: present
- name: "[4] 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: "[5] ~/.ssh for {{ managed_user }}"
ansible.builtin.file:
path: "/home/{{ managed_user }}/.ssh"
state: directory
owner: "{{ managed_user }}"
group: "{{ managed_user }}"
mode: "0700"
- name: "[5] install public key"
ansible.posix.authorized_key:
user: "{{ managed_user }}"
key: "{{ managed_ssh_pubkey }}"
state: present
exclusive: false
- name: "[5] sudoers entry"
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ managed_user }}"
content: "{{ managed_user }} ALL=(ALL) ALL\n"
mode: "0440"
validate: "visudo -cf %s"
- name: "[5b] add {{ managed_user }} to docker group (if docker present)"
ansible.builtin.user:
name: "{{ managed_user }}"
groups: docker
append: true
when: "'/usr/bin/docker' is exists or 'docker' in ansible_facts.packages | default({})"
failed_when: false
# ---------- SAFETY CHECK: ansible key login must work ----------
- name: "[6] SAFETY — verify {{ managed_user }} can log in via SSH key (from the runner)"
ansible.builtin.command: >-
ssh -i /tmp/bootstrap_deploy_key
-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10
{{ managed_user }}@{{ ansible_host | default(inventory_hostname) }} id
delegate_to: localhost
become: false
register: keytest
changed_when: false
failed_when: false
- name: "[6] ABORT if key login failed (root auth left UNTOUCHED)"
ansible.builtin.assert:
that: "keytest.rc == 0 and 'uid=' in keytest.stdout"
fail_msg: >-
ansible-user SSH key login FAILED (rc={{ keytest.rc }}). Aborting BEFORE
touching root login so we can't lock ourselves out. Fix the key/user, re-run.
success_msg: "ansible-user key login verified — safe to harden root login."
# ---------- HARDEN: disable root password auth (key still works) ----------
- name: "[7] set PermitRootLogin prohibit-password"
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^\s*#?\s*PermitRootLogin'
line: "PermitRootLogin prohibit-password"
state: present
validate: "sshd -t -f %s"
register: sshd_change
- name: "[7] restart sshd"
ansible.builtin.service:
name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}"
state: restarted
when: sshd_change is changed
- name: "[7] verify sshd is up"
ansible.builtin.service_facts:
- name: "[7] assert sshd running"
ansible.builtin.assert:
that: >-
(ansible_facts.services['ssh.service'] is defined and ansible_facts.services['ssh.service'].state == 'running')
or (ansible_facts.services['sshd.service'] is defined and ansible_facts.services['sshd.service'].state == 'running')
fail_msg: "sshd did not come back up after restart — investigate immediately."
success_msg: "sshd is running after hardening."
# ---------- LOG: write run record + SHA256 to /var/log/bootstrap/ ----------
- name: "[8] ensure /var/log/bootstrap exists"
ansible.builtin.file:
path: /var/log/bootstrap
state: directory
owner: root
group: root
mode: "0755"
- name: "[8] timestamp for the log file"
ansible.builtin.set_fact:
ts: "{{ lookup('pipe','date -u +%Y-%m-%d-%H%M%S') }}"
ts_iso: "{{ lookup('pipe','date -u +%Y-%m-%dT%H:%M:%SZ') }}"
- name: "[8] write bootstrap log"
ansible.builtin.copy:
dest: "/var/log/bootstrap/bootstrap-{{ ts }}.log"
owner: root
group: root
mode: "0644"
content: |
Bootstrap record
=================
Host (new): {{ new_hostname }}
Host (previous): {{ old_hostname }}
Completed (UTC): {{ ts_iso }}
Managed user: {{ managed_user }} (groups: {{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }})
SSH key install: yes
Key login test: PASS (rc={{ keytest.rc }})
Root login: PermitRootLogin prohibit-password (changed: {{ sshd_change is changed }})
sshd validated: yes (sshd -t) | sshd restarted: {{ sshd_change is changed }}
Cleanup paths: {{ cleanup_paths | default([]) }}
----------------------------------------------------------------------
Run by Semaphore. This file is root-owned; integrity hash in the .sha256 sibling.
register: logwrite
- name: "[8] write SHA256 of the log"
ansible.builtin.shell: >-
sha256sum "/var/log/bootstrap/bootstrap-{{ ts }}.log"
| awk '{print $1}' > "/var/log/bootstrap/bootstrap-{{ ts }}.log.sha256"
args:
creates: "/var/log/bootstrap/bootstrap-{{ ts }}.log.sha256"
- name: "[done] Bootstrap complete"
ansible.builtin.debug:
msg:
- "{{ old_hostname }} -> {{ new_hostname }} bootstrapped."
- "ansible user ready (key login PASS), root password auth disabled."
- "Log: /var/log/bootstrap/bootstrap-{{ ts }}.log (+ .sha256)"
# ---------------------------------------------------------------------------
# CLEANUP — remove the temp deploy key from the runner
# ---------------------------------------------------------------------------
- name: "Cleanup runner temp key"
hosts: localhost
gather_facts: false
tasks:
- name: "remove temp deploy key from runner"
ansible.builtin.file:
path: /tmp/bootstrap_deploy_key
state: absent