Compare commits
15 Commits
994337fc9a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c8c35534 | ||
|
|
5f2d368a97 | ||
|
|
7916ec86b7 | ||
|
|
c86df61152 | ||
|
|
77869f0e50 | ||
|
|
e34cb4adb1 | ||
|
|
01cb285d54 | ||
|
|
edfab0c61c | ||
| f5fca9171a | |||
| 75a84f7724 | |||
|
|
6b9fccb1ea | ||
|
|
462855f077 | ||
| 21019fa6ea | |||
|
|
5d567d1536 | ||
|
|
2639c5e872 |
33
README.md
Normal file
33
README.md
Normal 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/`.
|
||||
5
group_vars/damien_hosts.yml
Normal file
5
group_vars/damien_hosts.yml
Normal 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
|
||||
19
homelab.yml
19
homelab.yml
@@ -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
|
||||
|
||||
@@ -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:
|
||||
ipfabric:
|
||||
|
||||
@@ -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
55
roles/upgrade/README.md
Normal 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.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
# roles/upgrade/tasks/main.yml
|
||||
#
|
||||
# Async pattern: Tailscale may restart during upgrade, dropping SSH.
|
||||
# Fire and forget → poll status with unreachable tolerance + retries.
|
||||
|
||||
- name: Upgrade all packages (Alpine)
|
||||
community.general.apk:
|
||||
upgrade: true
|
||||
update_cache: true
|
||||
async: 3600
|
||||
poll: 0
|
||||
register: upgrade_alpine_job
|
||||
# By default async job return change: true
|
||||
# Force adapting the change condition for idempotency
|
||||
changed_when: false
|
||||
when: ansible_facts['os_family'] == "Alpine"
|
||||
|
||||
- name: Wait for Alpine upgrade
|
||||
ansible.builtin.async_status:
|
||||
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: upgrade_alpine_result.changed | default(false)
|
||||
ignore_unreachable: true
|
||||
when:
|
||||
- ansible_facts['os_family'] == "Alpine"
|
||||
- upgrade_alpine_job.ansible_job_id is defined
|
||||
|
||||
- name: Upgrade all packages (Debian)
|
||||
ansible.builtin.apt:
|
||||
upgrade: full
|
||||
update_cache: true
|
||||
async: 3600
|
||||
poll: 0
|
||||
register: upgrade_debian_job
|
||||
# By default async job return change: true
|
||||
# Force adapting the change condition for idempotency
|
||||
changed_when: false
|
||||
when: ansible_facts['os_family'] == "Debian"
|
||||
|
||||
- name: Wait for Debian upgrade
|
||||
ansible.builtin.async_status:
|
||||
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: upgrade_debian_result.changed | default(false)
|
||||
ignore_unreachable: true
|
||||
when:
|
||||
- ansible_facts['os_family'] == "Debian"
|
||||
- upgrade_debian_job.ansible_job_id is defined
|
||||
|
||||
Reference in New Issue
Block a user