--- # ============================================================================ # 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 }}'."