Compare commits

...

5 Commits

Author SHA1 Message Date
Tuan-Dat Tran
6325941078 docs: add raspberry-pi ansible management plan and spec 2026-06-04 01:45:16 +02:00
Tuan-Dat Tran
36f944d1c4 feat(edge_vps): add vps playbook 2026-06-04 01:45:16 +02:00
Tuan-Dat Tran
cce6aba4cd fix(edge_vps): fix wireguard route template and update elastic/vps vars 2026-06-04 01:45:16 +02:00
Tuan-Dat Tran
f873256f65 feat(edge_vps): add traefik dynamic config template 2026-06-04 01:45:01 +02:00
Tuan-Dat Tran
a331265bde feat(edge_vps): add pangolin/gerbil/traefik stack with versioned images 2026-06-04 01:44:55 +02:00
13 changed files with 442 additions and 40 deletions

View File

@@ -0,0 +1,251 @@
# Raspberry Pi Ansible Management Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `naruto` and `pi` Raspberry Pis to Ansible inventory under a new `raspberry_pi` role, starting with `common` as the base.
**Architecture:** New inventory group `raspberry_pi` with a dedicated role of the same name. The playbook applies both `common` and `raspberry_pi` roles. Two ARM incompatibilities in `extra_packages.yaml` are fixed in the `common` role itself so all future ARM hosts benefit.
**Tech Stack:** Ansible, Debian 11 (Bullseye), aarch64
---
## File Map
| Action | Path | Responsibility |
|--------|------|----------------|
| Create | `vars/raspberry_pi.ini` | Inventory group with naruto and pi |
| Create | `vars/group_vars/raspberry_pi/vars.yaml` | Group-level vars (empty, inherits from `all`) |
| Modify | `roles/common/tasks/extra_packages.yaml` | Fix `bottom` arch and Neovim AppImage for ARM |
| Create | `roles/raspberry_pi/tasks/main.yaml` | Role entry point, placeholder for future Pi tasks |
| Create | `playbooks/raspberry-pi.yaml` | Playbook targeting `raspberry_pi` group |
---
### Task 1: Add inventory and group vars
**Files:**
- Create: `vars/raspberry_pi.ini`
- Create: `vars/group_vars/raspberry_pi/vars.yaml`
- [ ] **Create inventory file**
```ini
[raspberry_pi]
naruto
pi
```
Save to `vars/raspberry_pi.ini`.
- [ ] **Create group vars file**
```yaml
---
```
Save to `vars/group_vars/raspberry_pi/vars.yaml`. Empty for now — both hosts inherit all vars from `vars/group_vars/all/`.
- [ ] **Verify Ansible can see both hosts**
```bash
ansible raspberry_pi --list-hosts
```
Expected output:
```
hosts (2):
naruto
pi
```
- [ ] **Commit**
```bash
git add vars/raspberry_pi.ini vars/group_vars/raspberry_pi/vars.yaml
git commit -m "feat(raspberry_pi): add inventory and group vars"
```
---
### Task 2: Fix ARM incompatibilities in `common/tasks/extra_packages.yaml`
**Files:**
- Modify: `roles/common/tasks/extra_packages.yaml`
Two issues to fix:
**Issue 1 — `bottom` deb URL is hardcoded to `amd64`.** The global `arch` variable already resolves to `arm64` on aarch64 hosts.
**Issue 2 — Neovim AppImage doesn't run on aarch64.** `neovim` is already installed via apt in `common_packages`, so on ARM we skip the AppImage entirely and the apt version is used.
- [ ] **Fix `bottom` URL to use `arch` variable**
In `roles/common/tasks/extra_packages.yaml`, replace:
```yaml
- name: Install bottom package
ansible.builtin.apt:
deb: https://github.com/ClementTsang/bottom/releases/download/0.9.6/bottom_0.9.6_amd64.deb
state: present
become: true
```
With:
```yaml
- name: Install bottom package
ansible.builtin.apt:
deb: https://github.com/ClementTsang/bottom/releases/download/0.9.6/bottom_0.9.6_{{ arch }}.deb
state: present
become: true
```
- [ ] **Add `when: ansible_architecture != 'aarch64'` to all Neovim AppImage tasks**
Replace the six Neovim AppImage tasks (from "Check if Neovim is already installed" through "Remove Neovim AppImage") with the version below. The neovim config clone tasks at the end are architecture-independent and stay unchanged.
```yaml
- name: Check if Neovim is already installed
ansible.builtin.command: "which nvim"
register: neovim_installed
changed_when: false
ignore_errors: true
when: ansible_architecture != 'aarch64'
- name: Download Neovim AppImage
ansible.builtin.get_url:
url: https://github.com/neovim/neovim/releases/download/v0.10.0/nvim.appimage
dest: /tmp/nvim.appimage
mode: "0755"
when: ansible_architecture != 'aarch64' and neovim_installed.rc != 0
register: download_result
- name: Extract Neovim AppImage
ansible.builtin.command:
cmd: "./nvim.appimage --appimage-extract"
chdir: /tmp
when: ansible_architecture != 'aarch64' and download_result.changed
register: extract_result
- name: Copy extracted Neovim files to /usr
ansible.builtin.copy:
src: /tmp/squashfs-root/usr/
dest: /usr/
remote_src: true
mode: "0755"
become: true
when: ansible_architecture != 'aarch64' and extract_result.changed
- name: Clean up extracted Neovim files
ansible.builtin.file:
path: /tmp/squashfs-root
state: absent
when: ansible_architecture != 'aarch64' and extract_result.changed
- name: Remove Neovim AppImage
ansible.builtin.file:
path: /tmp/nvim.appimage
state: absent
when: ansible_architecture != 'aarch64' and download_result.changed
```
- [ ] **Commit**
```bash
git add roles/common/tasks/extra_packages.yaml
git commit -m "fix(common): support aarch64 in extra_packages"
```
---
### Task 3: Create `raspberry_pi` role
**Files:**
- Create: `roles/raspberry_pi/tasks/main.yaml`
- [ ] **Create role task entry point**
```yaml
---
```
Save to `roles/raspberry_pi/tasks/main.yaml`. Intentionally empty for now — Pi-specific workloads (Newt on naruto, docker stack on pi) are added in future tasks.
- [ ] **Commit**
```bash
git add roles/raspberry_pi/tasks/main.yaml
git commit -m "feat(raspberry_pi): add empty role scaffold"
```
---
### Task 4: Create playbook
**Files:**
- Create: `playbooks/raspberry-pi.yaml`
- [ ] **Create playbook**
```yaml
---
- name: Set up Raspberry Pis
hosts: raspberry_pi
gather_facts: true
roles:
- role: common
tags:
- common
- role: raspberry_pi
tags:
- raspberry_pi
```
Save to `playbooks/raspberry-pi.yaml`.
- [ ] **Commit**
```bash
git add playbooks/raspberry-pi.yaml
git commit -m "feat(raspberry_pi): add playbook"
```
---
### Task 5: Run and verify
- [ ] **Dry-run against both hosts**
```bash
ansible-playbook playbooks/raspberry-pi.yaml --check
```
Note: the `apt upgrade` task will fail in check mode without `python3-apt` on the remote (same issue seen with mii). If it fails there, proceed to the real run.
- [ ] **Run for real**
```bash
ansible-playbook playbooks/raspberry-pi.yaml
```
Expected: all tasks `ok` or `changed`, no failures. Watch for:
- `bottom` task — should download `arm64` deb
- Neovim AppImage tasks — should be skipped on both hosts
- Hostname task — `pi` will be renamed from `raspberrypi` to `pi`
- [ ] **Verify hostname on pi was updated**
```bash
ssh pi "hostname"
```
Expected: `pi`
- [ ] **Verify bottom installed correctly on both**
```bash
ansible raspberry_pi -a "btm --version"
```
Expected: version string printed for both hosts, no errors.

View File

@@ -0,0 +1,36 @@
# Raspberry Pi Ansible Management
**Date:** 2026-05-29
## Goal
Bring `naruto` (Pi 4, 8GB) and `pi` (Pi 3, 1GB) under Ansible management using a new `raspberry_pi` role that starts with the `common` role as its base.
## Inventory
New file `vars/raspberry_pi.ini` with a `[raspberry_pi]` group containing both hosts. Both connect as user `tudattr` (non-root, sudo available).
## ARM Fixes in `common` Role
Two tasks in `extra_packages.yaml` are amd64-only and must be fixed before running on ARM:
- **bottom:** URL is hardcoded to `amd64.deb`. Fix to use the existing `arch` global variable so it resolves to `arm64` on aarch64 hosts.
- **Neovim:** Fetched as an AppImage, which doesn't run on aarch64. Fix to install `neovim` via apt on ARM, skipping the AppImage path.
These fixes apply to the `common` role itself so any future ARM host benefits.
## New Role: `raspberry_pi`
Structure mirrors other roles. `tasks/main.yaml` includes `common` tasks, then Pi-specific tasks (none yet — placeholder for future workloads like Newt on naruto, docker stack on pi).
## New Playbook
`playbooks/raspberry-pi.yaml` targets `raspberry_pi` group, applies `raspberry_pi` role with tag `raspberry_pi`.
## Group Vars
`vars/group_vars/raspberry_pi/vars.yaml` — empty for now, inherits all from `all`. Can hold Pi-specific overrides later.
## Hostname
`pi` is currently named `raspberrypi`. The `common` hostname task will rename it to `pi` to match the inventory name.

8
playbooks/vps.yaml Normal file
View File

@@ -0,0 +1,8 @@
---
- name: Set up VPS
hosts: vps
gather_facts: true
roles:
- role: edge_vps
tags:
- edge_vps

View File

@@ -6,6 +6,10 @@ edge_vps_wireguard_address: "10.133.7.1/24"
edge_vps_wireguard_port: 61975 edge_vps_wireguard_port: 61975
edge_vps_traefik_config_dir: "{{ edge_vps_config_base }}/traefik" edge_vps_traefik_config_dir: "{{ edge_vps_config_base }}/traefik"
edge_vps_traefik_logs_dir: "{{ edge_vps_traefik_config_dir }}/logs" edge_vps_traefik_logs_dir: "{{ edge_vps_traefik_config_dir }}/logs"
edge_vps_pangolin_config_dir: "{{ edge_vps_config_base }}/pangolin" edge_vps_pangolin_config_dir: "{{ edge_vps_config_base }}"
edge_vps_elastic_config_dir: "{{ edge_vps_config_base }}/elastic-agent" edge_vps_pangolin_compose_dir: /root
edge_vps_pangolin_version: "1.12.1"
edge_vps_gerbil_version: "1.2.2"
edge_vps_traefik_version: "v3.5"
edge_vps_elastic_config_dir: /root/agent
edge_vps_elastic_state_dir: /var/lib/elastic-agent/elastic-system/elastic-agent/state edge_vps_elastic_state_dir: /var/lib/elastic-agent/elastic-system/elastic-agent/state

View File

@@ -7,6 +7,12 @@
- name: Restart traefik - name: Restart traefik
ansible.builtin.command: ansible.builtin.command:
cmd: docker compose restart cmd: podman compose restart traefik
chdir: "{{ edge_vps_traefik_config_dir }}" chdir: "{{ edge_vps_pangolin_compose_dir }}"
listen: restart traefik listen: restart traefik
- name: Restart pangolin
ansible.builtin.command:
cmd: podman compose restart pangolin
chdir: "{{ edge_vps_pangolin_compose_dir }}"
listen: restart pangolin

View File

@@ -14,9 +14,9 @@
- "{{ edge_vps_traefik_config_dir }}" - "{{ edge_vps_traefik_config_dir }}"
- "{{ edge_vps_traefik_logs_dir }}" - "{{ edge_vps_traefik_logs_dir }}"
- name: Create Pangolin config directory - name: Create Pangolin letsencrypt directory
ansible.builtin.file: ansible.builtin.file:
path: "{{ edge_vps_pangolin_config_dir }}" path: "{{ edge_vps_pangolin_config_dir }}/letsencrypt"
state: directory state: directory
mode: "0755" mode: "0755"

View File

@@ -6,10 +6,9 @@
mode: "0644" mode: "0644"
notify: restart traefik notify: restart traefik
- name: Deploy Cloudflare credentials for ACME - name: Deploy Traefik dynamic config
ansible.builtin.copy: ansible.builtin.template:
content: | src: traefik/dynamic_config.yml.j2
CF_DNS_API_TOKEN={{ vault_edge_vps.traefik.cloudflare_api_token }} dest: "{{ edge_vps_traefik_config_dir }}/dynamic_config.yml"
dest: "{{ edge_vps_traefik_config_dir }}/cloudflare.env" mode: "0644"
mode: "0600" notify: restart traefik
no_log: true

View File

@@ -9,16 +9,11 @@
- name: Deploy Pangolin docker-compose - name: Deploy Pangolin docker-compose
ansible.builtin.template: ansible.builtin.template:
src: pangolin/docker-compose.yml.j2 src: pangolin/docker-compose.yml.j2
dest: "{{ edge_vps_pangolin_config_dir }}/docker-compose.yml" dest: "{{ edge_vps_pangolin_compose_dir }}/docker-compose.yml"
mode: "0644" mode: "0644"
- name: Create letsencrypt directory for Pangolin
ansible.builtin.file:
path: "{{ edge_vps_pangolin_config_dir }}/letsencrypt"
state: directory
mode: "0755"
- name: Start Pangolin - name: Start Pangolin
community.docker.docker_compose_v2: ansible.builtin.command:
project_src: "{{ edge_vps_pangolin_config_dir }}" cmd: podman compose up -d
state: present chdir: "{{ edge_vps_pangolin_compose_dir }}"
changed_when: false

View File

@@ -24,6 +24,7 @@
mode: "0644" mode: "0644"
- name: Start Elastic Agent - name: Start Elastic Agent
community.docker.docker_compose_v2: ansible.builtin.command:
project_src: "{{ edge_vps_elastic_config_dir }}" cmd: podman compose up -d
state: present chdir: "{{ edge_vps_elastic_config_dir }}"
changed_when: false

View File

@@ -1,25 +1,58 @@
name: pangolin
services: services:
pangolin: pangolin:
image: fosrl/pangolin:latest image: docker.io/fosrl/pangolin:{{ edge_vps_pangolin_version }}
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
ports:
- "3001:3001"
- "443:443"
- "80:80"
volumes: volumes:
- ./config.yml:/app/config/config.yml:ro - ./config:/app/config
- ./letsencrypt:/letsencrypt healthcheck:
depends_on: test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
- gerbil interval: "10s"
timeout: "10s"
retries: 15
gerbil: gerbil:
image: fosrl/gerbil:latest image: docker.io/fosrl/gerbil:{{ edge_vps_gerbil_version }}
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
network_mode: host depends_on:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- ./config/:/var/config
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE - SYS_MODULE
ports:
- 51820:51820/udp
- 21820:21820/udp
- 443:443
- 80:80
- 6443:6443
traefik:
image: docker.io/traefik:{{ edge_vps_traefik_version }}
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil
depends_on:
pangolin:
condition: service_healthy
command:
- --configFile=/etc/traefik/traefik_config.yml
environment:
CLOUDFLARE_DNS_API_TOKEN: {{ vault_edge_vps.traefik.cloudflare_api_token }}
volumes: volumes:
- /lib/modules:/lib/modules - ./config/traefik:/etc/traefik:ro
- ./config/letsencrypt:/letsencrypt
- ./config/traefik/logs:/var/log/traefik
networks:
default:
driver: bridge
name: pangolin

View File

@@ -0,0 +1,67 @@
http:
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
routers:
main-app-router-redirect:
rule: "Host(`{{ edge_vps_pangolin_dashboard_url | regex_replace('^https?://', '') }}`)"
service: next-service
entryPoints:
- web
middlewares:
- redirect-to-https
next-router:
rule: "Host(`{{ edge_vps_pangolin_dashboard_url | regex_replace('^https?://', '') }}`) && !PathPrefix(`/api/v1`)"
service: next-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
domains:
- main: "{{ edge_vps_pangolin_base_domain }}"
sans:
- "*.{{ edge_vps_pangolin_base_domain }}"
{% for domain in edge_vps_traefik_extra_tls_domains | default([]) %}
- main: "{{ domain }}"
sans:
- "*.{{ domain }}"
{% endfor %}
api-router:
rule: "Host(`{{ edge_vps_pangolin_dashboard_url | regex_replace('^https?://', '') }}`) && PathPrefix(`/api/v1`)"
service: api-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
ws-router:
rule: "Host(`{{ edge_vps_pangolin_dashboard_url | regex_replace('^https?://', '') }}`)"
service: api-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:3002"
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000"
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2

View File

@@ -7,12 +7,12 @@ PostUp = sysctl -w net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i {{ edge_vps_wireguard_interface }} -j ACCEPT PostUp = iptables -A FORWARD -i {{ edge_vps_wireguard_interface }} -j ACCEPT
PostUp = iptables -A FORWARD -o {{ edge_vps_wireguard_interface }} -j ACCEPT PostUp = iptables -A FORWARD -o {{ edge_vps_wireguard_interface }} -j ACCEPT
{% for route in edge_vps_wireguard_routes | default([]) %} {% for route in edge_vps_wireguard_routes | default([]) %}
PostUp = ip route add {{ route }} via {{ route.gateway }} dev {{ edge_vps_wireguard_interface }} PostUp = ip route add {{ route.network }} via {{ route.gateway }} dev {{ edge_vps_wireguard_interface }}
{% endfor %} {% endfor %}
PostDown = iptables -D FORWARD -i {{ edge_vps_wireguard_interface }} -j ACCEPT PostDown = iptables -D FORWARD -i {{ edge_vps_wireguard_interface }} -j ACCEPT
PostDown = iptables -D FORWARD -o {{ edge_vps_wireguard_interface }} -j ACCEPT PostDown = iptables -D FORWARD -o {{ edge_vps_wireguard_interface }} -j ACCEPT
{% for route in edge_vps_wireguard_routes | default([]) %} {% for route in edge_vps_wireguard_routes | default([]) %}
PostDown = ip route del {{ route }} via {{ route.gateway }} dev {{ edge_vps_wireguard_interface }} PostDown = ip route del {{ route.network }} via {{ route.gateway }} dev {{ edge_vps_wireguard_interface }}
{% endfor %} {% endfor %}
{% for peer in vault_edge_vps.wireguard.peers %} {% for peer in vault_edge_vps.wireguard.peers %}

View File

@@ -9,6 +9,8 @@ edge_vps_pangolin_base_endpoint: "pangolin.seyshiro.de"
edge_vps_pangolin_base_domain: "seyshiro.de" edge_vps_pangolin_base_domain: "seyshiro.de"
edge_vps_acme_email: "me+acme@tudattr.dev" edge_vps_acme_email: "me+acme@tudattr.dev"
edge_vps_traefik_extra_tls_domains:
- "tudattr.dev"
edge_vps_elastic_version: "9.2.2" edge_vps_elastic_version: "9.2.2"
edge_vps_elastic_dns_server: "10.43.0.10" edge_vps_elastic_dns_server: "10.43.0.10"