diff --git a/.github/workflows/ansible-deploy.yaml b/.github/workflows/ansible-deploy.yaml new file mode 100644 index 0000000..bf18540 --- /dev/null +++ b/.github/workflows/ansible-deploy.yaml @@ -0,0 +1,44 @@ +--- +name: Deploy Ansible configuration +on: + push: + branches: [main] + paths: [ansible/**] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: ansible-deploy + cancel-in-progress: false +jobs: + ansible-playbook: + environment: ansible + name: Run the playbook against all hosts + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + cache-dependency-glob: uv.lock + activate-environment: true + - name: Install dependencies + run: uv sync --frozen --only-group ansible + - name: Configure SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + run: |- + install -d -m 0700 "$HOME/.ssh" + printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/id_ed25519" + chmod 0600 "$HOME/.ssh/id_ed25519" + cat ansible/known_hosts >> "$HOME/.ssh/known_hosts" + - name: Run the playbook + working-directory: ansible + env: + ANSIBLE_REMOTE_USER: ci + ANSIBLE_BECOME_ASK_PASS: 'false' + run: uv run ansible-playbook --diff playbook.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d8482d8..6241b18 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -27,3 +27,29 @@ jobs: run: uv sync - name: Run prek run: uv run prek run --all-files + ansible-lint: + name: Ansible lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: pyproject.toml + - name: Sync dependencies + run: uv sync --frozen --only-group ansible + - name: Run ansible-lint + working-directory: ansible + run: uv run ansible-lint + - name: Run playbook syntax check + working-directory: ansible + env: + ANSIBLE_BECOME_ASK_PASS: 'false' + run: uv run ansible-playbook --syntax-check playbook.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..447be10 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ansible/vault_password diff --git a/README.md b/README.md index eafbb33..3ac4c1a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # infrastructure Our infrastructure + +## Documentation + +- [Ansible](docs/ansible.md) — server configuration, CI deploys, bootstrapping diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index b6ee802..a282d83 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -1,6 +1,5 @@ [defaults] inventory = inventory/hosts.yaml -vault_password_file = vault_password [privilege_escalation] become = yes diff --git a/ansible/known_hosts b/ansible/known_hosts new file mode 100644 index 0000000..404bbc3 --- /dev/null +++ b/ansible/known_hosts @@ -0,0 +1,2 @@ +# microwave.box.letsbuilda.dev:22 SSH-2.0-OpenSSH_10.2p1 Ubuntu-2ubuntu3.5 +microwave.box.letsbuilda.dev ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA19eLgakX4Y69JrGaMetJlg4YmTBC7KoYvhnGiX83Iz diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml index a0eb4be..8c2d9b3 100644 --- a/ansible/playbook.yaml +++ b/ansible/playbook.yaml @@ -1,4 +1,7 @@ --- +- name: Manage users + hosts: all + roles: [users] - name: Run common setup hosts: all roles: [common] diff --git a/ansible/roles/users/files/ci-sudoers b/ansible/roles/users/files/ci-sudoers new file mode 100644 index 0000000..31ff177 --- /dev/null +++ b/ansible/roles/users/files/ci-sudoers @@ -0,0 +1 @@ +ci ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/ansible/roles/users/files/ci_ed25519.pub b/ansible/roles/users/files/ci_ed25519.pub new file mode 100644 index 0000000..c122ba0 --- /dev/null +++ b/ansible/roles/users/files/ci_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL5Qm8eF6Sqdu+sOKMimDAWIJVlgmgyBQQhcOqapHmMI ci@letsbuilda/infrastructure diff --git a/ansible/roles/users/tasks/main.yaml b/ansible/roles/users/tasks/main.yaml new file mode 100644 index 0000000..77bfb17 --- /dev/null +++ b/ansible/roles/users/tasks/main.yaml @@ -0,0 +1,32 @@ +--- +- name: Create the ci automation user + ansible.builtin.user: + name: ci + shell: /bin/bash + create_home: true + password_lock: true +- name: Authorize the CI deploy key for the ci user + ansible.posix.authorized_key: + user: ci + key: "{{ lookup('file', 'ci_ed25519.pub') }}" + exclusive: true +- name: Grant the ci user passwordless sudo + ansible.builtin.copy: + src: ci-sudoers + dest: /etc/sudoers.d/ci + owner: root + group: root + mode: '0440' + validate: /usr/sbin/visudo -cf %s +- name: Create the docker group + ansible.builtin.group: + name: docker + system: true +- name: Create the letsbuilda service user + ansible.builtin.user: + name: letsbuilda + system: true + shell: /usr/sbin/nologin + create_home: true + groups: [docker] + append: true diff --git a/docs/ansible.md b/docs/ansible.md new file mode 100644 index 0000000..d38b993 --- /dev/null +++ b/docs/ansible.md @@ -0,0 +1,42 @@ +# Ansible + +Server configuration lives in `ansible/`. All commands below run from that directory. + +## Running locally + +``` +uv sync --group ansible +uv run ansible-playbook --diff playbook.yaml +``` + +You connect as your own user and are prompted for your sudo (BECOME) password. + +## CI deploys + +Pushes to `main` that touch `ansible/**` (and manual `workflow_dispatch` runs) run the +playbook from GitHub Actions via the `ansible` GitHub Environment. CI connects as the +dedicated `ci` user (created by the `users` role) using the `SSH_PRIVATE_KEY` +environment secret, with passwordless sudo granted by `/etc/sudoers.d/ci`. The host +key is pinned in `ansible/known_hosts`, keeping strict host key checking enabled. + +## Bootstrapping / key rotation + +The `ci` user only exists after the playbook has run once, so a new host needs one +local run (as yourself, with your sudo password) before CI can deploy: + +1. Generate the CI keypair: + `ssh-keygen -t ed25519 -N '' -C 'ci@letsbuilda/infrastructure' -f ./ci_ed25519` +2. Put the public key in `ansible/roles/users/files/ci_ed25519.pub`. +3. Pin the host key: run `ssh-keyscan -t ed25519 microwave.box.letsbuilda.dev` from a + trusted network, verify the fingerprint out-of-band, and add the output to + `ansible/known_hosts`. +4. Add the private key as the `SSH_PRIVATE_KEY` secret in the `ansible` GitHub + Environment (restrict the environment's deployment branches to `main`). +5. Run the playbook locally once to create the `ci` user, then verify the CI auth + path: + `ssh -i ./ci_ed25519 -o UserKnownHostsFile=ansible/known_hosts ci@microwave.box.letsbuilda.dev 'sudo -n true && echo sudo-ok'` +6. Shred the local private key copy. + +To rotate the CI key: repeat with a new keypair, replacing the committed public key +and the `SSH_PRIVATE_KEY` secret, then run the playbook once (locally or via the old +key). If the host is reinstalled, refresh `ansible/known_hosts` the same way as step 3.