Skip to content

Router-Hosts Ansible Role Implementation Plan

Status: Completed Completed: 2025-12-25 PRs: #344, #353

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Create an Ansible role to deploy router-hosts server on Firewalla using Docker Compose with Vault Agent for automated mTLS certificate management.

Architecture: Two-container Docker Compose deployment (vault-agent + router-hosts) with AppRole authentication. Vault Agent fetches certificates from PKI, router-hosts serves gRPC with mTLS. Ansible fetches AppRole credentials at deploy time.

Tech Stack: Ansible 2.14+, Docker Compose, HashiCorp Vault Agent, router-hosts v0.6.0

Reference: docs/plans/2025-12-24-router-hosts-ansible-role-design.md


Task 1: Create Role Directory Structure

Files: - Create: ansible/roles/router-hosts/defaults/main.yml - Create: ansible/roles/router-hosts/meta/main.yml - Create: ansible/roles/router-hosts/handlers/main.yml - Create: ansible/roles/router-hosts/tasks/main.yml

Step 1: Create directory structure

mkdir -p ansible/roles/router-hosts/{defaults,meta,handlers,tasks,templates}

Step 2: Create defaults/main.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# defaults file for router-hosts
#
# This role deploys router-hosts server on Firewalla using Docker Compose
# with Vault Agent for automated mTLS certificate management.
#
# router-hosts is a gRPC server for managing DNS host entries.
# https://github.com/fzymgc-house/router-hosts

# Base directory for config, data, and credentials
# External storage on Firewalla persists across firmware updates
router_hosts_base_dir: "/extdata/router-hosts"

# Container images
router_hosts_image: "ghcr.io/fzymgc-house/router-hosts"
router_hosts_version: "v0.6.0"
router_hosts_vault_image: "hashicorp/vault:1.18"

# Vault configuration
router_hosts_vault_addr: "https://vault.fzymgc.house"
router_hosts_vault_pki_path: "fzymgc-house/v1/ica1/v1"
router_hosts_vault_approle_name: "router-hosts-agent"

# Certificate configuration
# Common name for the server certificate
router_hosts_cert_cn: "router-hosts"

# DNS SANs for the server certificate
router_hosts_cert_dns_sans:
  - "localhost"
  - "router.fzymgc.house"
  - "router"
  - "router.local"

# IP SANs for the server certificate
router_hosts_cert_ip_sans:
  - "127.0.0.1"
  - "192.168.20.1"
  - "fddb:f665:73f7:1::1"
  - "fe80::226d:31ff:fe31:715"

# Certificate TTL (30 days)
router_hosts_cert_ttl: "720h"

# gRPC server configuration
router_hosts_grpc_port: 50051

# Container user (pi user on Firewalla)
router_hosts_user: "pi"

# Firewalla-specific paths
# Docker compose location (Firewalla convention)
router_hosts_docker_compose_dir: "/home/pi/.firewalla/run/docker/router-hosts"

# Boot script location (post_main.d for auto-start)
router_hosts_post_main_dir: "/home/pi/.firewalla/config/post_main.d"

Step 3: Create meta/main.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
galaxy_info:
  author: fzymgc-house
  description: Deploy router-hosts server with Vault Agent mTLS on Firewalla
  license: MIT-0
  min_ansible_version: "2.14"
  platforms:
    - name: Debian
      versions:
        - bullseye

dependencies: []

# Required collections (versions in ansible/requirements.yml)
collections:
  - community.hashi_vault

Step 4: Create handlers/main.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# handlers file for router-hosts

- name: Restart router-hosts
  ansible.builtin.systemd:
    name: docker-compose@router-hosts
    state: restarted
  listen: restart router-hosts

Step 5: Create tasks/main.yml (skeleton)

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# tasks file for router-hosts
# Deploys router-hosts server with Vault Agent mTLS on Firewalla

- name: Include preflight checks
  ansible.builtin.include_tasks: preflight.yml
  tags:
    - router-hosts
    - router-hosts-preflight

- name: Include Vault credentials setup
  ansible.builtin.include_tasks: vault-credentials.yml
  tags:
    - router-hosts
    - router-hosts-vault

- name: Include directory setup
  ansible.builtin.include_tasks: directories.yml
  tags:
    - router-hosts
    - router-hosts-directories

- name: Include configuration deployment
  ansible.builtin.include_tasks: configure.yml
  tags:
    - router-hosts
    - router-hosts-configure

- name: Include Docker deployment
  ansible.builtin.include_tasks: docker.yml
  tags:
    - router-hosts
    - router-hosts-docker

- name: Include verification
  ansible.builtin.include_tasks: verify.yml
  tags:
    - router-hosts
    - router-hosts-verify

Step 6: Run ansible-lint to verify structure

Run: ansible-lint ansible/roles/router-hosts/ Expected: Pass with no errors (may have warnings about missing task files)

Step 7: Commit

git add ansible/roles/router-hosts/
git commit -m "feat(ansible): add router-hosts role skeleton

- defaults with Vault Agent and Firewalla configuration
- meta with collection dependencies
- handlers for service restart
- main tasks entry point"

Task 2: Create Preflight Task File

Files: - Create: ansible/roles/router-hosts/tasks/preflight.yml

Step 1: Create preflight.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Preflight checks for router-hosts deployment

- name: Verify Docker is available
  ansible.builtin.command:
    cmd: docker --version
  register: router_hosts_docker_check
  changed_when: false
  failed_when: router_hosts_docker_check.rc != 0
  tags:
    - router-hosts
    - router-hosts-preflight

- name: Verify Docker daemon is running
  ansible.builtin.command:
    cmd: docker info
  register: router_hosts_docker_info
  changed_when: false
  failed_when: router_hosts_docker_info.rc != 0
  tags:
    - router-hosts
    - router-hosts-preflight

- name: Verify Vault is reachable
  ansible.builtin.uri:
    url: "{{ router_hosts_vault_addr }}/v1/sys/health"
    method: GET
    validate_certs: true
    status_code:
      - 200
      - 429  # Sealed but reachable
      - 472  # Standby node
      - 473  # Performance standby
  register: router_hosts_vault_health
  delegate_to: localhost
  become: false
  tags:
    - router-hosts
    - router-hosts-preflight

- name: Verify external storage mount exists
  ansible.builtin.stat:
    path: /extdata
  register: router_hosts_extdata_stat
  tags:
    - router-hosts
    - router-hosts-preflight

- name: Fail if external storage not mounted
  ansible.builtin.fail:
    msg: |
      External storage /extdata not found.
      Ensure the USB/SD storage is mounted on the Firewalla.
  when: not router_hosts_extdata_stat.stat.exists
  tags:
    - router-hosts
    - router-hosts-preflight

Step 2: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/tasks/preflight.yml Expected: Pass

Step 3: Commit

git add ansible/roles/router-hosts/tasks/preflight.yml
git commit -m "feat(ansible): add router-hosts preflight checks

- Docker availability and daemon status
- Vault reachability check
- External storage mount verification"

Task 3: Create Vault Credentials Task File

Files: - Create: ansible/roles/router-hosts/tasks/vault-credentials.yml

Step 1: Create vault-credentials.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Fetch Vault AppRole credentials at deploy time
# Runs on localhost (where Vault token is available) and copies to target

- name: Read AppRole role_id from Vault
  community.hashi_vault.vault_read:
    url: "{{ router_hosts_vault_addr }}"
    path: "auth/approle/role/{{ router_hosts_vault_approle_name }}/role-id"
  register: router_hosts_role_id_result
  delegate_to: localhost
  become: false
  no_log: true
  tags:
    - router-hosts
    - router-hosts-vault

- name: Generate new AppRole secret_id from Vault
  community.hashi_vault.vault_write:
    url: "{{ router_hosts_vault_addr }}"
    path: "auth/approle/role/{{ router_hosts_vault_approle_name }}/secret-id"
  register: router_hosts_secret_id_result
  delegate_to: localhost
  become: false
  no_log: true
  tags:
    - router-hosts
    - router-hosts-vault

- name: Set AppRole facts
  ansible.builtin.set_fact:
    router_hosts_role_id: "{{ router_hosts_role_id_result.data.data.role_id }}"
    router_hosts_secret_id: "{{ router_hosts_secret_id_result.data.data.secret_id }}"
  no_log: true
  tags:
    - router-hosts
    - router-hosts-vault

- name: Verify credentials were retrieved
  ansible.builtin.assert:
    that:
      - router_hosts_role_id | length > 0
      - router_hosts_secret_id | length > 0
    fail_msg: "Failed to retrieve AppRole credentials from Vault"
    quiet: true
  no_log: true
  tags:
    - router-hosts
    - router-hosts-vault

Step 2: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/tasks/vault-credentials.yml Expected: Pass

Step 3: Commit

git add ansible/roles/router-hosts/tasks/vault-credentials.yml
git commit -m "feat(ansible): add router-hosts Vault credentials fetch

- Read role_id from Vault AppRole
- Generate fresh secret_id at deploy time
- Delegate to localhost where Vault token is available"

Task 4: Create Directories Task File

Files: - Create: ansible/roles/router-hosts/tasks/directories.yml

Step 1: Create directories.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Create directory structure for router-hosts

- name: Get pi user info
  ansible.builtin.getent:
    database: passwd
    key: "{{ router_hosts_user }}"
  register: router_hosts_user_info
  tags:
    - router-hosts
    - router-hosts-directories

- name: Set user UID/GID facts
  ansible.builtin.set_fact:
    router_hosts_uid: "{{ router_hosts_user_info.ansible_facts.getent_passwd[router_hosts_user][1] }}"
    router_hosts_gid: "{{ router_hosts_user_info.ansible_facts.getent_passwd[router_hosts_user][2] }}"
  tags:
    - router-hosts
    - router-hosts-directories

- name: Create base directory
  ansible.builtin.file:
    path: "{{ router_hosts_base_dir }}"
    state: directory
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0755"
  tags:
    - router-hosts
    - router-hosts-directories

- name: Create subdirectories
  ansible.builtin.file:
    path: "{{ router_hosts_base_dir }}/{{ item }}"
    state: directory
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0755"
  loop:
    - config
    - data
    - vault-approle
    - scripts
    - certs
  tags:
    - router-hosts
    - router-hosts-directories

- name: Set restrictive permissions on vault-approle directory
  ansible.builtin.file:
    path: "{{ router_hosts_base_dir }}/vault-approle"
    mode: "0700"
  tags:
    - router-hosts
    - router-hosts-directories

- name: Create Docker Compose directory (Firewalla convention)
  ansible.builtin.file:
    path: "{{ router_hosts_docker_compose_dir }}"
    state: directory
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0755"
  tags:
    - router-hosts
    - router-hosts-directories

- name: Ensure post_main.d directory exists
  ansible.builtin.file:
    path: "{{ router_hosts_post_main_dir }}"
    state: directory
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0755"
  tags:
    - router-hosts
    - router-hosts-directories

Step 2: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/tasks/directories.yml Expected: Pass

Step 3: Commit

git add ansible/roles/router-hosts/tasks/directories.yml
git commit -m "feat(ansible): add router-hosts directory setup

- Base directory structure on /extdata
- Restricted permissions for vault-approle
- Docker Compose and post_main.d directories"

Task 5: Create Templates

Files: - Create: ansible/roles/router-hosts/templates/docker-compose.yml.j2 - Create: ansible/roles/router-hosts/templates/vault-agent-config.hcl.j2 - Create: ansible/roles/router-hosts/templates/server.toml.j2 - Create: ansible/roles/router-hosts/templates/on-hosts-update.sh.j2 - Create: ansible/roles/router-hosts/templates/z0100-start-router-hosts.sh.j2

Step 1: Create docker-compose.yml.j2

# {{ ansible_managed }}
# Docker Compose for router-hosts with Vault Agent mTLS
---
services:
  vault-agent:
    image: {{ router_hosts_vault_image }}
    container_name: router-hosts-vault-agent
    restart: unless-stopped
    user: "{{ router_hosts_uid }}:{{ router_hosts_gid }}"
    command:
      - agent
      - -config=/vault/config/vault-agent.hcl
    environment:
      VAULT_ADDR: "{{ router_hosts_vault_addr }}"
      VAULT_CACERT: /etc/ssl/certs/ca-certificates.crt
    volumes:
      - {{ router_hosts_base_dir }}/vault-approle:/vault/approle:ro
      - {{ router_hosts_base_dir }}/config/vault-agent.hcl:/vault/config/vault-agent.hcl:ro
      - {{ router_hosts_base_dir }}/certs:/vault/certs:rw
      - /etc/ssl/certs:/etc/ssl/certs:ro
    healthcheck:
      test: ["CMD", "test", "-f", "/vault/certs/server.crt"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

  router-hosts:
    image: {{ router_hosts_image }}:{{ router_hosts_version }}
    container_name: router-hosts-server
    restart: unless-stopped
    user: "{{ router_hosts_uid }}:{{ router_hosts_gid }}"
    depends_on:
      vault-agent:
        condition: service_healthy
    ports:
      - "{{ router_hosts_grpc_port }}:{{ router_hosts_grpc_port }}"
    environment:
      RUST_LOG: info
    volumes:
      - {{ router_hosts_base_dir }}/config/server.toml:/config/server.toml:ro
      - {{ router_hosts_base_dir }}/data:/data:rw
      - {{ router_hosts_base_dir }}/certs:/certs:ro
      - {{ router_hosts_base_dir }}/scripts:/scripts:ro
    command:
      - server
      - --config=/config/server.toml
    healthcheck:
      test: ["CMD", "test", "-f", "/data/hosts"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

Step 2: Create vault-agent-config.hcl.j2

# {{ ansible_managed }}
# Vault Agent configuration for router-hosts mTLS certificates

vault {
  address = "{{ router_hosts_vault_addr }}"
}

auto_auth {
  method "approle" {
    config = {
      role_id_file_path   = "/vault/approle/role_id"
      secret_id_file_path = "/vault/approle/secret_id"
      remove_secret_id_file_after_reading = false
    }
  }

  sink "file" {
    config = {
      path = "/vault/certs/.vault-token"
      mode = 0600
    }
  }
}

template_config {
  static_secret_render_interval = "5m"
}

template {
  contents = <<-EOF
  {{ '{{' }}- with pkiCert "{{ router_hosts_vault_pki_path }}/issue/server" "common_name={{ router_hosts_cert_cn }}" "ttl={{ router_hosts_cert_ttl }}" "alt_names={{ router_hosts_cert_dns_sans | join(',') }}" "ip_sans={{ router_hosts_cert_ip_sans | join(',') }}" -{{ '}}' }}
  {{ '{{' }} .Cert {{ '}}' }}
  {{ '{{' }}- end {{ '}}' }}
  EOF
  destination = "/vault/certs/server.crt"
  perms = 0644
}

template {
  contents = <<-EOF
  {{ '{{' }}- with pkiCert "{{ router_hosts_vault_pki_path }}/issue/server" "common_name={{ router_hosts_cert_cn }}" "ttl={{ router_hosts_cert_ttl }}" "alt_names={{ router_hosts_cert_dns_sans | join(',') }}" "ip_sans={{ router_hosts_cert_ip_sans | join(',') }}" -{{ '}}' }}
  {{ '{{' }} .Key {{ '}}' }}
  {{ '{{' }}- end {{ '}}' }}
  EOF
  destination = "/vault/certs/server.key"
  perms = 0600
}

template {
  contents = <<-EOF
  {{ '{{' }}- with pkiCert "{{ router_hosts_vault_pki_path }}/issue/server" "common_name={{ router_hosts_cert_cn }}" "ttl={{ router_hosts_cert_ttl }}" "alt_names={{ router_hosts_cert_dns_sans | join(',') }}" "ip_sans={{ router_hosts_cert_ip_sans | join(',') }}" -{{ '}}' }}
  {{ '{{' }} .CA {{ '}}' }}
  {{ '{{' }}- end {{ '}}' }}
  EOF
  destination = "/vault/certs/ca.crt"
  perms = 0644
  # Use share_dependencies to ensure all cert files are written atomically
  # when any template is rendered (they share the pkiCert call)
}

Step 3: Create server.toml.j2

# {{ ansible_managed }}
# router-hosts server configuration

[server]
listen_addr = "0.0.0.0:{{ router_hosts_grpc_port }}"

[tls]
cert_path = "/certs/server.crt"
key_path = "/certs/server.key"
ca_path = "/certs/ca.crt"
require_client_cert = true

[storage]
db_path = "/data/router-hosts.db"
hosts_file_path = "/data/hosts"

[hooks]
on_update = "/scripts/on-hosts-update.sh"

Step 4: Create on-hosts-update.sh.j2

#!/usr/bin/env bash
# {{ ansible_managed }}
# Hook script called after hosts file updates
# Currently a no-op that logs; uncomment to restart Firewalla DNS

# TODO: Uncomment to restart Firewalla DNS when ready
# sudo systemctl restart firerouter_dns

logger -t router-hosts "Hosts file updated"

Step 5: Create z0100-start-router-hosts.sh.j2

#!/usr/bin/env bash
# {{ ansible_managed }}
# Boot script to start router-hosts Docker Compose service
# Placed in post_main.d to run after Firewalla main startup

sudo systemctl enable --now docker-compose@router-hosts

Step 6: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/templates/ Expected: Pass (templates themselves aren't linted, but syntax should be valid)

Step 7: Commit

git add ansible/roles/router-hosts/templates/
git commit -m "feat(ansible): add router-hosts templates

- docker-compose.yml.j2: vault-agent + router-hosts containers
- vault-agent-config.hcl.j2: AppRole auth and PKI cert templates
- server.toml.j2: router-hosts server configuration
- on-hosts-update.sh.j2: hook script (currently no-op)
- z0100-start-router-hosts.sh.j2: boot script for post_main.d"

Task 6: Create Configure Task File

Files: - Create: ansible/roles/router-hosts/tasks/configure.yml

Step 1: Create configure.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Deploy configuration files for router-hosts

- name: Write AppRole role_id
  ansible.builtin.copy:
    content: "{{ router_hosts_role_id }}"
    dest: "{{ router_hosts_base_dir }}/vault-approle/role_id"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0600"
  no_log: true
  notify: restart router-hosts
  tags:
    - router-hosts
    - router-hosts-configure

- name: Write AppRole secret_id
  ansible.builtin.copy:
    content: "{{ router_hosts_secret_id }}"
    dest: "{{ router_hosts_base_dir }}/vault-approle/secret_id"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0600"
  no_log: true
  notify: restart router-hosts
  tags:
    - router-hosts
    - router-hosts-configure

- name: Deploy Vault Agent configuration
  ansible.builtin.template:
    src: vault-agent-config.hcl.j2
    dest: "{{ router_hosts_base_dir }}/config/vault-agent.hcl"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0644"
  notify: restart router-hosts
  tags:
    - router-hosts
    - router-hosts-configure

- name: Deploy router-hosts server configuration
  ansible.builtin.template:
    src: server.toml.j2
    dest: "{{ router_hosts_base_dir }}/config/server.toml"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0644"
  notify: restart router-hosts
  tags:
    - router-hosts
    - router-hosts-configure

- name: Deploy on-hosts-update hook script
  ansible.builtin.template:
    src: on-hosts-update.sh.j2
    dest: "{{ router_hosts_base_dir }}/scripts/on-hosts-update.sh"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0755"
  tags:
    - router-hosts
    - router-hosts-configure

- name: Deploy Docker Compose file
  ansible.builtin.template:
    src: docker-compose.yml.j2
    dest: "{{ router_hosts_docker_compose_dir }}/docker-compose.yml"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0644"
  notify: restart router-hosts
  tags:
    - router-hosts
    - router-hosts-configure

- name: Deploy boot script
  ansible.builtin.template:
    src: z0100-start-router-hosts.sh.j2
    dest: "{{ router_hosts_post_main_dir }}/z0100-start-router-hosts.sh"
    owner: "{{ router_hosts_user }}"
    group: "{{ router_hosts_user }}"
    mode: "0755"
  tags:
    - router-hosts
    - router-hosts-configure

Step 2: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/tasks/configure.yml Expected: Pass

Step 3: Commit

git add ansible/roles/router-hosts/tasks/configure.yml
git commit -m "feat(ansible): add router-hosts configuration deployment

- AppRole credentials with restricted permissions
- Vault Agent and server configuration
- Docker Compose and boot scripts"

Task 7: Create Docker Task File

Files: - Create: ansible/roles/router-hosts/tasks/docker.yml

Step 1: Create docker.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Deploy router-hosts Docker Compose service

- name: Pull Vault Agent image
  ansible.builtin.command:
    cmd: docker pull {{ router_hosts_vault_image }}
  register: router_hosts_vault_pull
  changed_when: "'Pull complete' in router_hosts_vault_pull.stdout or 'Downloaded newer' in router_hosts_vault_pull.stdout"
  tags:
    - router-hosts
    - router-hosts-docker

- name: Pull router-hosts image
  ansible.builtin.command:
    cmd: docker pull {{ router_hosts_image }}:{{ router_hosts_version }}
  register: router_hosts_pull
  changed_when: "'Pull complete' in router_hosts_pull.stdout or 'Downloaded newer' in router_hosts_pull.stdout"
  tags:
    - router-hosts
    - router-hosts-docker

- name: Start Docker Compose service
  ansible.builtin.systemd:
    name: docker-compose@router-hosts
    state: started
    enabled: true
  tags:
    - router-hosts
    - router-hosts-docker

Step 2: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/tasks/docker.yml Expected: Pass

Step 3: Commit

git add ansible/roles/router-hosts/tasks/docker.yml
git commit -m "feat(ansible): add router-hosts Docker deployment

- Pull container images
- Start and enable docker-compose@router-hosts service"

Task 8: Create Verify Task File

Files: - Create: ansible/roles/router-hosts/tasks/verify.yml

Step 1: Create verify.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Verify router-hosts deployment

- name: Wait for Vault Agent to generate certificates
  ansible.builtin.wait_for:
    path: "{{ router_hosts_base_dir }}/certs/server.crt"
    state: present
    timeout: 120
  tags:
    - router-hosts
    - router-hosts-verify

- name: Wait for router-hosts container to be running
  ansible.builtin.command:
    cmd: docker ps --filter "name=router-hosts-server" --filter "status=running" --format "{{ '{{' }}.Names{{ '}}' }}"
  register: router_hosts_container_check
  until: router_hosts_container_check.stdout | length > 0
  retries: 12
  delay: 5
  changed_when: false
  tags:
    - router-hosts
    - router-hosts-verify

- name: Wait for gRPC port to be available
  ansible.builtin.wait_for:
    host: 127.0.0.1
    port: "{{ router_hosts_grpc_port }}"
    state: started
    timeout: 60
  tags:
    - router-hosts
    - router-hosts-verify

- name: Check certificate validity
  ansible.builtin.command:
    cmd: openssl x509 -in {{ router_hosts_base_dir }}/certs/server.crt -noout -dates
  register: router_hosts_cert_dates
  changed_when: false
  tags:
    - router-hosts
    - router-hosts-verify

- name: Report deployment status
  ansible.builtin.debug:
    msg: |
      router-hosts deployed successfully on {{ inventory_hostname }}
      gRPC: 0.0.0.0:{{ router_hosts_grpc_port }}
      Certificates: {{ router_hosts_base_dir }}/certs/
      {{ router_hosts_cert_dates.stdout }}
  tags:
    - router-hosts
    - router-hosts-verify

Step 2: Run ansible-lint

Run: ansible-lint ansible/roles/router-hosts/tasks/verify.yml Expected: Pass

Step 3: Commit

git add ansible/roles/router-hosts/tasks/verify.yml
git commit -m "feat(ansible): add router-hosts verification

- Wait for certificates from Vault Agent
- Container and port availability checks
- Certificate validity report"

Task 9: Create Playbook

Files: - Create: ansible/router-hosts-playbook.yml

Step 1: Create router-hosts-playbook.yml

# SPDX-License-Identifier: MIT-0
# code: language=ansible
---
# Deploy router-hosts to Firewalla
#
# Prerequisites:
# - Vault token available (via VAULT_TOKEN or vault login)
# - community.hashi_vault collection installed
#
# Usage:
# ansible-playbook -i inventory/hosts.yml router-hosts-playbook.yml
#
# Dry run:
# ansible-playbook -i inventory/hosts.yml router-hosts-playbook.yml --check --diff

- name: Deploy router-hosts to Firewalla
  hosts: router
  become: true
  gather_facts: true

  pre_tasks:
    - name: Verify target is the router
      ansible.builtin.assert:
        that:
          - inventory_hostname == 'router'
        fail_msg: "This playbook should only run on the 'router' host"
        quiet: true

  roles:
    - router-hosts

  post_tasks:
    - name: Display service status
      ansible.builtin.command:
        cmd: systemctl status docker-compose@router-hosts --no-pager
      register: router_hosts_service_status
      changed_when: false
      failed_when: false

    - name: Show service status
      ansible.builtin.debug:
        var: router_hosts_service_status.stdout_lines

Step 2: Run syntax check

Run: ansible-playbook -i ansible/inventory/hosts.yml ansible/router-hosts-playbook.yml --syntax-check Expected: Pass

Step 3: Run ansible-lint

Run: ansible-lint ansible/router-hosts-playbook.yml Expected: Pass

Step 4: Commit

git add ansible/router-hosts-playbook.yml
git commit -m "feat(ansible): add router-hosts playbook

- Targets router host
- Uses router-hosts role
- Displays service status on completion"

Task 10: Final Validation and Documentation

Files: - Modify: ansible/CLAUDE.md (update roles inventory)

Step 1: Run full ansible-lint on role

Run: ansible-lint ansible/roles/router-hosts/ ansible/router-hosts-playbook.yml Expected: Pass with no errors

Step 2: Run syntax check

Run: ansible-playbook -i ansible/inventory/hosts.yml ansible/router-hosts-playbook.yml --syntax-check Expected: Pass

Step 3: Update ansible/CLAUDE.md roles inventory

Add to the Roles Inventory table:

| `router-hosts` | router-hosts gRPC server with Vault mTLS | `router` |

Step 4: Commit documentation update

git add ansible/CLAUDE.md
git commit -m "docs: add router-hosts role to CLAUDE.md inventory"

Step 5: Create PR

gh pr create \
  --title "feat(ansible): add router-hosts role for Firewalla deployment" \
  --body "$(cat <<'EOF'
## Summary
- Add router-hosts Ansible role for deploying router-hosts server on Firewalla
- Uses Docker Compose with Vault Agent for automated mTLS certificate management
- Integrates with Firewalla's docker-compose@ systemd template service

## Changes
- New role: `ansible/roles/router-hosts/`
- New playbook: `ansible/router-hosts-playbook.yml`
- Updated: `ansible/CLAUDE.md` (roles inventory)

## Test plan
- [ ] Syntax check: `ansible-playbook --syntax-check`
- [ ] Lint: `ansible-lint ansible/roles/router-hosts/`
- [ ] Dry run against router: `ansible-playbook --check --diff`
- [ ] Full deployment: `ansible-playbook router-hosts-playbook.yml`

## Related
- Design: `docs/plans/2025-12-24-router-hosts-ansible-role-design.md`
- router-hosts repo: https://github.com/fzymgc-house/router-hosts

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

Summary

Task Description Files
1 Create role skeleton defaults/, meta/, handlers/, tasks/main.yml
2 Preflight checks tasks/preflight.yml
3 Vault credentials tasks/vault-credentials.yml
4 Directory setup tasks/directories.yml
5 Templates 5 template files
6 Configuration deployment tasks/configure.yml
7 Docker deployment tasks/docker.yml
8 Verification tasks/verify.yml
9 Playbook router-hosts-playbook.yml
10 Validation and PR lint, docs, PR

Total: 10 tasks, ~12 commits, ~20 files created/modified