Compare commits

..

17 Commits

Author SHA1 Message Date
Harwinder Sekhon 3f83e77ec7 Report updated vs already up-to-date hosts in update_system.yml
Check each host for pending packages before upgrading, then print a
per-host status (updated with the package list, up to date, or failed)
and one consolidated summary play with totals.

Failed upgrades are caught per host so they are reported instead of
dropping out of the run, and a final fail task keeps the run non-zero.
The read-only cache refresh and pending checks run with check_mode:
false, so --check works as a dry run.
2026-09-30 21:01:23 -04:00
Harwinder Sekhon 501e81dbc1 Add comments and pending-update reporting to auto_updates.yml
Documents each play/task, and adds per-host and final summary output
showing how many packages are pending on each host.
2026-09-19 19:31:59 -04:00
Harwinder Sekhon 7ad388ba50 Telegram alerts: accept credentials from extra vars or environment, report which is missing
Semaphore variable groups can pass values as extra vars or as environment
variables, and secrets can be either type. Read telegram_bot_token,
TELEGRAM_BOT_TOKEN (extra var) or TELEGRAM_BOT_TOKEN (env), same for the
chat id, and say in the "not configured" message which of the two is
missing so a wrong box or a detached variable group is visible in the log.

Claude-Session: https://claude.ai/code/session_01XEizHawueRXUcxQELRZ8kq
2026-09-06 23:15:11 -04:00
Harwinder Sekhon 5aaa7020ac Fix playbook bugs from review, add Telegram alerts to health and service checks
Review fixes:
- Availability plays: "ping_result is succeeded" is true for unreachable
  hosts, so offline hosts were added to reachable_hosts and failed the
  next play. Test reachability explicitly (7 playbooks).
- bootstrap_lxc: vim-enhanced and mtr-tiny package names, enable CRB next
  to EPEL, add the root key without replacing authorized_keys, validate
  sshd_config edits and restart sshd only on change.
- backup_docker: the command module does not expand $(...); fix rsync
  filter order and add --exclude=*; restart only the containers that were
  stopped, even when rsync fails; read the storage address from the
  inventory; keep semaphore and semaphore-db running.
- new_lxc: community.general.proxmox no longer exists, use
  community.proxmox; node is mini-pc; valid hostname; password or token
  auth; let the module pick the VMID; longer timeout.
- proxmox-backup: surface the vzdump exit code, let vzdump prune with
  keep-last, drop the rename/prune steps that globbed *.zstd.
- time-region: chrony regex no longer comments out its own server line;
  /etc/localtime as a symlink; reuse facts across plays.
- update_pihole_blocklists: FQCN, become: true, changed_when.
- ansible.cfg gathering = smart; update_system only pings ubuntu/rocky.

Telegram alerts:
- health_check: filesystems above disk_warn_pct (default 80), excluding
  efivarfs, and offline hosts.
- service_check: services not running.
- One message per run from localhost, credentials from Semaphore
  (TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID), no_log on the send task,
  prints the alert text when Telegram is not configured.

Claude-Session: https://claude.ai/code/session_01XEizHawueRXUcxQELRZ8kq
2026-09-06 23:06:18 -04:00
harry 0b7e136b24 Update inventories/all.yml 2026-09-07 02:13:22 +00:00
harry b994343825 Update inventories/all.yml 2026-09-07 02:00:43 +00:00
harry 9b57eb1815 Update inventories/all.yml 2026-09-07 01:57:38 +00:00
Harwinder Sekhon 1d351c382a discover_guests: treat any 192.168.x address as LAN, the lab now spans several subnets
Claude-Session: https://claude.ai/code/session_01JEczyvRjfakkiDNkbZA7PR
2026-09-06 20:30:10 -04:00
Harwinder Sekhon 22311ac70e Add discover_guests playbook and README with Semaphore scheduling guide
Claude-Session: https://claude.ai/code/session_01JEczyvRjfakkiDNkbZA7PR
2026-09-06 20:30:10 -04:00
Harwinder Sekhon ce7e2bc205 Add guest playbooks: ssh hardening, reboot check, auto updates, service check, guest agent, admin user
Claude-Session: https://claude.ai/code/session_01JEczyvRjfakkiDNkbZA7PR
2026-09-06 20:30:10 -04:00
Harwinder Sekhon 686080a493 Add repo foundation: proxmox/lxc/vms groups, ansible.cfg, collections, lint configs
- inventory: add proxmox host, lxc and vms groups, critical_services per host
- retarget bootstrap and time-region to ubuntu:rocky so the Proxmox host is untouched
- add ansible.cfg, collections/requirements.yml, .gitignore, .yamllint, .ansible-lint
- remove stale root new_lxc.yml duplicate and the Nextcloud upgrade playbook

Claude-Session: https://claude.ai/code/session_01JEczyvRjfakkiDNkbZA7PR
2026-09-06 20:30:10 -04:00
harry 3e232a30df Update inventories/all.yml 2026-07-04 16:37:28 +00:00
harry 231ae03407 Update inventories/all.yml 2026-06-04 19:25:35 +00:00
harry fa959afa96 Update inventories/all.yml 2026-06-04 19:24:08 +00:00
harwinder 9665dd3bfe Fix health check: RAM formula, exclude snap/overlay, use ASCII output 2026-05-16 17:38:45 -04:00
harwinder d9a1403f52 Add disk and RAM health check playbook
Reports disk usage, memory, and swap per host with warnings
for partitions exceeding 85% usage.
2026-05-16 17:34:36 -04:00
harwinder cf0e62d80c Refactor inventory into OS groups and update playbooks accordingly
Group hosts by OS (ubuntu/rocky) in inventory, refactor playbooks to
use group-targeted plays instead of ansible_os_family conditionals,
add bot host, and add Docker volume backup playbook via rsync to storage.
2026-05-16 17:31:29 -04:00
27 changed files with 2009 additions and 475 deletions
+10
View File
@@ -0,0 +1,10 @@
---
profile: basic
exclude_paths:
- .claude/
- .venv/
- collections/ansible_collections/
skip_list:
- yaml[line-length]
+8
View File
@@ -0,0 +1,8 @@
*.retry
__pycache__/
*.pyc
.vault_pass*
.ansible/
collections/ansible_collections/
.claude/
.venv/
+24
View File
@@ -0,0 +1,24 @@
---
extends: default
ignore: |
.claude/
.venv/
collections/ansible_collections/
rules:
line-length:
max: 160
truthy:
allowed-values: ['true', 'false']
document-start:
level: warning
braces:
min-spaces-inside: 0
max-spaces-inside: 1
comments:
min-spaces-from-content: 1
comments-indentation: disable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true
+158
View File
@@ -0,0 +1,158 @@
# Homelab Ansible
Playbooks for a Proxmox homelab: Rocky Linux 9 and Ubuntu LXC containers,
Ubuntu VMs and the Proxmox host itself. Everything is run unattended from
Semaphore.
## Layout
```
ansible.cfg defaults for local runs (Semaphore passes its own inventory/key)
collections/requirements.yml collections Semaphore installs before each run
inventories/all.yml the homelab inventory (groups below)
inventories/new-lxc.yml throwaway inventory for a freshly created container
playbooks/*.yml one self-contained playbook per task, no roles
```
## Inventory groups
| Group | Members | Notes |
|-----------|-----------------------------|-------|
| `ubuntu` | Ubuntu containers and VMs | apt plays |
| `rocky` | Rocky Linux 9 containers | dnf plays |
| `proxmox` | `pve` | the Proxmox host |
| `lxc` | containers only | subset of ubuntu/rocky |
| `vms` | QEMU VMs only | subset of ubuntu/rocky, currently empty |
A host may sit in several groups. `ansible_host` is defined once under the OS
group. `critical_services` on a host entry feeds `service_check.yml`.
Placeholders to fill in (`grep PLACEHOLDER inventories/all.yml`): the Proxmox
address, the first Ubuntu VM, and the service lists for `webserver`, `bot` and
`storage`.
## Semaphore
- Inventory type File, path `inventories/all.yml`.
- Collections in `collections/requirements.yml` are installed automatically.
- One task template per playbook. Parameters go in as extra vars (JSON).
## Playbooks
| Playbook | Hosts | Purpose / extra vars |
|----------|-------|----------------------|
| `ping.yml` | all | connectivity check |
| `health_check.yml` | all | disk and RAM report; Telegram alert for filesystems above `disk_warn_pct` (default 80) and offline hosts |
| `update_system.yml` | ubuntu, rocky | apt dist-upgrade / dnf update; reports per host whether it was updated (with the package list) or already up to date, plus a final summary. Supports `--check` as a dry run |
| `bootstrap_lxc.yml` | ubuntu, rocky | base packages (EPEL and CRB enabled on Rocky), Ansible root key, sshd basics |
| `add_root_key.yml` | all | add a personal public key to root |
| `time-region.yml` | ubuntu, rocky | timezone, NTP on VMs, NTP disabled in LXC. `timezone_region`, `ntp_server` |
| `new_lxc.yml` | localhost | create a Rocky container via the Proxmox API. `proxmox_api_user` plus `proxmox_api_password` or `proxmox_api_token_id`/`proxmox_api_token_secret`; `lxc_hostname` |
| `backup_docker.yml` | docker | rsync compose files and volumes to storage; containers in `backup_skip_containers` (default semaphore, semaphore-db) stay up |
| `proxmox-backup.yml` | proxmox | vzdump selected guests, vzdump keeps the last `keep_last`. `guests`, `backup_mode` |
| `discover_guests.yml` | proxmox | list every guest on the host and flag those missing from the inventory; `adopt=true` pushes the Ansible key into unmanaged running containers |
| `update_pihole_blocklists.yml` | pihole | `pihole -up` and gravity update |
| `ssh_harden.yml` | ubuntu, rocky | sshd drop-in (key-only root login) and fail2ban sshd jail. `ssh_disable_password_auth`, `ssh_max_auth_tries` |
| `reboot_check.yml` | ubuntu, rocky | report reboot-required; reboot when `do_reboot=true`, skipping `reboot_exclude` (default `[docker]`) |
| `auto_updates.yml` | ubuntu, rocky | unattended-upgrades / dnf-automatic for security updates, no auto reboot |
| `service_check.yml` | ubuntu, rocky | check `critical_services` per host; `restart_stopped=true` starts stopped ones; Telegram alert for services not running |
| `vm_guest_agent.yml` | vms | install and enable qemu-guest-agent |
| `user_setup.yml` | ubuntu, rocky | non-root admin with sudo and SSH keys. `admin_user`, `admin_sudo_nopasswd`, `admin_ssh_keys` |
Conventions: FQCN module names, `become: true` at play level, OS handled by
targeting the `ubuntu` / `rocky` groups in separate plays, reachability check
as the first play of anything that touches many hosts, ASCII-only reports.
### Notes
- `ssh_harden.yml` only disables password login on hosts where root already
has an authorized key. It writes `/etc/ssh/sshd_config.d/00-ansible.conf`,
which takes precedence over `sshd_config` and cloud-init drop-ins.
- `reboot_check.yml` never reboots hosts in `reboot_exclude`; the default
excludes `docker` because Semaphore most likely runs there.
- `discover_guests.yml` is the answer to "did I forget to add a container". Run it after
creating anything in the Proxmox UI; with `adopt=true` it drops the Ansible key into
the new container so bootstrap can reach it. Paste the printed snippet into the inventory.
- `auto_updates.yml` and `update_system.yml` overlap. Use the first for
hands-off security patches, the second for full upgrades on demand.
## Telegram alerts
`health_check.yml` and `service_check.yml` send one Telegram message per run
when there is something to report (filesystem above the threshold, host
offline, service not running). Without credentials they print the same text
in the run log instead, so nothing breaks before the bot exists.
1. Create a bot with `@BotFather` and copy the token.
2. Send the bot a message (or add it to a group), then open
`https://api.telegram.org/bot<TOKEN>/getUpdates` and read `chat.id`.
3. In Semaphore, create a variable group with the two values as secret
environment variables `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` (extra
vars `telegram_bot_token` / `telegram_chat_id` work too) and attach it to
the Health check and Service check templates.
The send task runs with `no_log`, so the token never appears in the run log;
a rejected call fails the run with the Telegram error text only. To add the
alert to another playbook, copy the final `Telegram alert` play from
`health_check.yml` and change the lines the template builds.
## Semaphore schedules
One template per row. Cron times are in Semaphore's own timezone (UTC inside
the container unless `TZ` is set). Reports run in the morning, anything that
restarts services or reboots runs at night. Semaphore does not chain templates,
so the gaps between related runs are deliberate.
Daily:
| Template | Playbook | Cron | Extra vars |
|----------|----------|------|------------|
| Health check | `health_check.yml` | `0 7 * * *` | |
| Service check | `service_check.yml` | `5 7 * * *` | |
| Discover guests | `discover_guests.yml` | `10 7 * * *` | |
| Reboot check (report) | `reboot_check.yml` | `15 7 * * *` | |
| Docker backup | `backup_docker.yml` | `0 2 * * *` | |
Weekly:
| Template | Playbook | Cron | Extra vars |
|----------|----------|------|------------|
| System update | `update_system.yml` | `0 3 * * 0` | |
| Reboot check (reboot) | `reboot_check.yml` | `30 3 * * 0` | `{"do_reboot": true}` |
| Proxmox backup | `proxmox-backup.yml` | `0 1 * * 6` | |
| Pi-hole update | `update_pihole_blocklists.yml` | `0 4 * * 0` | |
Monthly (drift check, also run after adding a host):
| Template | Playbook | Cron | Extra vars |
|----------|----------|------|------------|
| SSH hardening | `ssh_harden.yml` | `0 5 1 * *` | |
| Auto updates | `auto_updates.yml` | `30 5 1 * *` | |
On demand only: `bootstrap_lxc.yml`, `add_root_key.yml`, `time-region.yml`,
`new_lxc.yml`, `user_setup.yml`, `vm_guest_agent.yml`, `ping.yml`.
`backup_docker.yml` stops the containers on the docker host while it copies
volumes, except those in `backup_skip_containers` (default `semaphore` and
`semaphore-db`, because Semaphore runs there; their volumes are copied live).
`proxmox-backup.yml` has the same trap: never list the guest that runs
Semaphore with `backup_mode: stop`, use `snapshot` for it.
## Adding a new container
1. Create it in the Proxmox UI (or with `new_lxc.yml`).
2. Run the Discover guests template with `{"adopt": true}`. It pushes the
Ansible key into the container and prints an inventory snippet.
3. Paste the snippet into `inventories/all.yml` under the group it names, and
add the name under `lxc` (or `vms`).
4. Run `bootstrap_lxc.yml`, then `ssh_harden.yml` and `auto_updates.yml`.
## Local checks
```
uv venv .venv && uv pip install --python .venv/bin/python ansible ansible-lint yamllint
.venv/bin/ansible-galaxy collection install -r collections/requirements.yml
.venv/bin/ansible-inventory --graph
.venv/bin/ansible-playbook --syntax-check playbooks/*.yml
.venv/bin/yamllint . && .venv/bin/ansible-lint
```
+12
View File
@@ -0,0 +1,12 @@
[defaults]
inventory = inventories/all.yml
host_key_checking = False
retry_files_enabled = False
interpreter_python = auto_silent
callback_result_format = yaml
forks = 10
# Gather facts once per host per run; later plays reuse them.
gathering = smart
[ssh_connection]
pipelining = True
+8
View File
@@ -0,0 +1,8 @@
---
# Semaphore installs these automatically before each run
# (it only looks at collections/requirements.yml and roles/requirements.yml).
# Unpinned on purpose until the Semaphore ansible-core version is known.
collections:
- name: community.general
- name: community.proxmox # the Proxmox modules left community.general in 12.0.0
- name: ansible.posix
+51 -8
View File
@@ -1,20 +1,63 @@
# Homelab inventory.
#
# Group layout:
# ubuntu - Ubuntu LXC containers and VMs
# rocky - Rocky Linux 9 LXC containers
# proxmox - the Proxmox VE host
# lxc - the subset of ubuntu/rocky hosts that are LXC containers
# vms - the subset of ubuntu/rocky hosts that are QEMU VMs
#
# A host may belong to several groups. Define ansible_host once, under the OS
# group; lxc/vms list names only. Entries marked PLACEHOLDER need real values.
#
# critical_services is read by playbooks/service_check.yml.
---
all: all:
vars: vars:
ansible_user: root ansible_user: root
children: children:
ubuntu: ubuntu:
hosts: hosts:
webserver: # webserver:
ansible_host: 192.168.55.10 # ansible_host: 192.168.55.10
# critical_services: # PLACEHOLDER: verify service names
# - nginx
docker: docker:
ansible_host: 192.168.55.11 ansible_host: 192.168.0.11
critical_services:
- docker
- containerd
# ubuntu-vm1: # PLACEHOLDER: first Ubuntu VM, also list under vms
# ansible_host: 192.168.2.XXX
# critical_services: []
rocky: rocky:
hosts: hosts:
courses-lxc:
ansible_host: 192.168.0.31
critical_services:
- httpd
- mariadb
pihole: pihole:
ansible_host: 192.168.2.2 ansible_host: 192.168.0.2
critical_services:
- pihole-FTL
bot: bot:
ansible_host: 192.168.2.174 ansible_host: 192.168.0.174
critical_services: [myapp] # PLACEHOLDER: add the bot's service name
storage: storage:
ansible_host: 192.168.2.115 ansible_host: 192.168.0.115
# nextcloud: critical_services: [smb] # PLACEHOLDER: e.g. nfs-server, smb
# ansible_host: 192.168.2.120 proxmox:
hosts:
pve:
ansible_host: 192.168.0.254 # PLACEHOLDER: verify
lxc:
hosts:
# webserver:
docker:
pihole:
bot:
storage:
vms:
hosts:
# ubuntu-vm1: # PLACEHOLDER: uncomment together with the ubuntu entry above
+1
View File
@@ -1,3 +1,4 @@
---
all: all:
children: children:
newlxcs: newlxcs:
-75
View File
@@ -1,75 +0,0 @@
---
- name: Build Dynamic Rocky Linux LXC Container
hosts: localhost
gather_facts: false
vars:
# --- Container Specifications ---
lxc_hostname: "new_lxc"
lxc_template: "local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz"
lxc_cores: 2
lxc_memory: 1024
lxc_storage: "TB"
lxc_disk_size: 10
lxc_bridge: "vmbr0"
lxc_dns: "192.168.2.2"
target_node: "pve"
# --- Security & Access ---
# The public key to inject into the /root/.ssh/authorized_keys of the new container
lxc_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key"
tasks:
- name: Get the next available VMID from the Proxmox cluster
ansible.builtin.uri:
url: "https://{{ proxmox_api_host }}:8006/api2/json/cluster/nextid"
method: GET
headers:
Authorization: "PVEAPIToken {{ proxmox_api_user }}={{ proxmox_api_password }}"
validate_certs: false
return_content: true
register: nextid_response
- name: Extract the dynamic VMID from the API response
ansible.builtin.set_fact:
dynamic_vmid: "{{ nextid_response.json.data }}"
- name: Create the Rocky Linux LXC container
community.general.proxmox:
api_host: "{{ proxmox_api_host }}"
api_user: "{{ proxmox_api_user }}"
api_password: "{{ proxmox_api_password }}"
node: "{{ target_node }}"
vmid: "{{ dynamic_vmid }}"
hostname: "{{ lxc_hostname }}"
ostemplate: "{{ lxc_template }}"
cores: "{{ lxc_cores }}"
memory: "{{ lxc_memory }}"
storage: "{{ lxc_storage }}"
disk: "{{ lxc_disk_size }}"
# Configure network for DHCP on the specified bridge
netif: '{"net0":"name=eth0,bridge={{ lxc_bridge }},ip=dhcp"}'
# Explicitly set the DNS nameserver
nameserver: "{{ lxc_dns }}"
# Inject the SSH public key
pubkey: "{{ lxc_ssh_key }}"
# Highly recommended security flag for Proxmox containers
unprivileged: true
state: present
- name: Ensure the new LXC container is powered on
community.general.proxmox:
api_host: "{{ proxmox_api_host }}"
api_user: "{{ proxmox_api_user }}"
api_password: "{{ proxmox_api_password }}"
vmid: "{{ dynamic_vmid }}"
state: started
- name: Output the assigned VMID to the Semaphore UI logs
ansible.builtin.debug:
msg: "Successfully built {{ lxc_hostname }} (Rocky Linux 9) with dynamic ID: {{ dynamic_vmid }}"
+2 -2
View File
@@ -1,11 +1,11 @@
--- ---
- name: Add plain text public SSH key to root user across mixed OS - name: Add plain text public SSH key to root user across mixed OS
hosts: all hosts: all
become: yes # Elevates privileges to root via sudo become: true
vars: vars:
# Paste your PUBLIC key string inside the quotes below # Paste your PUBLIC key string inside the quotes below
my_plain_text_public_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDbFKxd6W+gHcNrugkxMwvixGmk3LxtsMEnSdaFp4L78WZ4VhdZrsnSJQ/rViT+vuc6K5SeugtggfQ90L25UyyMv2BhLZaoMS2dpoVJEZon/Y2Hemm11vdEApAK/u0kPTTM5JB0/TZfpoP9sr4pmRIUruRXBhLDvtUV9tJvKSzbhChtB+vBg+MpywG9VvDP6eCVpFaP43RcAFtJR7irJmexd5m4CVhIFaWNeTbbe8YI08i2gDinYl95ygu9i/TorQKLWme2v9ejxQFfJTWGaXHnQ18W0X94Ns4a4OccGcvJ8JmvYsaAPRAOSveq1Fpdw1VXHSKigTmRl6pHWd9/rq6K6Ojctbjo1kHiNHdLKLZbLDeM0OQlzNl74snsImmZddY59wnOKyHtfY0ZI7sQ+7A/0cSaMY6h7lt2xK0WmayuoT8UtKZlItZHCkOA5bV29arI/HTz+yR/LW8SihPPFpVv5aDiOUs0PYd9j0+CtULBJeYpMQJpkD93g06vgEJjhUOBZ6JmilxVDihDARU0x0xiRCQJcq4Nhk/x3GzPe2kkrTI+nZFaQn1K8hcBdSyA12/xAG8Fh1SMCxLwJdCNuL3gQoYd6XPzKqn6BIqAgiyq5DbHhO6mehOye4Rro6i/H1UkZe862QH/R5uH+8Z2/FYRpiCPzQyLwrqx/eVfT7JMFQ== harwinder@harwinder.net" my_plain_text_public_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDbFKxd6W+gHcNrugkxMwvixGmk3LxtsMEnSdaFp4L78WZ4VhdZrsnSJQ/rViT+vuc6K5SeugtggfQ90L25UyyMv2BhLZaoMS2dpoVJEZon/Y2Hemm11vdEApAK/u0kPTTM5JB0/TZfpoP9sr4pmRIUruRXBhLDvtUV9tJvKSzbhChtB+vBg+MpywG9VvDP6eCVpFaP43RcAFtJR7irJmexd5m4CVhIFaWNeTbbe8YI08i2gDinYl95ygu9i/TorQKLWme2v9ejxQFfJTWGaXHnQ18W0X94Ns4a4OccGcvJ8JmvYsaAPRAOSveq1Fpdw1VXHSKigTmRl6pHWd9/rq6K6Ojctbjo1kHiNHdLKLZbLDeM0OQlzNl74snsImmZddY59wnOKyHtfY0ZI7sQ+7A/0cSaMY6h7lt2xK0WmayuoT8UtKZlItZHCkOA5bV29arI/HTz+yR/LW8SihPPFpVv5aDiOUs0PYd9j0+CtULBJeYpMQJpkD93g06vgEJjhUOBZ6JmilxVDihDARU0x0xiRCQJcq4Nhk/x3GzPe2kkrTI+nZFaQn1K8hcBdSyA12/xAG8Fh1SMCxLwJdCNuL3gQoYd6XPzKqn6BIqAgiyq5DbHhO6mehOye4Rro6i/H1UkZe862QH/R5uH+8Z2/FYRpiCPzQyLwrqx/eVfT7JMFQ== harwinder@harwinder.net" # yamllint disable-line rule:line-length
tasks: tasks:
- name: Ensure the public key is present in root's authorized_keys - name: Ensure the public key is present in root's authorized_keys
+238
View File
@@ -0,0 +1,238 @@
---
# Enables automatic security updates: unattended-upgrades on Ubuntu and
# dnf-automatic on Rocky Linux. Neither reboots automatically; use
# reboot_check.yml for that.
# Play 1: probe every ubuntu/rocky host and sort them into a "reachable_hosts"
# group so the two update plays below can skip anything that's offline.
- name: Check Host Availability
hosts: ubuntu:rocky
gather_facts: false
ignore_unreachable: true
tasks:
# Ping each host; failures/unreachables are swallowed so the play keeps
# going instead of aborting the whole run.
- name: Ping hosts to check availability
ansible.builtin.ping:
register: ping_result
ignore_errors: true
ignore_unreachable: true
# Dynamically creates/populates the "reachable_hosts" group, used by the
# Ubuntu and Rocky plays' host patterns (ubuntu:&reachable_hosts etc.).
- name: Add reachable hosts to group
ansible.builtin.group_by:
key: reachable_hosts
when: ping_result is reachable and ping_result is succeeded
# Print a message for any host that failed the ping, so offline hosts
# are visible in the run output.
- name: Report offline hosts
ansible.builtin.debug:
msg: "{{ inventory_hostname }} - OFFLINE"
when: ping_result is unreachable or ping_result is failed
delegate_to: localhost
# Play 2: install and configure unattended-upgrades on Ubuntu hosts, then
# report its status. Does not enable automatic reboots.
- name: Auto Updates - Ubuntu
hosts: ubuntu:&reachable_hosts
become: true
gather_facts: false
tasks:
# Make sure the unattended-upgrades package is present.
- name: Install unattended-upgrades
ansible.builtin.apt:
name: unattended-upgrades
state: present
update_cache: true
cache_valid_time: 3600
# Turn on apt's periodic package-list refresh, unattended-upgrade run,
# and weekly autoclean.
- name: Enable periodic package list refresh and upgrades
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
// Managed by Ansible (playbooks/auto_updates.yml)
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
owner: root
group: root
mode: '0644'
# Local overrides layered on top of the stock 50unattended-upgrades
# config: clean up unused deps, but never reboot automatically.
- name: Configure unattended-upgrades behaviour
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/52unattended-upgrades-local
content: |
// Managed by Ansible (playbooks/auto_updates.yml)
// Allowed origins (security / ESM) come from the stock 50unattended-upgrades.
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
owner: root
group: root
mode: '0644'
# Enable and start the systemd timers that actually trigger apt's
# periodic update/upgrade cycle.
- name: Enable apt timers
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop:
- apt-daily.timer
- apt-daily-upgrade.timer
# Simulate an unattended-upgrade run (no changes made) so the report
# below shows what would actually be upgraded.
- name: Dry-run unattended-upgrade
ansible.builtin.command: unattended-upgrade --dry-run -v
register: uu_dry_run
changed_when: false
failed_when: false
# Pull the package list out of the dry-run's "Packages that will be
# upgraded: ..." line, used for the per-host and final summary reports.
- name: Extract pending package list
ansible.builtin.set_fact:
pending_update_packages: "{{ (uu_dry_run.stdout | regex_findall('Packages that will be upgraded:\\s*(.*)') | first | default('')).split() }}"
- name: Compute pending update count
ansible.builtin.set_fact:
pending_update_count: "{{ pending_update_packages | length }}"
# Grab the current state of the apt-daily timers for the report.
- name: List apt timers # noqa: command-instead-of-module (read-only listing)
ansible.builtin.command: systemctl list-timers apt-daily* --no-pager --all
register: apt_timers
changed_when: false
# Summarize timer status, pending update count, and the tail of the
# dry-run output per host.
- name: Print report
ansible.builtin.debug:
msg: |
======================================================
HOST: {{ inventory_hostname }} ({{ ansible_host }})
======================================================
TIMERS:
{{ apt_timers.stdout }}
------------------------------------------------------
PENDING UPDATES: {{ pending_update_count }}
{{ pending_update_packages | join(', ') }}
------------------------------------------------------
DRY RUN (last lines):
{{ (uu_dry_run.stdout_lines + uu_dry_run.stderr_lines)[-6:] | join('\n') }}
======================================================
# Play 3: install and configure dnf-automatic on Rocky Linux hosts (security
# updates only), then report its status.
- name: Auto Updates - Rocky Linux
hosts: rocky:&reachable_hosts
become: true
gather_facts: false
tasks:
# Make sure the dnf-automatic package is present.
- name: Install dnf-automatic
ansible.builtin.dnf:
name: dnf-automatic
state: present
# Set dnf-automatic to download and apply security updates only, and
# log output to stdout (captured by the systemd journal).
- name: Configure dnf-automatic for security updates
community.general.ini_file:
path: /etc/dnf/automatic.conf
section: "{{ item.section }}"
option: "{{ item.option }}"
value: "{{ item.value }}"
mode: '0644'
loop:
- { section: commands, option: upgrade_type, value: security }
- { section: commands, option: download_updates, value: "yes" }
- { section: commands, option: apply_updates, value: "yes" }
- { section: emitters, option: emit_via, value: stdio }
loop_control:
label: "{{ item.section }}/{{ item.option }} = {{ item.value }}"
# Enable and start the systemd timer that triggers dnf-automatic runs.
- name: Enable dnf-automatic timer
ansible.builtin.systemd:
name: dnf-automatic.timer
enabled: true
state: started
# Grab the current state of the dnf-automatic timer for the report.
- name: List dnf-automatic timers # noqa: command-instead-of-module (read-only listing)
ansible.builtin.command: systemctl list-timers dnf-automatic* --no-pager --all
register: dnf_timers
changed_when: false
# Check what security updates are pending. rc 100 means updates are
# available, rc 0 means none, so only other codes count as a failure.
- name: Check for pending security updates
ansible.builtin.command: dnf check-update --security
register: dnf_check
changed_when: false
failed_when: dnf_check.rc not in [0, 100]
# Pull the "name.arch version repo" lines out of the check-update
# output, used for the per-host and final summary reports.
- name: Extract pending package list
ansible.builtin.set_fact:
pending_update_packages: "{{ dnf_check.stdout_lines | select('match', '^\\S+\\.\\S+\\s+\\S+\\s+\\S+$') | map('regex_replace', '^(\\S+)\\s.*$', '\\1') | list }}"
- name: Compute pending update count
ansible.builtin.set_fact:
pending_update_count: "{{ pending_update_packages | length }}"
# Summarize timer status and pending update count per host.
- name: Print report
ansible.builtin.debug:
msg: |
======================================================
HOST: {{ inventory_hostname }} ({{ ansible_host }})
======================================================
TIMERS:
{{ dnf_timers.stdout }}
------------------------------------------------------
PENDING UPDATES: {{ pending_update_count }}
{{ pending_update_packages | join(', ') }}
======================================================
# Play 4: after both OS-specific plays have run, print one consolidated
# summary of which hosts have pending updates and how many packages.
- name: Summary - Pending Updates
hosts: localhost
gather_facts: false
tasks:
- name: Print hosts with pending updates
ansible.builtin.debug:
msg: "{{ item }} - {{ hostvars[item].pending_update_count }} package(s) pending: {{ hostvars[item].pending_update_packages | join(', ') }}"
loop: "{{ groups['reachable_hosts'] | default([]) }}"
when: hostvars[item].pending_update_count | default(0) | int > 0
- name: Print hosts fully up to date
ansible.builtin.debug:
msg: "{{ item }} - up to date"
loop: "{{ groups['reachable_hosts'] | default([]) }}"
when: hostvars[item].pending_update_count is defined and hostvars[item].pending_update_count | int == 0
- name: Print totals
ansible.builtin.debug:
msg: >-
{{ groups['reachable_hosts'] | default([]) | length }} host(s) reachable,
{{ groups['reachable_hosts'] | default([])
| map('extract', hostvars, 'pending_update_count') | select('defined') | map('int') | select('>', 0) | list | length }}
with pending updates,
{{ groups['reachable_hosts'] | default([])
| map('extract', hostvars, 'pending_update_count') | select('defined') | map('int') | sum }}
package(s) total pending.
+95 -37
View File
@@ -1,62 +1,120 @@
--- ---
- name: Backup Docker containers and volumes to storage # Backs up the docker host to the storage container: compose files and
# configs from compose_dir while everything runs, then the Docker volumes
# with the containers stopped so the copy is consistent.
#
# Containers in backup_skip_containers are never stopped. Semaphore runs on
# this host, so it and its database stay up (their volumes are copied live).
# root on the docker host needs an SSH key that is authorized on backup_host.
#
# Extra vars (optional):
# backup_host - inventory name of the destination (default storage)
# backup_root - directory on backup_host (default /mnt/storage/Docker_backups)
# backup_skip_containers - default [semaphore, semaphore-db]
- name: Backup Docker compose files and volumes to storage
hosts: docker hosts: docker
become: true become: true
gather_facts: false
vars: vars:
backup_dest: "192.168.2.115:/mnt/storage/Docker_backups" backup_host: storage
compose_dir: "/home/harry" backup_root: /mnt/storage/Docker_backups
volumes_dir: "/var/lib/docker/volumes" backup_addr: "{{ hostvars[backup_host].ansible_host }}"
backup_skip_containers:
- semaphore
- semaphore-db
compose_dir: /home/harry
volumes_dir: /var/lib/docker/volumes
rsync_ssh: ssh -o StrictHostKeyChecking=accept-new
tasks: tasks:
- name: Ensure backup destination directories exist - name: Ensure backup destination directories exist
ansible.builtin.command: ansible.builtin.file:
cmd: ssh -o StrictHostKeyChecking=no root@192.168.2.115 "mkdir -p /mnt/storage/Docker_backups/compose /mnt/storage/Docker_backups/volumes" path: "{{ backup_root }}/{{ item }}"
changed_when: false state: directory
mode: '0755'
loop: [compose, volumes]
delegate_to: "{{ backup_host }}"
# rsync applies the first matching rule: excludes first, then the rule
# that lets it descend into directories, then the wanted files, then
# everything else is dropped.
- name: Rsync compose files and configs (safe while running) - name: Rsync compose files and configs (safe while running)
ansible.builtin.shell: | ansible.builtin.command:
rsync -az --delete \ argv:
--include='*/' \ - rsync
--include='docker-compose.yml' \ - -az
--include='compose.yml' \ - --delete
--include='.env' \ - --prune-empty-dirs
--include='*.conf' \ - -e
--include='*.yml' \ - "{{ rsync_ssh }}"
--include='*.yaml' \ - --exclude=node_modules/
--include='*.json' \ - --exclude=.git/
--exclude='node_modules/' \ - --exclude=__pycache__/
--exclude='.git/' \ - --include=*/
--exclude='__pycache__/' \ - --include=docker-compose.yml
{{ compose_dir }}/ root@{{ backup_dest }}/compose/ - --include=compose.yml
register: compose_sync - --include=.env
- --include=*.conf
- --include=*.yml
- --include=*.yaml
- --include=*.json
- --exclude=*
- "{{ compose_dir }}/"
- "root@{{ backup_addr }}:{{ backup_root }}/compose/"
changed_when: true
- name: Get list of running containers - name: List running containers
ansible.builtin.command: docker ps -q ansible.builtin.command:
argv: [docker, ps, --format, "{{ '{{' }}.Names{{ '}}' }}"]
register: running_containers register: running_containers
changed_when: false changed_when: false
- name: Stop all containers - name: Work out which containers to stop
ansible.builtin.command: docker stop $(docker ps -q) ansible.builtin.set_fact:
when: running_containers.stdout | length > 0 containers_to_stop: "{{ running_containers.stdout_lines | difference(backup_skip_containers) }}"
- name: Copy the volumes with the containers stopped
block:
- name: Stop containers
ansible.builtin.command:
argv: "{{ ['docker', 'stop'] + containers_to_stop }}"
when: containers_to_stop | length > 0
- name: Rsync Docker volumes to storage - name: Rsync Docker volumes to storage
ansible.builtin.shell: | ansible.builtin.command:
rsync -az --delete \ argv:
{{ volumes_dir }}/ root@{{ backup_dest }}/volumes/ - rsync
register: volume_sync - -az
- --delete
- -e
- "{{ rsync_ssh }}"
- "{{ volumes_dir }}/"
- "root@{{ backup_addr }}:{{ backup_root }}/volumes/"
changed_when: true
- name: Start all containers back up always:
ansible.builtin.command: docker start $(docker ps -aq) - name: Start the containers that were running before
ansible.builtin.command:
argv: "{{ ['docker', 'start'] + containers_to_stop }}"
when: containers_to_stop | length > 0
- name: Verify containers are running - name: Verify containers are running
ansible.builtin.command: docker ps --format "{{ '{{' }}.Names{{ '}}' }} - {{ '{{' }}.Status{{ '}}' }}" ansible.builtin.command:
argv: [docker, ps, --format, "{{ '{{' }}.Names{{ '}}' }} - {{ '{{' }}.Status{{ '}}' }}"]
register: container_status register: container_status
changed_when: false changed_when: false
- name: Backup summary - name: Backup summary
ansible.builtin.debug: ansible.builtin.debug:
msg: msg:
- "Compose sync: {{ 'OK' if compose_sync.rc == 0 else 'FAILED' }}" - "Destination: {{ backup_host }} ({{ backup_addr }}):{{ backup_root }}"
- "Volume sync: {{ 'OK' if volume_sync.rc == 0 else 'FAILED' }}" - "Stopped for the volume copy: {{ containers_to_stop | join(', ') if containers_to_stop else 'none' }}"
- "Containers running: {{ container_status.stdout_lines | length }}" - "Kept running: {{ backup_skip_containers | join(', ') }}"
- "Containers running: {{ container_status.stdout_lines | length }} now, {{ running_containers.stdout_lines | length }} before"
- name: Warn when fewer containers are running than before
ansible.builtin.debug:
msg: "WARNING: {{ item }} did not come back up on {{ inventory_hostname }}"
loop: "{{ running_containers.stdout_lines | difference(container_status.stdout_lines | map('regex_replace', ' - .*$', '')) }}"
+55 -59
View File
@@ -1,3 +1,9 @@
---
# Base packages, the Ansible root key and sshd basics for every Ubuntu/Rocky
# guest. Run it once after a container is created (and adopted, see
# discover_guests.yml). ssh_harden.yml then tightens sshd further through
# /etc/ssh/sshd_config.d/00-ansible.conf.
- name: Bootstrap - Install packages on Ubuntu - name: Bootstrap - Install packages on Ubuntu
hosts: ubuntu hosts: ubuntu
become: true become: true
@@ -19,7 +25,9 @@
- zip - zip
- git - git
- sudo - sudo
- tmux
- tree - tree
- bash-completion
- traceroute - traceroute
- dnsutils - dnsutils
- ca-certificates - ca-certificates
@@ -27,36 +35,46 @@
- python3-pip - python3-pip
- software-properties-common - software-properties-common
- rsync - rsync
- apt-transport-https
- sysstat - sysstat
- screen - screen
- mtr - mtr-tiny # plain "mtr" pulls in GTK on Ubuntu
- tar - tar
state: present state: present
update_cache: true update_cache: true
cache_valid_time: 3600
- name: Bootstrap - Install packages on Rocky Linux - name: Bootstrap - Install packages on Rocky Linux
hosts: rocky hosts: rocky
become: true become: true
tasks: tasks:
- name: Ensure EPEL repo is enabled # epel-release ships in the "extras" repo on Rocky/Alma/CentOS Stream.
# dnf-plugins-core provides "dnf config-manager" for the next task.
- name: Ensure EPEL release package and dnf plugins are installed
ansible.builtin.dnf: ansible.builtin.dnf:
name: epel-release name:
- epel-release
- dnf-plugins-core
state: present state: present
# EPEL packages may depend on CRB (CodeReady Builder). EPEL's install
# guide enables it together with epel-release.
- name: Enable the CRB repo
community.general.dnf_config_manager:
name: crb
state: enabled
- name: Install packages via dnf - name: Install packages via dnf
ansible.builtin.dnf: ansible.builtin.dnf:
name: name:
# BaseOS / AppStream
- tar - tar
- vim - vim-enhanced # "vim" is only a virtual provide on RHEL
- htop
- nmap - nmap
- curl - curl
- mtr - mtr
- wget - wget
- iperf3 - iperf3
- fail2ban
- net-tools - net-tools
- lsof - lsof
- unzip - unzip
@@ -73,70 +91,48 @@
- python3-pip - python3-pip
- rsync - rsync
- sysstat - sysstat
# EPEL
- htop
- fail2ban
- screen - screen
state: present state: present
update_cache: true update_cache: true
- name: Bootstrap - Configure SSH access - name: Bootstrap - Configure SSH access
hosts: all hosts: ubuntu:rocky
become: true become: true
gather_facts: false
vars:
bootstrap_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key"
sshd_service: "{{ 'ssh' if inventory_hostname in groups['ubuntu'] else 'sshd' }}"
tasks: tasks:
- name: Ensure root's .ssh directory exists - name: Add the Ansible key to root's authorized_keys
ansible.builtin.file: ansible.posix.authorized_key:
path: /root/.ssh user: root
state: directory key: "{{ bootstrap_ssh_key }}"
owner: root state: present
group: root
mode: '0700'
- name: Set authorized_keys for root # PasswordAuthentication stays on so a fresh container is still reachable
ansible.builtin.copy: # by password; ssh_harden.yml turns it off once keys are in place.
dest: /root/.ssh/authorized_keys - name: Allow key-based root login
content: |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key
owner: root
group: root
mode: '0600'
- name: Permit root login via public key
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin\s+' regexp: '^#?{{ item.option }}\s'
line: 'PermitRootLogin prohibit-password' line: "{{ item.option }} {{ item.value }}"
state: present validate: /usr/sbin/sshd -t -f %s
backup: true backup: true
loop:
- { option: PermitRootLogin, value: prohibit-password }
- { option: PubkeyAuthentication, value: "yes" }
- { option: PasswordAuthentication, value: "yes" }
loop_control:
label: "{{ item.option }} {{ item.value }}"
notify: Restart sshd
- name: Ensure PubkeyAuthentication is enabled handlers:
ansible.builtin.lineinfile: - name: Restart sshd
path: /etc/ssh/sshd_config
regexp: '^#?PubkeyAuthentication\s+'
line: 'PubkeyAuthentication yes'
state: present
- name: Ensure PasswordAuthentication is enabled
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication\s+'
line: 'PasswordAuthentication yes'
state: present
- name: Bootstrap - Restart SSH on Ubuntu
hosts: ubuntu
become: true
tasks:
- name: Restart ssh service
ansible.builtin.service: ansible.builtin.service:
name: ssh name: "{{ sshd_service }}"
state: restarted
- name: Bootstrap - Restart SSH on Rocky Linux
hosts: rocky
become: true
tasks:
- name: Restart sshd service
ansible.builtin.service:
name: sshd
state: restarted state: restarted
+215
View File
@@ -0,0 +1,215 @@
---
# Lists every container and VM on the Proxmox host and reports which ones are
# missing from inventories/all.yml. A guest counts as managed when its LAN IP
# matches an inventory ansible_host, or (if its IP is unknown, e.g. stopped)
# when its name matches an inventory hostname.
#
# With adopt=true the Ansible SSH key is pushed into every unmanaged RUNNING
# container straight from the host (pct exec), so a container created by hand
# in the Proxmox UI becomes reachable without knowing its root password.
# Afterwards: add the printed inventory snippet, then run bootstrap_lxc.yml.
#
# Extra vars (optional):
# adopt - default false
# lan_prefix - address prefix that counts as the LAN; used to pick the right address
# when a guest has several (default 192.168., i.e. any lab subnet)
# adopt_ssh_key - public key to push (default: the ansible-key from bootstrap_lxc.yml)
- name: Discover Proxmox guests not in inventory
hosts: proxmox
become: true
gather_facts: false
vars:
adopt: false
lan_prefix: "192.168."
lan_regex: "^{{ lan_prefix | regex_escape }}"
adopt_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key"
# pct "ostype" -> inventory group
group_map:
centos: rocky
ubuntu: ubuntu
debian: ubuntu
tasks:
- name: Initialise working facts
ansible.builtin.set_fact:
guest_ips: {}
guest_ostypes: {}
guest_table: []
inventory_by_ip: {}
- name: List all guests on the host
ansible.builtin.command: pvesh get /cluster/resources --type vm --output-format json
register: resources
changed_when: false
- name: Parse guest list
ansible.builtin.set_fact:
guests: "{{ resources.stdout | from_json | rejectattr('template', 'defined') | sort(attribute='vmid') | list
+ (resources.stdout | from_json | selectattr('template', 'defined') | selectattr('template', 'equalto', 0) | list) }}"
- name: Read container OS type
ansible.builtin.command: pct config {{ item.vmid }}
loop: "{{ guests | selectattr('type', 'equalto', 'lxc') | list }}"
loop_control:
label: "{{ item.vmid }} {{ item.name | default('') }}"
register: lxc_configs
changed_when: false
failed_when: false
- name: Record container OS types
ansible.builtin.set_fact:
guest_ostypes: "{{ guest_ostypes | combine({(item.item.vmid | string): ostype}) }}"
vars:
ostype: "{{ (item.stdout | default('') | regex_search('(?m)^ostype: (\\S+)', '\\1')) | default(['-'], true) | first }}"
loop: "{{ lxc_configs.results | default([]) }}"
loop_control:
label: "{{ item.item.vmid }}"
- name: Read container IP addresses
ansible.builtin.command: pct exec {{ item.vmid }} -- hostname -I
loop: "{{ guests | selectattr('type', 'equalto', 'lxc') | selectattr('status', 'equalto', 'running') | list }}"
loop_control:
label: "{{ item.vmid }} {{ item.name | default('') }}"
register: lxc_ips
changed_when: false
failed_when: false
- name: Record container IP addresses
ansible.builtin.set_fact:
guest_ips: "{{ guest_ips | combine({(item.item.vmid | string): lan_ip}) }}"
vars:
all_ips: "{{ item.stdout | default('') | split }}"
lan_ip: "{{ all_ips | select('match', lan_regex) | first | default(all_ips | first | default('-')) }}"
loop: "{{ lxc_ips.results | default([]) }}"
loop_control:
label: "{{ item.item.vmid }}"
- name: Read VM IP addresses via the guest agent
ansible.builtin.command: qm guest cmd {{ item.vmid }} network-get-interfaces
loop: "{{ guests | selectattr('type', 'equalto', 'qemu') | selectattr('status', 'equalto', 'running') | list }}"
loop_control:
label: "{{ item.vmid }} {{ item.name | default('') }}"
register: qemu_ips
changed_when: false
failed_when: false
- name: Record VM IP addresses
ansible.builtin.set_fact:
guest_ips: "{{ guest_ips | combine({(item.item.vmid | string): lan_ip}) }}"
vars:
agent_ok: "{{ item.rc == 0 and (item.stdout | default('') | trim) is match('^\\[') }}"
all_ips: >-
{{ ((item.stdout | from_json) if agent_ok else [])
| map(attribute='ip-addresses', default=[]) | flatten
| selectattr('ip-address-type', 'equalto', 'ipv4')
| map(attribute='ip-address') | reject('match', '^127\\.') | list }}
lan_ip: "{{ all_ips | select('match', lan_regex) | first | default(all_ips | first | default('-')) }}"
loop: "{{ qemu_ips.results | default([]) }}"
loop_control:
label: "{{ item.item.vmid }}"
- name: Index inventory hosts by address
ansible.builtin.set_fact:
inventory_by_ip: "{{ inventory_by_ip | combine({hostvars[item].ansible_host: item}) }}"
loop: "{{ groups['all'] }}"
when: hostvars[item].ansible_host is defined
- name: Assemble guest table
ansible.builtin.set_fact:
guest_table: "{{ guest_table + [guest_entry] }}"
vars:
vmid: "{{ item.vmid | string }}"
guest_name: "{{ item.name | default('-') }}"
guest_ip: "{{ guest_ips[vmid] | default('-') }}"
guest_ostype: "{{ guest_ostypes[vmid] | default('-') }}"
inventory_match: "{{ inventory_by_ip[guest_ip] | default(guest_name if guest_name in groups['all'] else '') }}"
guest_entry:
vmid: "{{ vmid }}"
type: "{{ item.type }}"
status: "{{ item.status }}"
name: "{{ guest_name }}"
ip: "{{ guest_ip }}"
group: "{{ (group_map[guest_ostype] | default(guest_ostype)) if item.type == 'lxc' else 'ubuntu (verify) + vms' }}"
inventory_name: "{{ inventory_match }}"
loop: "{{ guests }}"
loop_control:
label: "{{ item.vmid }} {{ item.name | default('') }}"
- name: Collect unmanaged guests
ansible.builtin.set_fact:
unmanaged: "{{ guest_table | selectattr('inventory_name', 'equalto', '') | list }}"
- name: Print guest report
ansible.builtin.debug:
msg: |
==========================================================================================
PROXMOX GUESTS vs INVENTORY (host: {{ inventory_hostname }})
==========================================================================================
VMID TYPE STATUS NAME IP GROUP INVENTORY
{% for g in guest_table %}
{{ '%-5s %-5s %-8s %-18s %-16s %-9s %s' | format(g.vmid, g.type, g.status, g.name, g.ip, g.group.split(' ')[0],
('managed as ' ~ g.inventory_name) if g.inventory_name else 'NOT IN INVENTORY') }}
{% endfor %}
------------------------------------------------------------------------------------------
TOTAL: {{ guest_table | length }} MANAGED: {{ (guest_table | length) - (unmanaged | length) }} UNMANAGED: {{ unmanaged | length }}
==========================================================================================
- name: Warn on unmanaged guests
ansible.builtin.debug:
msg: "WARNING: {{ item.type }} {{ item.vmid }} ({{ item.name }}, {{ item.status }}, ip {{ item.ip }}) is not in inventories/all.yml"
loop: "{{ unmanaged }}"
loop_control:
label: "{{ item.vmid }} {{ item.name }}"
- name: Suggested inventory entries for unmanaged guests
ansible.builtin.debug:
msg: |
Add to inventories/all.yml under the OS group shown, and list the name under lxc or vms:
{% for g in unmanaged %}
# {{ g.type }} {{ g.vmid }} -> group: {{ g.group }}
{{ g.name }}:
ansible_host: {{ g.ip if g.ip != '-' else lan_prefix ~ 'XXX # start the guest and rerun to discover its address' }}
critical_services: []
{% endfor %}
when: unmanaged | length > 0
- name: Push the Ansible SSH key into unmanaged running containers
ansible.builtin.shell: >-
pct exec {{ item.vmid }} -- sh -c
'mkdir -p /root/.ssh &&
chmod 700 /root/.ssh &&
touch /root/.ssh/authorized_keys &&
chmod 600 /root/.ssh/authorized_keys &&
if ! grep -qxF "{{ adopt_ssh_key }}" /root/.ssh/authorized_keys; then
echo "{{ adopt_ssh_key }}" >> /root/.ssh/authorized_keys && echo KEY_ADDED; fi;
command -v sshd >/dev/null 2>&1 && echo SSHD_OK || echo NO_SSHD'
loop: "{{ unmanaged | selectattr('type', 'equalto', 'lxc') | selectattr('status', 'equalto', 'running') | list }}"
loop_control:
label: "{{ item.vmid }} {{ item.name }}"
register: adopt_result
changed_when: "'KEY_ADDED' in adopt_result.stdout"
when: adopt | bool
- name: Adoption summary
ansible.builtin.debug:
msg: >-
{{ item.item.vmid }} ({{ item.item.name }}) -
key {{ 'ADDED' if 'KEY_ADDED' in item.stdout else 'already present' }},
{{ 'sshd present' if 'SSHD_OK' in item.stdout else 'WARNING: no sshd in the container - install openssh-server before Ansible can reach it' }}
- next: add it to inventories/all.yml and run bootstrap_lxc.yml
loop: "{{ adopt_result.results | default([]) }}"
loop_control:
label: "{{ item.item.vmid }}"
when:
- adopt | bool
- item.rc is defined
- name: Note about unmanaged VMs
ansible.builtin.debug:
msg: "NOTE: {{ item.type }} {{ item.vmid }} ({{ item.name }}) is a VM; adopt only handles containers. Add its key via cloud-init or the console."
loop: "{{ unmanaged | selectattr('type', 'equalto', 'qemu') | list }}"
loop_control:
label: "{{ item.vmid }} {{ item.name }}"
when: adopt | bool
+93 -7
View File
@@ -1,4 +1,14 @@
--- ---
# Disk and RAM report for every reachable host. Filesystems above
# disk_warn_pct and hosts that did not answer are printed as warnings and,
# when Telegram credentials are present, sent as one Telegram message per run.
#
# Extra vars (optional):
# disk_warn_pct - default 80
# telegram_bot_token / TELEGRAM_BOT_TOKEN - extra var or environment variable
# telegram_chat_id / TELEGRAM_CHAT_ID - extra var or environment variable
# Without both Telegram values the playbook only prints the report.
- name: Check Host Availability - name: Check Host Availability
hosts: all hosts: all
gather_facts: false gather_facts: false
@@ -14,12 +24,12 @@
- name: Add reachable hosts to group - name: Add reachable hosts to group
ansible.builtin.group_by: ansible.builtin.group_by:
key: reachable_hosts key: reachable_hosts
when: ping_result is succeeded when: ping_result is reachable and ping_result is succeeded
- name: Report offline hosts - name: Report offline hosts
ansible.builtin.debug: ansible.builtin.debug:
msg: "{{ inventory_hostname }} - OFFLINE" msg: "{{ inventory_hostname }} - OFFLINE"
when: ping_result is not succeeded when: ping_result is unreachable or ping_result is failed
delegate_to: localhost delegate_to: localhost
- name: Disk and RAM Usage Report - name: Disk and RAM Usage Report
@@ -27,9 +37,12 @@
become: true become: true
gather_facts: true gather_facts: true
vars:
disk_warn_pct: 80
tasks: tasks:
- name: Get disk usage (excluding snap, overlay, tmpfs) - name: Get disk usage (excluding snap, overlay, tmpfs)
ansible.builtin.shell: df -h --output=target,size,used,avail,pcent -x tmpfs -x devtmpfs -x squashfs -x overlay | tail -n +2 ansible.builtin.shell: df -h --output=target,size,used,avail,pcent -x tmpfs -x devtmpfs -x squashfs -x overlay -x efivarfs | tail -n +2
register: disk_usage register: disk_usage
changed_when: false changed_when: false
@@ -55,8 +68,81 @@
{{ disk_usage.stdout }} {{ disk_usage.stdout }}
====================================================== ======================================================
- name: Warn on high disk usage (>85%) - name: Collect filesystems above {{ disk_warn_pct }}%
ansible.builtin.debug: ansible.builtin.set_fact:
msg: "WARNING: {{ item.split()[-1] }} usage on {{ item.split()[0] }} of {{ inventory_hostname }}" disk_alerts: "{{ disk_alerts | default([]) + [item.split()[0] ~ ' ' ~ item.split()[-1]] }}"
loop: "{{ disk_usage.stdout_lines }}" loop: "{{ disk_usage.stdout_lines }}"
when: (item.split()[-1] | replace('%','') | int) > 85 when: (item.split()[-1] | replace('%', '') | int) > disk_warn_pct | int
- name: Warn on high disk usage
ansible.builtin.debug:
msg: "WARNING: {{ item }} used on {{ inventory_hostname }} (threshold {{ disk_warn_pct }}%)"
loop: "{{ disk_alerts | default([]) }}"
- name: Telegram alert
hosts: localhost
connection: local
gather_facts: false
vars:
disk_warn_pct: 80
# Accepted from a Semaphore variable group as extra vars (telegram_bot_token
# or TELEGRAM_BOT_TOKEN) or as environment variables (TELEGRAM_BOT_TOKEN).
telegram_bot_token: "{{ TELEGRAM_BOT_TOKEN | default(lookup('env', 'TELEGRAM_BOT_TOKEN'), true) | string }}"
telegram_chat_id: "{{ TELEGRAM_CHAT_ID | default(lookup('env', 'TELEGRAM_CHAT_ID'), true) | string }}"
telegram_configured: "{{ telegram_bot_token | string | length > 0 and telegram_chat_id | string | length > 0 }}"
offline_hosts: "{{ groups['all'] | difference(groups['reachable_hosts'] | default([])) }}"
tasks:
- name: Build alert text
ansible.builtin.set_fact:
alert_text: "{{ alert_body | default('', true) | trim }}" # an empty template renders as None
vars:
alert_body: |-
{% for h in groups['reachable_hosts'] | default([]) %}
{% for fs in hostvars[h].disk_alerts | default([]) %}
DISK {{ h }}: {{ fs }} used (threshold {{ disk_warn_pct }}%)
{% endfor %}
{% endfor %}
{% for h in offline_hosts %}
OFFLINE {{ h }} did not answer
{% endfor %}
- name: Nothing to alert
ansible.builtin.debug:
msg: "Every host reachable and all filesystems at or below {{ disk_warn_pct }}% - no alert"
when: alert_text | length == 0
- name: Print alerts when Telegram is not configured
ansible.builtin.debug:
msg: |-
ALERTS (Telegram not configured - bot token {{ 'set' if telegram_bot_token | string | length > 0 else 'MISSING' }},
chat id {{ 'set' if telegram_chat_id | string | length > 0 else 'MISSING' }};
pass TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID through the variable group attached to this template)
{{ alert_text }}
when:
- alert_text | length > 0
- not telegram_configured
- name: Send Telegram alert
ansible.builtin.uri:
url: "https://api.telegram.org/bot{{ telegram_bot_token }}/sendMessage"
method: POST
body_format: json
body:
chat_id: "{{ telegram_chat_id }}"
text: "Homelab health check {{ now().strftime('%Y-%m-%d %H:%M') }}\n{{ alert_text }}"
status_code: [200]
register: telegram_result
failed_when: false
no_log: true # the bot token is part of the URL
when:
- alert_text | length > 0
- telegram_configured
- name: Telegram delivery status
ansible.builtin.assert:
that: telegram_result.status == 200
fail_msg: "Telegram API returned {{ telegram_result.status }}: {{ telegram_result.json.description | default('no response') }}"
success_msg: "Telegram alert sent ({{ alert_text.split('\n') | length }} line(s))"
when: telegram_result is not skipped
+47 -46
View File
@@ -1,73 +1,74 @@
--- ---
- name: Build Dynamic Rocky Linux LXC Container # Creates a Rocky Linux 9 container through the Proxmox API and starts it.
# Runs on the Semaphore host (localhost); proxmoxer must be importable by the
# Python that runs Ansible there.
#
# Extra vars (Semaphore environment / variable group):
# proxmox_api_user - e.g. root@pam (required)
# proxmox_api_password - that user's password (this, or a token)
# proxmox_api_token_id / proxmox_api_token_secret (API token instead of a password)
# proxmox_api_host - defaults to the pve entry in the inventory
#
# Optional: lxc_hostname, lxc_template, lxc_cores, lxc_memory, lxc_storage,
# lxc_disk_size, lxc_bridge, lxc_dns, target_node.
- name: Build a Rocky Linux LXC container
hosts: localhost hosts: localhost
connection: local
gather_facts: false gather_facts: false
vars: vars:
# --- Container Specifications --- proxmox_api_host: "{{ hostvars['pve'].ansible_host }}"
lxc_hostname: "new_lxc" target_node: mini-pc
lxc_template: "local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz" lxc_hostname: new-lxc # underscores are not valid in a hostname
lxc_template: local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz
lxc_cores: 2 lxc_cores: 2
lxc_memory: 1024 lxc_memory: 1024
lxc_storage: "TB" lxc_storage: TB
lxc_disk_size: 10 lxc_disk_size: 10
lxc_bridge: "vmbr0" lxc_bridge: vmbr0
lxc_dns: "192.168.2.2" lxc_dns: "{{ hostvars['pihole'].ansible_host }}"
target_node: "pve"
# --- Security & Access ---
lxc_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key" lxc_ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key"
tasks: module_defaults:
# ======================================================================== community.proxmox.proxmox:
# NEW FIX: Auto-install missing dependencies into Semaphore's isolated venv
# ========================================================================
- name: Ensure Proxmox API dependencies are installed in Semaphore
ansible.builtin.shell: "{{ ansible_playbook_python }} -m pip install proxmoxer requests"
changed_when: false
# ========================================================================
- name: Get the next available VMID from the Proxmox cluster
ansible.builtin.uri:
url: "https://{{ proxmox_api_host }}:8006/api2/json/cluster/nextid"
method: GET
headers:
Authorization: "PVEAPIToken {{ proxmox_api_user }}={{ proxmox_api_password }}"
validate_certs: false
return_content: true
register: nextid_response
- name: Extract the dynamic VMID from the API response
ansible.builtin.set_fact:
dynamic_vmid: "{{ nextid_response.json.data }}"
- name: Create the Rocky Linux LXC container
community.general.proxmox:
api_host: "{{ proxmox_api_host }}" api_host: "{{ proxmox_api_host }}"
api_user: "{{ proxmox_api_user }}" api_user: "{{ proxmox_api_user }}"
api_password: "{{ proxmox_api_password }}" api_password: "{{ proxmox_api_password | default(omit) }}"
api_token_id: "{{ proxmox_api_token_id | default(omit) }}"
api_token_secret: "{{ proxmox_api_token_secret | default(omit) }}"
validate_certs: false
timeout: 300 # template extraction takes longer than the 30 s default
tasks:
- name: Ensure proxmoxer is available to the controller's Python
ansible.builtin.command: "{{ ansible_playbook_python }} -m pip install proxmoxer requests"
register: pip_install
changed_when: "'Successfully installed' in pip_install.stdout"
- name: Create the container (Proxmox assigns the next free VMID)
community.proxmox.proxmox:
node: "{{ target_node }}" node: "{{ target_node }}"
vmid: "{{ dynamic_vmid }}"
hostname: "{{ lxc_hostname }}" hostname: "{{ lxc_hostname }}"
ostemplate: "{{ lxc_template }}" ostemplate: "{{ lxc_template }}"
cores: "{{ lxc_cores }}" cores: "{{ lxc_cores }}"
memory: "{{ lxc_memory }}" memory: "{{ lxc_memory }}"
disk: "{{ lxc_storage }}:{{ lxc_disk_size }}" disk: "{{ lxc_storage }}:{{ lxc_disk_size }}"
netif: '{"net0":"name=eth0,bridge={{ lxc_bridge }},ip=dhcp"}' netif:
net0: "name=eth0,bridge={{ lxc_bridge }},ip=dhcp"
nameserver: "{{ lxc_dns }}" nameserver: "{{ lxc_dns }}"
pubkey: "{{ lxc_ssh_key }}" pubkey: "{{ lxc_ssh_key }}"
unprivileged: true unprivileged: true
state: present state: present
register: lxc_create
- name: Ensure the new LXC container is powered on - name: Start the container
community.general.proxmox: community.proxmox.proxmox:
api_host: "{{ proxmox_api_host }}" vmid: "{{ lxc_create.vmid }}"
api_user: "{{ proxmox_api_user }}"
api_password: "{{ proxmox_api_password }}"
vmid: "{{ dynamic_vmid }}"
state: started state: started
- name: Output the assigned VMID to the Semaphore UI logs - name: Report
ansible.builtin.debug: ansible.builtin.debug:
msg: "Successfully built {{ lxc_hostname }} (Rocky Linux 9) with dynamic ID: {{ dynamic_vmid }}" msg: >-
Built {{ lxc_hostname }} (Rocky Linux 9) as VMID {{ lxc_create.vmid }} on {{ target_node }}.
Next: run discover_guests.yml with adopt=true, add it to inventories/all.yml, then bootstrap_lxc.yml.
-140
View File
@@ -1,140 +0,0 @@
---
- name: Safe Nextcloud Upgrade Playbook
hosts: nextcloud
become: yes
vars:
nc_path: /var/www/nextcloud
php_bin: /usr/bin/php
tasks:
- name: Pause playbook for 3 seconds
pause:
seconds: 3
prompt: "Press Ctrl+C then A to abort if no backups exist."
###########################################################################
# 1 - CHECK CURRENT INSTALLED VERSION
###########################################################################
- name: 1.1 - Get current NC status
command: "{{ php_bin }} occ status"
args:
chdir: "{{ nc_path }}"
register: nc_status
- name: 1.2 - Extract installed version
set_fact:
installed_version: "{{ nc_status.stdout | regex_search('versionstring: (.*)', '\\1') }}"
- name: 1.3 - Show installed version
debug:
msg: "Current installed version: {{ installed_version }}"
###########################################################################
# 2 - CHECK LATEST AVAILABLE VERSION (via updater.phar)
###########################################################################
- name: 2.1 - Check for available update
command: "{{ php_bin }} updater/updater.phar --check"
args:
chdir: "{{ nc_path }}"
register: update_check
failed_when: false # we want to parse output even if exit code != 0
- name: 2.2 - Parse latest available version
set_fact:
available_version: "{{ update_check.stdout | regex_search('Update to version (.*) available', '\\1') }}"
when: update_check.stdout is search('Update to version')
- name: 2.3 - Show available version
debug:
msg: "Latest available version: {{ available_version | default('NONE') }}"
###########################################################################
# 2.4 - SKIP IF ALREADY UP TO DATE
###########################################################################
- name: 2.4 - Skip upgrade if already on latest version
debug:
msg: "Nextcloud is already up to date ({{ installed_version }}). Skipping upgrade."
when: available_version is not defined
tags: skip_upgrade
changed_when: false
- name: End play if up to date
meta: end_play
when: available_version is not defined
###########################################################################
# 3 - DISABLE APPS + MAINTENANCE MODE
###########################################################################
- name: 3.1 - Enable maintenance mode
command: "{{ php_bin }} occ maintenance:mode --on"
args:
chdir: "{{ nc_path }}"
###########################################################################
# 3.2 - RUN UPDATER WITH RETRIES
###########################################################################
- name: 3.2 - Run Nextcloud CLI updater (retry)
command: "{{ php_bin }} updater/updater.phar --no-interaction"
args:
chdir: "{{ nc_path }}"
register: updater_output
retries: 3
delay: 5
until: updater_output.rc == 0
failed_when: updater_output.rc != 0
###########################################################################
# 3.3 - OCC UPGRADE (DB MIGRATIONS)
###########################################################################
- block:
- name: 3.3 - Run occ upgrade
command: "{{ php_bin }} occ upgrade"
args:
chdir: "{{ nc_path }}"
register: upgrade_run
failed_when: >
upgrade_run.rc != 0 and
"already in process" not in upgrade_run.stdout and
"maintenance mode" not in upgrade_run.stdout
rescue:
- name: 3.3R - Handle locked upgrade
debug:
msg: "Upgrade appears stuck. Removing maintenance lock and retrying."
- name: Remove maintenance mode flag from config.php
lineinfile:
path: "{{ nc_path }}/config/config.php"
regexp: "'maintenance' => true"
line: " 'maintenance' => false,"
backrefs: yes
- name: Retry OCC upgrade
command: "{{ php_bin }} occ upgrade"
args:
chdir: "{{ nc_path }}"
register: upgrade_retry
failed_when: upgrade_retry.rc != 0
###########################################################################
# 4 - ALWAYS DISABLE MAINTENANCE MODE
###########################################################################
- name: ALWAYS disable maintenance mode
command: "{{ php_bin }} occ maintenance:mode --off"
args:
chdir: "{{ nc_path }}"
ignore_errors: true
###########################################################################
# 5 - FINAL RESULT
###########################################################################
- name: Show final upgrade result
debug:
msg: |
✔ Nextcloud upgrade completed.
Installed: {{ installed_version }}
Updated to: {{ available_version }}
Check logs if issues appear:
- {{ nc_path }}/data/updater.log
- {{ nc_path }}/data/nextcloud.log
+2 -2
View File
@@ -1,7 +1,7 @@
---
- name: Ping all servers - name: Ping all servers
hosts: all hosts: all
gather_facts: no gather_facts: false
tasks: tasks:
- name: Test connectivity with ping - name: Test connectivity with ping
ansible.builtin.ping: ansible.builtin.ping:
+58 -37
View File
@@ -1,60 +1,81 @@
--- ---
# vzdump of selected guests on the Proxmox host. vzdump prunes old dumps of
# each guest itself (keep-last), so nothing else touches the dump directory.
# Every run is appended to {{ backup_dir }}/proxmox_backup.log on the host.
#
# backup_mode "stop" shuts the guest down for the whole backup. Never list
# the guest that runs Semaphore with mode stop: the job would stop itself
# half way through. Use "snapshot" for that one (falls back to suspend on
# storage without snapshot support).
#
# Extra vars (optional):
# guests - list of { id, type, name }
# keep_last - dumps kept per guest (default 3)
# backup_mode - stop | snapshot | suspend (default stop)
# storage - target PVE storage id (default local)
# compression - zstd | gzip | lzo (default zstd)
- name: Backup selected Proxmox VMs and LXCs - name: Backup selected Proxmox VMs and LXCs
hosts: proxmox hosts: proxmox
become: true become: true
gather_facts: false
vars: vars:
backup_dir: "/var/lib/vz/dump" backup_dir: /var/lib/vz/dump
storage: "local" storage: local
compression: "zstd" compression: zstd
maxfiles: 3 keep_last: 3
backup_mode: stop
guests: guests:
#- { id: 104, type: "lxc", name: "jellyfin" } # - { id: 104, type: lxc, name: courses }
- { id: 108, type: "lxc", name: "docker" } - { id: 108, type: lxc, name: docker }
#- { id: 101, type: "qemu", name: "pnetlab" } # - { id: 101, type: qemu, name: pnetlab }
tasks: tasks:
- name: Ensure backup directory exists - name: Ensure the log directory exists
file: ansible.builtin.file:
path: "{{ backup_dir }}" path: "{{ backup_dir }}"
state: directory state: directory
mode: '0755' mode: '0755'
- name: Run vzdump backup with logging - name: Run vzdump
shell: | ansible.builtin.shell: |
set -o pipefail
LOGFILE="{{ backup_dir }}/proxmox_backup.log" LOGFILE="{{ backup_dir }}/proxmox_backup.log"
START=$(date +%s) START=$(date +%s)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup of {{ item.name }} ({{ item.type }}) [ID: {{ item.id }}]" >> "$LOGFILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] START {{ item.name }} ({{ item.type }} {{ item.id }}) mode={{ backup_mode }}" >> "$LOGFILE"
OUTPUT=$(vzdump {{ item.id }} --mode {{ backup_mode }} --compress {{ compression }} \
OUTPUT=$(vzdump {{ item.id }} --mode stop --compress {{ compression }} --storage {{ storage }} --remove 0 2>&1) --storage {{ storage }} --prune-backups keep-last={{ keep_last }} 2>&1)
STATUS=$? STATUS=$?
DURATION=$(( $(date +%s) - START ))
END=$(date +%s) if [ "$STATUS" -eq 0 ]; then
DURATION=$((END - START)) echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS {{ item.name }} ({{ item.id }}) ${DURATION}s" >> "$LOGFILE"
echo "$OUTPUT" | grep -E 'archive file size|pruned|removing backup' || true
if [ $STATUS -eq 0 ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ SUCCESS - {{ item.name }} [{{ item.id }}] - Duration: ${DURATION}s" >> "$LOGFILE"
else else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ FAILURE - {{ item.name }} [{{ item.id }}] - Duration: ${DURATION}s" >> "$LOGFILE" echo "[$(date '+%Y-%m-%d %H:%M:%S')] FAILURE {{ item.name }} ({{ item.id }}) ${DURATION}s" >> "$LOGFILE"
echo "---- ERROR OUTPUT ----" >> "$LOGFILE" printf -- '---- vzdump output ----\n%s\n-----------------------\n' "$OUTPUT" >> "$LOGFILE"
echo "$OUTPUT" >> "$LOGFILE" echo "$OUTPUT" | tail -n 20
echo "----------------------" >> "$LOGFILE"
fi fi
exit "$STATUS"
args: args:
executable: /bin/bash executable: /bin/bash
loop: "{{ guests }}" loop: "{{ guests }}"
loop_control:
label: "{{ item.name }} ({{ item.type }} {{ item.id }})"
changed_when: true
- name: Rename backup files to include guest name - name: List the dumps kept for each guest
shell: | ansible.builtin.command: pvesm list {{ storage }} --content backup --vmid {{ item.id }}
latest_file=$(ls -t {{ backup_dir }}/vzdump-{{ item.type }}-{{ item.id }}-*.{{ compression }} | head -n 1)
new_file="{{ backup_dir }}/{{ item.name }}-{{ item.type }}-{{ item.id }}-{{ '%Y%m%d-%H%M%S' | strftime(ansible_date_time.epoch | int) }}.{{ compression }}"
cp "$latest_file" "$new_file"
args:
executable: /bin/bash
loop: "{{ guests }}" loop: "{{ guests }}"
loop_control:
label: "{{ item.name }} ({{ item.id }})"
register: dump_lists
changed_when: false
- name: Prune old backups (keep last {{ maxfiles }}) - name: Backup summary
shell: | ansible.builtin.debug:
ls -tp {{ backup_dir }}/{{ item.name }}-{{ item.type }}-{{ item.id }}-*{{ compression }} | grep -v '/$' | tail -n +{{ maxfiles + 1 }} | xargs -r rm -- msg: |
args: {% for r in dump_lists.results %}
executable: /bin/bash {{ r.item.name }} ({{ r.item.type }} {{ r.item.id }}) - mode {{ backup_mode }}, keeping the last {{ keep_last }}:
loop: "{{ guests }}" {{ r.stdout }}
{% endfor %}
+146
View File
@@ -0,0 +1,146 @@
---
# Reports which Ubuntu/Rocky guests need a reboot and, only when asked,
# reboots them. LXC containers restart via Proxmox when they reboot themselves.
#
# Extra vars (optional):
# do_reboot - default false (report only)
# reboot_exclude - hosts never rebooted by this playbook (default: [docker],
# which most likely runs Semaphore itself)
- name: Check Host Availability
hosts: ubuntu:rocky
gather_facts: false
ignore_unreachable: true
tasks:
- name: Ping hosts to check availability
ansible.builtin.ping:
register: ping_result
ignore_errors: true
ignore_unreachable: true
- name: Add reachable hosts to group
ansible.builtin.group_by:
key: reachable_hosts
when: ping_result is reachable and ping_result is succeeded
- name: Report offline hosts
ansible.builtin.debug:
msg: "{{ inventory_hostname }} - OFFLINE"
when: ping_result is unreachable or ping_result is failed
delegate_to: localhost
- name: Reboot Check - Ubuntu
hosts: ubuntu:&reachable_hosts
become: true
gather_facts: true
tasks:
- name: Check for the reboot-required flag
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_flag
- name: Read the packages that requested a reboot
ansible.builtin.slurp:
src: /var/run/reboot-required.pkgs
register: reboot_pkgs
when: reboot_flag.stat.exists
failed_when: false
- name: Record reboot status
ansible.builtin.set_fact:
reboot_needed: "{{ reboot_flag.stat.exists }}"
reboot_reason: "{{ (reboot_pkgs.content | default('') | b64decode | trim | replace('\n', ', ')) if reboot_flag.stat.exists else '' }}"
- name: Reboot Check - Rocky Linux
hosts: rocky:&reachable_hosts
become: true
gather_facts: true
tasks:
- name: Ensure needs-restarting is available
ansible.builtin.dnf:
name: yum-utils
state: present
# needs-restarting -r compares package install times against the kernel boot
# time, which inside an LXC container is the host's boot time, so the flag
# would never clear. Containers use the process-based check instead.
- name: Check whether a reboot is required (VM)
ansible.builtin.command: needs-restarting -r
register: needs_reboot
failed_when: needs_reboot.rc not in [0, 1]
changed_when: false
when: ansible_virtualization_type != "lxc"
- name: Check for processes using updated libraries (LXC)
ansible.builtin.command: needs-restarting
register: stale_procs
failed_when: stale_procs.rc not in [0, 1]
changed_when: false
when: ansible_virtualization_type == "lxc"
- name: Record reboot status
ansible.builtin.set_fact:
reboot_needed: >-
{{ (needs_reboot.rc == 1) if ansible_virtualization_type != 'lxc'
else ((stale_procs.stdout | trim | length) > 0) }}
reboot_reason: >-
{{ (needs_reboot.stdout | default('') | trim | replace('\n', '; ')) if ansible_virtualization_type != 'lxc'
else ((stale_procs.stdout_lines | default([]) | length | string) ~ ' process(es) using updated libraries') }}
- name: Reboot Check - Report
hosts: reachable_hosts
gather_facts: false
tasks:
- name: Print reboot status
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} ({{ ansible_virtualization_type | default('unknown') }}) -
REBOOT NEEDED: {{ 'YES' if reboot_needed | default(false) | bool else 'no' }}
{{ ('- ' ~ reboot_reason) if (reboot_needed | default(false) | bool and reboot_reason | default('') | length > 0) else '' }}
- name: Reboot Check - Reboot hosts that need it
hosts: reachable_hosts
become: true
gather_facts: false
vars:
do_reboot: false
reboot_exclude:
- docker
tasks:
- name: Report hosts skipped because they are excluded
ansible.builtin.debug:
msg: "SKIPPED: {{ inventory_hostname }} needs a reboot but is listed in reboot_exclude"
when:
- do_reboot | bool
- reboot_needed | default(false) | bool
- inventory_hostname in reboot_exclude
# Inside a container the default boot_id belongs to the host and never
# changes, so the reboot module would wait forever. PID 1's start time does
# change when the container restarts.
- name: Reboot LXC container
ansible.builtin.reboot:
boot_time_command: "awk '{print $22}' /proc/1/stat"
reboot_timeout: 300
msg: "Reboot initiated by Ansible (reboot_check.yml)"
when:
- do_reboot | bool
- reboot_needed | default(false) | bool
- inventory_hostname not in reboot_exclude
- ansible_virtualization_type == "lxc"
- name: Reboot VM
ansible.builtin.reboot:
reboot_timeout: 300
msg: "Reboot initiated by Ansible (reboot_check.yml)"
when:
- do_reboot | bool
- reboot_needed | default(false) | bool
- inventory_hostname not in reboot_exclude
- ansible_virtualization_type != "lxc"
+151
View File
@@ -0,0 +1,151 @@
---
# Verifies the critical services listed per host in the inventory
# (critical_services) and optionally starts any that are stopped. Services
# found not running are sent as one Telegram message per run when Telegram
# credentials are present.
#
# Extra vars (optional):
# restart_stopped - default false; when true, start services found stopped
# telegram_bot_token / TELEGRAM_BOT_TOKEN - extra var or environment variable
# telegram_chat_id / TELEGRAM_CHAT_ID - extra var or environment variable
- name: Check Host Availability
hosts: ubuntu:rocky
gather_facts: false
ignore_unreachable: true
tasks:
- name: Ping hosts to check availability
ansible.builtin.ping:
register: ping_result
ignore_errors: true
ignore_unreachable: true
- name: Add reachable hosts to group
ansible.builtin.group_by:
key: reachable_hosts
when: ping_result is reachable and ping_result is succeeded
- name: Report offline hosts
ansible.builtin.debug:
msg: "{{ inventory_hostname }} - OFFLINE"
when: ping_result is unreachable or ping_result is failed
delegate_to: localhost
- name: Service Check
hosts: reachable_hosts
become: true
gather_facts: false
vars:
restart_stopped: false
tasks:
- name: Gather service facts
ansible.builtin.service_facts:
- name: Evaluate critical services
ansible.builtin.set_fact:
service_states: >-
{{ service_states | default([])
+ [{'name': item, 'state': (ansible_facts.services[item ~ '.service'].state | default('not-found'))}] }}
loop: "{{ critical_services | default([]) }}"
- name: Print report
ansible.builtin.debug:
msg: |
======================================================
HOST: {{ inventory_hostname }} ({{ ansible_host }})
======================================================
{% if service_states | default([]) | length == 0 %}
no critical services defined in inventory
{% else %}
{% for svc in service_states %}
{{ '%-24s' | format(svc.name) }} {{ 'RUNNING' if svc.state == 'running' else ('NOT FOUND' if svc.state == 'not-found' else svc.state | upper) }}
{% endfor %}
{% endif %}
======================================================
- name: Warn on services that are not running
ansible.builtin.debug:
msg: "WARNING: {{ item.name }} is {{ 'not installed' if item.state == 'not-found' else item.state }} on {{ inventory_hostname }}"
loop: "{{ service_states | default([]) }}"
loop_control:
label: "{{ item.name }}"
when: item.state != 'running'
- name: Start stopped services
ansible.builtin.service:
name: "{{ item.name }}"
state: started
loop: "{{ service_states | default([]) }}"
loop_control:
label: "{{ item.name }}"
when:
- restart_stopped | bool
- item.state not in ['running', 'not-found']
- name: Telegram alert
hosts: localhost
connection: local
gather_facts: false
vars:
restart_stopped: false
# Accepted from a Semaphore variable group as extra vars (telegram_bot_token
# or TELEGRAM_BOT_TOKEN) or as environment variables (TELEGRAM_BOT_TOKEN).
telegram_bot_token: "{{ TELEGRAM_BOT_TOKEN | default(lookup('env', 'TELEGRAM_BOT_TOKEN'), true) | string }}"
telegram_chat_id: "{{ TELEGRAM_CHAT_ID | default(lookup('env', 'TELEGRAM_CHAT_ID'), true) | string }}"
telegram_configured: "{{ telegram_bot_token | string | length > 0 and telegram_chat_id | string | length > 0 }}"
tasks:
- name: Build alert text
ansible.builtin.set_fact:
alert_text: "{{ alert_body | default('', true) | trim }}" # an empty template renders as None
vars:
alert_body: |-
{% for h in groups['reachable_hosts'] | default([]) %}
{% for svc in hostvars[h].service_states | default([]) if svc.state != 'running' %}
{% set note = ' - start attempted' if (restart_stopped | bool and svc.state != 'not-found') else '' %}
SERVICE {{ h }}: {{ svc.name }} {{ 'not installed' if svc.state == 'not-found' else svc.state }}{{ note }}
{% endfor %}
{% endfor %}
- name: Nothing to alert
ansible.builtin.debug:
msg: "All critical services running - no alert"
when: alert_text | length == 0
- name: Print alerts when Telegram is not configured
ansible.builtin.debug:
msg: |-
ALERTS (Telegram not configured - bot token {{ 'set' if telegram_bot_token | string | length > 0 else 'MISSING' }},
chat id {{ 'set' if telegram_chat_id | string | length > 0 else 'MISSING' }};
pass TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID through the variable group attached to this template)
{{ alert_text }}
when:
- alert_text | length > 0
- not telegram_configured
- name: Send Telegram alert
ansible.builtin.uri:
url: "https://api.telegram.org/bot{{ telegram_bot_token }}/sendMessage"
method: POST
body_format: json
body:
chat_id: "{{ telegram_chat_id }}"
text: "Homelab service check {{ now().strftime('%Y-%m-%d %H:%M') }}\n{{ alert_text }}"
status_code: [200]
register: telegram_result
failed_when: false
no_log: true # the bot token is part of the URL
when:
- alert_text | length > 0
- telegram_configured
- name: Telegram delivery status
ansible.builtin.assert:
that: telegram_result.status == 200
fail_msg: "Telegram API returned {{ telegram_result.status }}: {{ telegram_result.json.description | default('no response') }}"
success_msg: "Telegram alert sent ({{ alert_text.split('\n') | length }} line(s))"
when: telegram_result is not skipped
+209
View File
@@ -0,0 +1,209 @@
---
# Hardens sshd on every reachable Ubuntu/Rocky guest and turns on a fail2ban
# sshd jail. Settings go into /etc/ssh/sshd_config.d/00-ansible.conf, which
# sorts first and therefore wins over anything in sshd_config or the
# cloud-init drop-in (sshd keeps the first value it sees for each option).
#
# Lockout guard: password authentication is only disabled on hosts where
# root already has at least one authorized key.
#
# Extra vars (optional):
# ssh_disable_password_auth - default true
# ssh_max_auth_tries - default 4
- name: Check Host Availability
hosts: ubuntu:rocky
gather_facts: false
ignore_unreachable: true
tasks:
- name: Ping hosts to check availability
ansible.builtin.ping:
register: ping_result
ignore_errors: true
ignore_unreachable: true
- name: Add reachable hosts to group
ansible.builtin.group_by:
key: reachable_hosts
when: ping_result is reachable and ping_result is succeeded
- name: Report offline hosts
ansible.builtin.debug:
msg: "{{ inventory_hostname }} - OFFLINE"
when: ping_result is unreachable or ping_result is failed
delegate_to: localhost
- name: SSH Hardening - Install fail2ban on Ubuntu
hosts: ubuntu:&reachable_hosts
become: true
gather_facts: false
tasks:
- name: Install fail2ban and nftables via apt
ansible.builtin.apt:
name:
- fail2ban
- nftables
state: present
update_cache: true
cache_valid_time: 3600
- name: SSH Hardening - Install fail2ban on Rocky Linux
hosts: rocky:&reachable_hosts
become: true
gather_facts: false
tasks:
- name: Ensure EPEL repo is enabled
ansible.builtin.dnf:
name: epel-release
state: present
- name: Install fail2ban and nftables via dnf
ansible.builtin.dnf:
name:
- fail2ban
- nftables
state: present
- name: SSH Hardening - Configure sshd and fail2ban
hosts: reachable_hosts
become: true
gather_facts: false
vars:
ssh_disable_password_auth: true
ssh_max_auth_tries: 4
sshd_dropin_path: /etc/ssh/sshd_config.d/00-ansible.conf
tasks:
- name: Count public keys in root's authorized_keys
ansible.builtin.command: grep -cE '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys
register: key_count
failed_when: false
changed_when: false
- name: Decide whether password authentication can be disabled
ansible.builtin.set_fact:
has_keys: "{{ key_count.rc == 0 and (key_count.stdout | int) > 0 }}"
- name: Warn when root has no authorized keys
ansible.builtin.debug:
msg: "WARNING: no authorized keys for root on {{ inventory_hostname }} - password authentication stays enabled"
when: not has_keys
- name: Ensure sshd_config.d directory exists
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: '0755'
- name: Ensure sshd_config includes the drop-in directory
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^Include\s+/etc/ssh/sshd_config\.d/\*\.conf'
line: 'Include /etc/ssh/sshd_config.d/*.conf'
insertbefore: BOF
backup: true
register: sshd_include
- name: Write sshd hardening drop-in
ansible.builtin.copy:
dest: "{{ sshd_dropin_path }}"
content: |
# Managed by Ansible (playbooks/ssh_harden.yml) - do not edit by hand
PermitRootLogin prohibit-password
PubkeyAuthentication yes
PasswordAuthentication {{ 'no' if (has_keys and ssh_disable_password_auth | bool) else 'yes' }}
KbdInteractiveAuthentication no
MaxAuthTries {{ ssh_max_auth_tries }}
X11Forwarding no
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
register: sshd_dropin
- name: Record whether sshd needs a restart
ansible.builtin.set_fact:
sshd_changed: "{{ sshd_include is changed or sshd_dropin is changed }}"
- name: Configure fail2ban sshd jail
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/sshd.local
content: |
# Managed by Ansible (playbooks/ssh_harden.yml)
[sshd]
enabled = true
backend = systemd
banaction = nftables-multiport
maxretry = 5
findtime = 10m
bantime = 1h
owner: root
group: root
mode: '0644'
register: f2b_jail
- name: Ensure fail2ban is enabled and running
ansible.builtin.systemd:
name: fail2ban
enabled: true
state: "{{ 'restarted' if f2b_jail is changed else 'started' }}"
- name: SSH Hardening - Restart SSH on Ubuntu
hosts: ubuntu:&reachable_hosts
become: true
gather_facts: false
tasks:
- name: Restart ssh service
ansible.builtin.service:
name: ssh
state: restarted
when: sshd_changed | bool
- name: SSH Hardening - Restart SSH on Rocky Linux
hosts: rocky:&reachable_hosts
become: true
gather_facts: false
tasks:
- name: Restart sshd service
ansible.builtin.service:
name: sshd
state: restarted
when: sshd_changed | bool
- name: SSH Hardening - Verify access
hosts: reachable_hosts
become: true
gather_facts: false
tasks:
- name: Drop the cached SSH connection so the next task performs a fresh login
ansible.builtin.meta: reset_connection
- name: Confirm key-based login still works
ansible.builtin.ping:
- name: Read fail2ban sshd jail status
ansible.builtin.command: fail2ban-client status sshd
register: f2b_status
changed_when: false
failed_when: false
- name: Summary
ansible.builtin.debug:
msg: |
======================================================
HOST: {{ inventory_hostname }} ({{ ansible_host }})
======================================================
PASSWORD AUTH: {{ 'disabled' if (has_keys and ssh_disable_password_auth | default(true) | bool) else 'ENABLED' }}
ROOT KEYS: {{ key_count.stdout if key_count.rc == 0 else 0 }}
SSHD: {{ 'restarted' if sshd_changed | bool else 'unchanged' }}
FAIL2BAN: {{ 'sshd jail active' if f2b_status.rc == 0 else 'WARNING: jail not reporting (' ~ (f2b_status.stderr | default('') | trim) ~ ')' }}
======================================================
+47 -41
View File
@@ -1,30 +1,46 @@
--- ---
- name: Configure timezone on all hosts # Sets the timezone on every Ubuntu/Rocky guest. LXC containers share the
hosts: all # host's clock, so NTP is disabled inside them; VMs sync against ntp_server.
#
# Extra vars (optional):
# timezone_region - default America/New_York
# ntp_server - default time.google.com
- name: Configure timezone
hosts: ubuntu:rocky
become: true become: true
gather_facts: true
vars: vars:
timezone_region: "America/New_York" timezone_region: America/New_York
tasks: tasks:
- name: Copy timezone file - name: Link /etc/localtime to the zoneinfo file
ansible.builtin.copy: ansible.builtin.file:
src: "/usr/share/zoneinfo/{{ timezone_region }}" src: "/usr/share/zoneinfo/{{ timezone_region }}"
dest: /etc/localtime dest: /etc/localtime
remote_src: true state: link
force: true
- name: Write /etc/timezone - name: Write /etc/timezone
ansible.builtin.copy: ansible.builtin.copy:
dest: /etc/timezone dest: /etc/timezone
content: "{{ timezone_region }}\n" content: "{{ timezone_region }}\n"
owner: root
group: root
mode: '0644'
- name: Disable NTP on Ubuntu LXC containers # The plays below reuse the facts gathered above instead of collecting them again.
- name: NTP on Ubuntu
hosts: ubuntu hosts: ubuntu
become: true become: true
gather_facts: false
vars:
ntp_server: time.google.com
tasks: tasks:
- name: Stop and mask timesyncd on LXC - name: Stop and mask timesyncd in LXC containers
ansible.builtin.systemd: ansible.builtin.systemd:
name: systemd-timesyncd name: systemd-timesyncd
state: stopped state: stopped
@@ -32,33 +48,11 @@
masked: true masked: true
when: ansible_virtualization_type == "lxc" when: ansible_virtualization_type == "lxc"
- name: Disable NTP on Rocky Linux LXC containers - name: Point timesyncd at ntp_server on VMs
hosts: rocky
become: true
tasks:
- name: Stop and mask chronyd on LXC
ansible.builtin.systemd:
name: chronyd
state: stopped
enabled: false
masked: true
when: ansible_virtualization_type == "lxc"
- name: Configure NTP on Ubuntu VMs
hosts: ubuntu
become: true
vars:
ntp_server: "time.google.com"
tasks:
- name: Configure timesyncd
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/systemd/timesyncd.conf path: /etc/systemd/timesyncd.conf
regexp: '^#?NTP=' regexp: '^#?NTP='
line: "NTP={{ ntp_server }}" line: "NTP={{ ntp_server }}"
state: present
when: ansible_virtualization_type != "lxc" when: ansible_virtualization_type != "lxc"
notify: Restart timesyncd notify: Restart timesyncd
@@ -69,27 +63,38 @@
state: restarted state: restarted
enabled: true enabled: true
- name: Configure NTP on Rocky Linux VMs - name: NTP on Rocky Linux
hosts: rocky hosts: rocky
become: true become: true
gather_facts: false
vars: vars:
ntp_server: "time.google.com" ntp_server: time.google.com
tasks: tasks:
- name: Disable default chrony pools - name: Stop and mask chronyd in LXC containers
ansible.builtin.systemd:
name: chronyd
state: stopped
enabled: false
masked: true
when: ansible_virtualization_type == "lxc"
# The lookahead keeps the server line added below from being commented
# out again on the next run.
- name: Disable the default chrony pools on VMs
ansible.builtin.replace: ansible.builtin.replace:
path: /etc/chrony.conf path: /etc/chrony.conf
regexp: '^(pool|server) .*' regexp: '^(pool|server)\s+(?!{{ ntp_server | regex_escape }}(\s|$)).*'
replace: '# \1 removed by Ansible' replace: '# \g<0> (disabled by Ansible)'
when: ansible_virtualization_type != "lxc" when: ansible_virtualization_type != "lxc"
notify: Restart chronyd
- name: Configure chrony NTP server - name: Point chrony at ntp_server on VMs
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: /etc/chrony.conf path: /etc/chrony.conf
regexp: '^server\s+{{ ntp_server | regex_escape }}(\s|$)'
line: "server {{ ntp_server }} iburst" line: "server {{ ntp_server }} iburst"
insertafter: EOF
state: present
when: ansible_virtualization_type != "lxc" when: ansible_virtualization_type != "lxc"
notify: Restart chronyd notify: Restart chronyd
@@ -101,8 +106,9 @@
enabled: true enabled: true
- name: Verify time configuration - name: Verify time configuration
hosts: all hosts: ubuntu:rocky
become: true become: true
gather_facts: false
tasks: tasks:
- name: Check current system time - name: Check current system time
+8 -4
View File
@@ -1,18 +1,22 @@
--- ---
- name: Update Pi-hole core and blocklists - name: Update Pi-hole core and blocklists
hosts: pihole hosts: pihole
become: yes become: true
gather_facts: false
tasks: tasks:
- name: Update Pi-hole core components - name: Update Pi-hole core components
command: pihole -up ansible.builtin.command: pihole -up
register: update_out register: update_out
changed_when: "'Everything is up to date' not in update_out.stdout"
- name: Update Pi-hole blocklists - name: Update Pi-hole blocklists
command: pihole -g ansible.builtin.command: pihole -g
register: gravity_out register: gravity_out
changed_when: true
- name: Display update summary - name: Display update summary
debug: ansible.builtin.debug:
msg: msg:
- "Core update: {{ update_out.stdout }}" - "Core update: {{ update_out.stdout }}"
- "Gravity update: {{ gravity_out.stdout }}" - "Gravity update: {{ gravity_out.stdout }}"
+218 -7
View File
@@ -1,46 +1,257 @@
---
# Full package upgrade for every reachable host: apt dist-upgrade on Ubuntu,
# dnf update on Rocky Linux. Each host is checked for pending packages before
# the upgrade runs, so the per-host and final reports show which systems were
# actually updated (and with what) and which were already up to date.
#
# Nothing reboots here; use reboot_check.yml for that.
# Play 1: probe every ubuntu/rocky host and sort them into a "reachable_hosts"
# group so the two upgrade plays below can skip anything that's offline.
- name: Check Host Availability - name: Check Host Availability
hosts: all hosts: ubuntu:rocky
gather_facts: false gather_facts: false
ignore_unreachable: true ignore_unreachable: true
tasks: tasks:
# Ping each host; failures/unreachables are swallowed so the play keeps
# going instead of aborting the whole run.
- name: Ping hosts to check availability - name: Ping hosts to check availability
ansible.builtin.ping: ansible.builtin.ping:
register: ping_result register: ping_result
ignore_errors: true ignore_errors: true
ignore_unreachable: true ignore_unreachable: true
# Dynamically creates/populates the "reachable_hosts" group, used by the
# upgrade plays' host patterns (ubuntu:&reachable_hosts etc.).
- name: Add reachable hosts to group - name: Add reachable hosts to group
ansible.builtin.group_by: ansible.builtin.group_by:
key: reachable_hosts key: reachable_hosts
when: ping_result is succeeded when: ping_result is reachable and ping_result is succeeded
# Print a message for any host that failed the ping, so offline hosts
# are visible in the run output and in the final summary.
- name: Report offline hosts - name: Report offline hosts
ansible.builtin.debug: ansible.builtin.debug:
msg: "Host {{ inventory_hostname }} is OFFLINE and will be skipped" msg: "{{ inventory_hostname }} - OFFLINE and will be skipped"
when: ping_result is not succeeded when: ping_result is unreachable or ping_result is failed
delegate_to: localhost delegate_to: localhost
# Play 2: upgrade Ubuntu hosts and record what each one needed.
- name: Update and Upgrade - Ubuntu - name: Update and Upgrade - Ubuntu
hosts: ubuntu:&reachable_hosts hosts: ubuntu:&reachable_hosts
become: true become: true
gather_facts: false gather_facts: false
tasks: tasks:
- name: Update & Upgrade via apt # Refresh the package lists first so the pending check below is accurate.
# A cache refreshed within the last hour is reused.
- name: Update apt cache
ansible.builtin.apt: ansible.builtin.apt:
update_cache: true update_cache: true
cache_valid_time: 3600 cache_valid_time: 3600
upgrade: dist changed_when: false # refreshing the lists is not a change worth reporting
check_mode: false
# Simulate the upgrade (no changes made) to learn which packages are
# pending. Each candidate shows up as an "Inst <name> ..." line.
- name: Check which packages are pending # noqa: command-instead-of-module (dry run, the apt module cannot simulate)
ansible.builtin.command: apt-get --just-print dist-upgrade
register: apt_simulate
changed_when: false
check_mode: false
environment:
DEBIAN_FRONTEND: noninteractive
- name: Record pending package list
ansible.builtin.set_fact:
updated_packages: "{{ apt_simulate.stdout | regex_findall('(?m)^Inst (\\S+)') }}"
# The actual upgrade. Hosts with nothing pending come back unchanged.
# A failure is caught so this host still shows up in the reports below;
# the final play turns any failure back into a non-zero exit.
- name: Update & Upgrade via apt
block:
- name: Run apt dist-upgrade
ansible.builtin.apt:
update_cache: false
upgrade: dist
register: upgrade_result
rescue:
- name: Record the upgrade error
ansible.builtin.set_fact:
update_error: "{{ upgrade_result.msg | default('see the task output above') }}"
# update_status drives both the per-host report and the final summary:
# updated - packages were installed
# up to date - nothing was pending
# failed - the upgrade task itself errored out
- name: Record update status
ansible.builtin.set_fact:
update_status: >-
{{ 'failed' if upgrade_result is failed
else ('updated' if upgrade_result is changed else 'up to date') }}
- name: Print report
ansible.builtin.debug:
msg: |
======================================================
HOST: {{ inventory_hostname }} ({{ ansible_host }}) - Ubuntu
======================================================
STATUS: {{ update_status | upper }}{{ ' (CHECK MODE - nothing was applied)' if ansible_check_mode else '' }}
{% if update_status == 'updated' %}
------------------------------------------------------
PACKAGES UPDATED: {{ updated_packages | length }}
{{ updated_packages | join(', ') | default('(list unavailable)', true) }}
{% elif update_status == 'failed' %}
------------------------------------------------------
ERROR: {{ update_error | default('see the task output above') }}
PACKAGES STILL PENDING: {{ updated_packages | length }}
{{ updated_packages | join(', ') | default('(list unavailable)', true) }}
{% endif %}
======================================================
# Play 3: upgrade Rocky Linux hosts and record what each one needed.
- name: Update and Upgrade - Rocky Linux - name: Update and Upgrade - Rocky Linux
hosts: rocky:&reachable_hosts hosts: rocky:&reachable_hosts
become: true become: true
gather_facts: false gather_facts: false
tasks: tasks:
# Refresh the metadata first so the pending check below is accurate.
- name: Update dnf cache
ansible.builtin.dnf:
update_cache: true
changed_when: false # refreshing the metadata is not a change worth reporting
check_mode: false
# rc 100 means updates are available, rc 0 means none, so only other
# codes count as a failure.
- name: Check which packages are pending
ansible.builtin.command: dnf check-update
register: dnf_check
changed_when: false
check_mode: false
failed_when: dnf_check.rc not in [0, 100]
# Pull the "name.arch version repo" lines out of the check-update
# output. Anything under "Obsoleting Packages" is a duplicate listing,
# so the output is truncated there first.
- name: Record pending package list
ansible.builtin.set_fact:
updated_packages: >-
{{ dnf_check.stdout.split('Obsoleting Packages')[0].splitlines()
| select('match', '^\S+\.\S+\s+\S+\s+\S+$')
| map('regex_replace', '^(\S+)\s.*$', '\1') | list }}
# The actual upgrade. Hosts with nothing pending come back unchanged.
# A failure is caught so this host still shows up in the reports below;
# the final play turns any failure back into a non-zero exit.
- name: Update & Upgrade via dnf - name: Update & Upgrade via dnf
block:
- name: Run dnf update
ansible.builtin.dnf: ansible.builtin.dnf:
name: "*" name: "*"
state: latest state: latest
update_cache: true update_cache: false
register: upgrade_result
rescue:
- name: Record the upgrade error
ansible.builtin.set_fact:
update_error: "{{ upgrade_result.msg | default('see the task output above') }}"
# Same three states as the Ubuntu play above.
- name: Record update status
ansible.builtin.set_fact:
update_status: >-
{{ 'failed' if upgrade_result is failed
else ('updated' if upgrade_result is changed else 'up to date') }}
- name: Print report
ansible.builtin.debug:
msg: |
======================================================
HOST: {{ inventory_hostname }} ({{ ansible_host }}) - Rocky Linux
======================================================
STATUS: {{ update_status | upper }}{{ ' (CHECK MODE - nothing was applied)' if ansible_check_mode else '' }}
{% if update_status == 'updated' %}
------------------------------------------------------
PACKAGES UPDATED: {{ updated_packages | length }}
{{ updated_packages | join(', ') | default('(list unavailable)', true) }}
{% elif update_status == 'failed' %}
------------------------------------------------------
ERROR: {{ update_error | default('see the task output above') }}
PACKAGES STILL PENDING: {{ updated_packages | length }}
{{ updated_packages | join(', ') | default('(list unavailable)', true) }}
{% endif %}
======================================================
# Play 4: after both upgrade plays have run, print one consolidated summary of
# which hosts were updated, which were already current, and which were missed.
- name: Summary - Updated Hosts
hosts: localhost
connection: local
gather_facts: false
vars:
all_targets: "{{ groups['ubuntu'] | default([]) | union(groups['rocky'] | default([])) }}"
reachable: "{{ groups['reachable_hosts'] | default([]) }}"
# update_status of every host that got far enough to set it.
statuses: "{{ reachable | map('extract', hostvars, 'update_status') | select('defined') | list }}"
status_counts:
"updated": "{{ statuses | select('equalto', 'updated') | list | length }}"
"up to date": "{{ statuses | select('equalto', 'up to date') | list | length }}"
"failed": "{{ statuses | select('equalto', 'failed') | list | length }}"
failed_hosts: "{{ reachable | map('extract', hostvars) | selectattr('update_status', 'defined')
| selectattr('update_status', 'equalto', 'failed')
| map(attribute='inventory_hostname') | list }}"
tasks:
- name: Print hosts that were updated
ansible.builtin.debug:
msg: >-
UPDATED {{ item }} -
{{ hostvars[item].updated_packages | default([]) | length }} package(s):
{{ hostvars[item].updated_packages | default([]) | join(', ') | default('(list unavailable)', true) }}
loop: "{{ reachable }}"
when: hostvars[item].update_status | default('') == 'updated'
- name: Print hosts that were already up to date
ansible.builtin.debug:
msg: "UP TO DATE {{ item }} - no packages pending"
loop: "{{ reachable }}"
when: hostvars[item].update_status | default('') == 'up to date'
- name: Print hosts whose upgrade failed
ansible.builtin.debug:
msg: "FAILED {{ item }} - the upgrade task reported an error, see the play output above"
loop: "{{ reachable }}"
when: hostvars[item].update_status | default('') == 'failed'
# Covers both hosts that failed the initial ping and any host that became
# unreachable part way through an upgrade play.
- name: Print hosts that were skipped
ansible.builtin.debug:
msg: "SKIPPED {{ item }} - offline or unreachable, not upgraded"
loop: "{{ all_targets }}"
when: hostvars[item].update_status is not defined
- name: Print totals
ansible.builtin.debug:
msg: >-
{{ all_targets | length }} host(s) targeted,
{{ status_counts['updated'] }} updated,
{{ status_counts['up to date'] }} already up to date,
{{ status_counts['failed'] }} failed,
{{ all_targets | reject('in', reachable) | list | length }} skipped;
{{ reachable | map('extract', hostvars, 'updated_packages')
| select('defined') | map('length') | sum }}
package(s) updated in total.
{{ '(CHECK MODE - nothing was applied.)' if ansible_check_mode else '' }}
# Keep a failed upgrade visible to Semaphore: every report above has been
# printed by now, so it is safe to end the run non-zero here.
- name: Fail the run if any host could not be upgraded
ansible.builtin.fail:
msg: "Upgrade failed on: {{ failed_hosts | join(', ') }}"
when: failed_hosts | length > 0
+104
View File
@@ -0,0 +1,104 @@
---
# Creates a non-root admin user with sudo and the homelab SSH keys on every
# reachable Ubuntu/Rocky guest. First step toward running Ansible as a
# non-root user.
#
# Extra vars (optional):
# admin_user - default harry
# admin_sudo_nopasswd - default true (required for unattended become)
# admin_ssh_keys - list of public keys (defaults to the ansible-key and
# the personal key already used by add_root_key.yml)
- name: Check Host Availability
hosts: ubuntu:rocky
gather_facts: false
ignore_unreachable: true
tasks:
- name: Ping hosts to check availability
ansible.builtin.ping:
register: ping_result
ignore_errors: true
ignore_unreachable: true
- name: Add reachable hosts to group
ansible.builtin.group_by:
key: reachable_hosts
when: ping_result is reachable and ping_result is succeeded
- name: Report offline hosts
ansible.builtin.debug:
msg: "{{ inventory_hostname }} - OFFLINE"
when: ping_result is unreachable or ping_result is failed
delegate_to: localhost
- name: User Setup - Create admin user on Ubuntu
hosts: ubuntu:&reachable_hosts
become: true
gather_facts: false
vars:
admin_user: harry
tasks:
- name: Create admin user in the sudo group - {{ admin_user }}
ansible.builtin.user:
name: "{{ admin_user }}"
shell: /bin/bash
groups: sudo
append: true
create_home: true
- name: User Setup - Create admin user on Rocky Linux
hosts: rocky:&reachable_hosts
become: true
gather_facts: false
vars:
admin_user: harry
tasks:
- name: Create admin user in the wheel group - {{ admin_user }}
ansible.builtin.user:
name: "{{ admin_user }}"
shell: /bin/bash
groups: wheel
append: true
create_home: true
- name: User Setup - Authorized keys and sudo
hosts: reachable_hosts
become: true
gather_facts: false
vars:
admin_user: harry
admin_sudo_nopasswd: true
admin_ssh_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINegitHaQG8i+s412h3wTWf/KvpeX1CEpYFva9MhHR+7 ansible-key"
- "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDbFKxd6W+gHcNrugkxMwvixGmk3LxtsMEnSdaFp4L78WZ4VhdZrsnSJQ/rViT+vuc6K5SeugtggfQ90L25UyyMv2BhLZaoMS2dpoVJEZon/Y2Hemm11vdEApAK/u0kPTTM5JB0/TZfpoP9sr4pmRIUruRXBhLDvtUV9tJvKSzbhChtB+vBg+MpywG9VvDP6eCVpFaP43RcAFtJR7irJmexd5m4CVhIFaWNeTbbe8YI08i2gDinYl95ygu9i/TorQKLWme2v9ejxQFfJTWGaXHnQ18W0X94Ns4a4OccGcvJ8JmvYsaAPRAOSveq1Fpdw1VXHSKigTmRl6pHWd9/rq6K6Ojctbjo1kHiNHdLKLZbLDeM0OQlzNl74snsImmZddY59wnOKyHtfY0ZI7sQ+7A/0cSaMY6h7lt2xK0WmayuoT8UtKZlItZHCkOA5bV29arI/HTz+yR/LW8SihPPFpVv5aDiOUs0PYd9j0+CtULBJeYpMQJpkD93g06vgEJjhUOBZ6JmilxVDihDARU0x0xiRCQJcq4Nhk/x3GzPe2kkrTI+nZFaQn1K8hcBdSyA12/xAG8Fh1SMCxLwJdCNuL3gQoYd6XPzKqn6BIqAgiyq5DbHhO6mehOye4Rro6i/H1UkZe862QH/R5uH+8Z2/FYRpiCPzQyLwrqx/eVfT7JMFQ== harwinder@harwinder.net" # yamllint disable-line rule:line-length
tasks:
- name: Add authorized keys for {{ admin_user }}
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ item }}"
state: present
loop: "{{ admin_ssh_keys }}"
loop_control:
label: "{{ item.split()[-1] }}"
- name: Grant sudo to {{ admin_user }}
ansible.builtin.copy:
dest: "/etc/sudoers.d/90-{{ admin_user }}"
content: "{{ admin_user }} ALL=(ALL) {{ 'NOPASSWD:' if admin_sudo_nopasswd | bool else '' }}ALL\n"
owner: root
group: root
mode: '0440'
validate: /usr/sbin/visudo -cf %s
- name: Summary
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} - user {{ admin_user }} ready
(sudo {{ 'NOPASSWD' if admin_sudo_nopasswd | bool else 'with password' }}, {{ admin_ssh_keys | length }} key(s))
+39
View File
@@ -0,0 +1,39 @@
---
# Installs and enables the QEMU guest agent on VMs (group "vms") so Proxmox
# can query IPs, quiesce filesystems for snapshots and shut the VM down cleanly.
# The VM also needs the agent enabled on the Proxmox side:
# qm set <vmid> --agent enabled=1 (then power-cycle the VM once)
- name: VM Guest Agent - Install and enable qemu-guest-agent
hosts: vms
become: true
gather_facts: true
tasks:
- name: Install qemu-guest-agent
ansible.builtin.package:
name: qemu-guest-agent
state: present
- name: Check whether the guest agent virtio port is present
ansible.builtin.stat:
path: /dev/virtio-ports/org.qemu.guest_agent.0
register: agent_port
- name: Enable and start qemu-guest-agent
ansible.builtin.systemd:
name: qemu-guest-agent
enabled: true
state: started
when: agent_port.stat.exists
- name: Warn when the agent device is missing
ansible.builtin.debug:
msg: "WARNING: {{ inventory_hostname }} has no guest agent device - run 'qm set <vmid> --agent enabled=1' on the Proxmox host and power-cycle the VM"
when: not agent_port.stat.exists
- name: Summary
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} ({{ ansible_virtualization_type | default('unknown') }}) -
qemu-guest-agent {{ 'RUNNING' if agent_port.stat.exists else 'NOT AVAILABLE' }}