Compare commits

...

12 Commits

Author SHA1 Message Date
Damien
b5c8c35534 Add ipfabric to excluded hosts 2026-06-21 14:28:39 +02:00
Damien
5f2d368a97 Remove ipfabric from root_hosts and add pve01 to excluded 2026-06-14 16:33:56 +02:00
Damien
7916ec86b7 Add tags to gather facts task 2026-06-14 12:10:18 +02:00
Damien
c86df61152 Add host exclusion and improve UFW task reliability
Add `excluded` host group and skip offline hosts
Fix UFW service initialization command for Alpine Linux
2026-06-13 10:17:13 +02:00
Damien
77869f0e50 Add ipfabric group 2026-06-12 12:54:33 +02:00
Damien
e34cb4adb1 Add pve01 to static inventory groups 2026-06-12 12:49:41 +02:00
Damien
01cb285d54 Add damien_hosts group to static inventory 2026-06-12 12:32:32 +02:00
Damien
edfab0c61c Add damien_hosts group with Ansible config 2026-06-12 12:31:35 +02:00
f5fca9171a Merge pull request 'Add initial project structure and documentation' (#6) from chore/documentation into main
Reviewed-on: #6
2026-06-02 06:16:33 +00:00
75a84f7724 Merge pull request 'Rename async job variables for consistency' (#5) from fix/basic_tag_violation into main
Reviewed-on: #5
2026-06-02 06:16:01 +00:00
Damien
6b9fccb1ea Rename async job variables for consistency
Rename async job variables in UFW and upgrade roles to follow a
consistent
naming pattern (upgrade_<os>_job) for better maintainability.
2026-06-02 08:13:05 +02:00
Damien
462855f077 Add initial project structure and documentation 2026-06-01 11:31:31 +02:00
7 changed files with 138 additions and 24 deletions

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# homelab-ansible
Ansible playbook to align my homelab VMs — Alpine & Debian.
## Roles
| # | Role | Purpose |
| --- | --------- | ------------------------------------------------------------- |
| 1 | `ufw` | Install UFW, deny incoming by default, allow only Tailscale |
| 2 | `repos` | Set Alpine repos to `edge` |
| 3 | `upgrade` | Upgrade all packages (handles Tailscale SSH drops gracefully) |
## Usage
```sh
# Full playbook
ansible-playbook homelab.yml
# Single role
ansible-playbook homelab.yml --tags ufw
# Dry-run
ansible-playbook homelab.yml --check --diff
```
## Inventory
- `inventory/tailscale_inventory.py` — dynamic inventory from local `tailscale status`, filtered by `tag:homelab`
- `inventory/static_groups.yml` — static group assignments (Alpine vs Debian)
## Vars
Per-role defaults live in `roles/*/defaults/main.yml`. Per-group overrides in `group_vars/`.

View File

@@ -0,0 +1,5 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/vars.json
---
ansible_user: damien
ansible_become: true
ansible_become_method: sudo

View File

@@ -1,8 +1,25 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/playbook.json
---
- name: Homelab alignment
hosts: managed
hosts: managed:!excluded
become: true
gather_facts: false
environment:
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
pre_tasks:
- name: Test connectivity
ansible.builtin.ping:
ignore_unreachable: true
register: ping_result
- name: Skip offline hosts
ansible.builtin.meta: end_host
when: ping_result.unreachable | default(false)
- name: Gather facts
ansible.builtin.setup:
tags: always
roles:
- role: ufw

View File

@@ -5,25 +5,29 @@ all:
children:
root_hosts:
hosts:
ipfabric:
grafana:
gitea:
prometheus:
prometheus-pve-exporter:
gitea-runner:
openbao:
komodo:
pve01:
debian_hosts:
hosts:
proxy:
damien_hosts:
hosts:
komodo:
managed:
children:
root_hosts:
debian_hosts:
damien_hosts:
excluded:
children:
hosts:
pve01:
hosts:
pve01:
ipfabric:

View File

@@ -17,12 +17,12 @@
- name: Add UFW to default runlevel (Alpine)
ansible.builtin.command:
cmd: rc-update add ufw default
register: rc_update_result
changed_when: >- # ">- : transform multiple lines into one line"
'already' not in rc_update_result.stdout
and 'already' not in rc_update_result.stderr
failed_when: rc_update_result.rc != 0
cmd: /sbin/rc-update add ufw default
register: ufw_rc_update_result
changed_when: >- # " >- : transform multiple lines into one line"
'already' not in ufw_rc_update_result.stdout
and 'already' not in ufw_rc_update_result.stderr
failed_when: ufw_rc_update_result.rc != 0
when: ansible_facts['os_family'] == "Alpine"
- name: Set default incoming policy

55
roles/upgrade/README.md Normal file
View File

@@ -0,0 +1,55 @@
# Upgrade role
Upgrades all packages using the native package manager (`apk` or `apt`).
## Problem
Tailscale may be updated during the upgrade. When its service restarts, the SSH tunnel drops and Ansible loses connectivity, causing a fatal `UNREACHABLE`.
## Solution — async + poll
Instead of a blocking upgrade, we fire the job in the background and poll for completion, tolerating temporary unreachability.
```mermaid
sequenceDiagram
participant A as Ansible
participant T as Target
A->>T: apk upgrade (async, poll: 0)
T-->>A: job ID: 42
Note over T: 🔄 upgrade en cours...
A->>T: async_status jid=42
T-->>A: finished: false
Note over A: FAILED-RETRYING (normal)
A-->>A: sleep 10s
A->>T: async_status jid=42
T-->>A: finished: false
Note over A: FAILED-RETRYING (normal)
A-->>A: sleep 10s
A->>T: async_status jid=42
T-->>A: finished: true ✅
Note over A: ok
```
- **`async: 3600` + `poll: 0`** — fire and forget. Ansible gets a job ID and moves on.
- **`async_status` with `until: finished`** — polls every 10s for up to 60 retries (~10 min).
- **`ignore_unreachable: true`** — if Tailscale dropped SSH, Ansible doesn't crash. It retries until the host is back.
- The `FAILED - RETRYING` messages are normal — they just mean the `until` condition isn't met yet.
## Idempotency
A task with `async` + `poll: 0` always reports `changed: true` by default, even when nothing happened. To fix this:
```mermaid
graph LR
A["Fire: changed_when: false"] --> B["Package manager runs in background"]
B --> C["Wait: polls every 10s"]
C -->|finished| D["async_status reads real changed value"]
D -->|"packages updated"| E["changed ✅"]
D -->|"nothing to do"| F["ok"]
```
- The **fire** task has `changed_when: false` — it never claims change.
- The **wait** task reads `changed_when: async_result.changed` — it propagates the real status from the package manager once the job finishes.
Second run with nothing to upgrade → `changed=0` on all hosts.

View File

@@ -10,7 +10,7 @@
update_cache: true
async: 3600
poll: 0
register: alpine_upgrade
register: upgrade_alpine_job
# By default async job return change: true
# Force adapting the change condition for idempotency
changed_when: false
@@ -18,18 +18,18 @@
- name: Wait for Alpine upgrade
ansible.builtin.async_status:
jid: "{{ alpine_upgrade.ansible_job_id }}"
register: alpine_result
until: alpine_result.finished
jid: "{{ upgrade_alpine_job.ansible_job_id }}"
register: upgrade_alpine_result
until: upgrade_alpine_result.finished
retries: 60
delay: 10
# By default async job return change: true
# Force adapting the change condition for idempotency
changed_when: alpine_result.changed | default(false)
changed_when: upgrade_alpine_result.changed | default(false)
ignore_unreachable: true
when:
- ansible_facts['os_family'] == "Alpine"
- alpine_upgrade.ansible_job_id is defined
- upgrade_alpine_job.ansible_job_id is defined
- name: Upgrade all packages (Debian)
ansible.builtin.apt:
@@ -37,7 +37,7 @@
update_cache: true
async: 3600
poll: 0
register: debian_upgrade
register: upgrade_debian_job
# By default async job return change: true
# Force adapting the change condition for idempotency
changed_when: false
@@ -45,15 +45,15 @@
- name: Wait for Debian upgrade
ansible.builtin.async_status:
jid: "{{ debian_upgrade.ansible_job_id }}"
register: debian_result
until: debian_result.finished
jid: "{{ upgrade_debian_job.ansible_job_id }}"
register: upgrade_debian_result
until: upgrade_debian_result.finished
retries: 60
delay: 10
# By default async job return change: true
# Force adapting the change condition for idempotency
changed_when: debian_result.changed | default(false)
changed_when: upgrade_debian_result.changed | default(false)
ignore_unreachable: true
when:
- ansible_facts['os_family'] == "Debian"
- debian_upgrade.ansible_job_id is defined
- upgrade_debian_job.ansible_job_id is defined