blumeops/docs/how-to/add-ansible-role.md
Erich Blume b197bd5f58 Adopt Dagger CI for docs build (Phase 2) (#157)
## Summary

Migrates the docs build pipeline to Dagger (Phase 2 of the Dagger CI adoption plan).

- **Backfill `date-modified` frontmatter** on all 80 docs — Dagger's `--src=.` excludes `.git`, so Quartz can't use git history for page dates. Frontmatter dates work with or without git.
- **New `docs-check-frontmatter` mise task + pre-commit hook** — validates all docs have `title`, `tags`, and `date-modified`
- **New Dagger functions** — `build_changelog` (towncrier in Python container) and `build_docs` (chains changelog → Quartz build in Node container, returns tarball)
- **Simplified CI workflow** — the ~44-line inline Quartz build (clone, npm ci, build, tar, cleanup) is replaced by `dagger call build-docs`. Changelog step remains local on the runner since towncrier needs to modify the host working tree for the git commit.

### Design decisions

- **Towncrier runs twice in CI**: once inside Dagger (for the docs tarball) and once on the runner (for the git commit). This is intentional — Dagger's directory export is additive and can't delete the consumed changelog fragments from the host.
- **Artifact hosting stays on Forgejo Releases** (not migrated to Forgejo Packages as the plan doc originally suggested). That migration can happen independently.
- **`date-modified` frontmatter** preserved even though `build_changelog` installs git — the git there is only for towncrier's `git add` call, not for history. The local iteration story (`dagger call build-docs --src=. --version=dev` with uncommitted changes) depends on frontmatter dates.

### Local iteration

```bash
dagger call build-docs --src=. --version=dev export --path=./docs-dev.tar.gz
tar tf docs-dev.tar.gz | head -20
```

## Deployment and Testing

- [x] `dagger call build-docs --src=. --version=dev` produces valid 1.1MB tarball (149 HTML pages)
- [x] Pre-commit hooks pass (including new `docs-check-frontmatter`)
- [ ] Full `workflow_dispatch` run after merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/157
2026-02-11 16:33:16 -08:00

3.2 KiB

title date-modified tags
Add Ansible Role 2026-02-07
how-to
ansible

Add an Ansible Role

Quick reference for adding a new Ansible role to provision services on indri.

Create Role Structure

ansible/roles/<role>/
├── defaults/main.yml    # Default variables
├── tasks/main.yml       # Task definitions
├── handlers/main.yml    # Handlers (restarts, etc.)
├── templates/           # Jinja2 templates
└── files/               # Static files (optional)

Minimal Role Example

# ansible/roles/<role>/defaults/main.yml
---
role_data_dir: ~/Library/Application Support/<service>
role_port: 8080
# ansible/roles/<role>/tasks/main.yml
---
- name: Ensure data directory exists
  ansible.builtin.file:
    path: "{{ role_data_dir }}"
    state: directory
    mode: '0755'

- name: Deploy configuration
  ansible.builtin.template:
    src: config.j2
    dest: "{{ role_data_dir }}/config"
    mode: '0644'
  notify: Restart service

- name: Deploy LaunchAgent plist
  ansible.builtin.template:
    src: launchagent.plist.j2
    dest: ~/Library/LaunchAgents/mcquack.<service>.plist
    mode: '0644'
  notify: Restart service
# ansible/roles/<role>/handlers/main.yml
---
- name: Restart service
  ansible.builtin.shell: |
    launchctl unload ~/Library/LaunchAgents/mcquack.<service>.plist 2>/dev/null || true
    launchctl load ~/Library/LaunchAgents/mcquack.<service>.plist
  listen: Restart service

Add Role to Playbook

Edit ansible/playbooks/indri.yml:

  roles:
    # ... existing roles ...
    - role: <role>
      tags: [<role>]

Add Secrets (if needed)

If the role needs secrets from 1Password, add pre_tasks:

  pre_tasks:
    # ... existing pre_tasks ...
    - name: Fetch <role> secret
      ansible.builtin.command:
        cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get <item-id> --fields <field> --reveal
      delegate_to: localhost
      register: _role_secret
      changed_when: false
      no_log: true
      check_mode: false
      tags: [<role>]

    - name: Set <role> secret fact
      ansible.builtin.set_fact:
        role_secret_var: "{{ _role_secret.stdout }}"
      no_log: true
      tags: [<role>]

Then use role_secret_var in your role with a guard:

# In role's tasks, fetch if not already set (allows running with --tags)
- name: Fetch secret if not set
  ansible.builtin.command:
    cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get <item-id> --fields <field> --reveal
  delegate_to: localhost
  register: _role_secret
  changed_when: false
  no_log: true
  check_mode: false
  when: role_secret_var is not defined

Test and Deploy

# Dry run
mise run provision-indri -- --tags <role> --check --diff

# Apply
mise run provision-indri -- --tags <role>

# Verify
ssh indri 'launchctl list | grep <service>'

Add Observability (optional)

For metrics collection, create a companion <role>_metrics role that:

  1. Writes metrics to /opt/homebrew/var/node_exporter/textfile/
  2. Runs via a LaunchAgent (cronjob-style)

See alloy for how metrics are collected from textfiles.