diff --git a/roles/sys_autoupdate/README.md b/roles/sys_autoupdate/README.md new file mode 100644 index 0000000..5051fc1 --- /dev/null +++ b/roles/sys_autoupdate/README.md @@ -0,0 +1,43 @@ +# sys_autoupdate + +Automated system updates and Podman image updates with ntfy notifications. + +Supports Arch Linux and Debian/Ubuntu. Deploys a Bash script + systemd timer that runs daily to: +1. Check for distro-specific news requiring manual intervention (Arch only) +2. Apply system updates (`pacman -Syu` / `apt-get dist-upgrade`) +3. Pull latest Podman images and restart pods with updated images +4. Send push notifications via ntfy.sh at each stage + +## Configuration + +See [defaults/main.yml](defaults/main.yml) for all variables. + +Required in host vars: + +```yaml +sys_autoupdate_ntfy_topic: your-notification-topic +``` + +## OS support + +| OS | Update command | News check | +|----|---------------|------------| +| Arch Linux | `pacman -Syu --noconfirm` | archlinux.org/news | +| Debian/Ubuntu | `apt-get dist-upgrade -y` | None (stable release) | + +OS-specific commands are defined in `vars/archlinux.yml` and `vars/debian.yml`, loaded automatically via `ansible_facts['os_family']`. + +## Podman image updates + +When `sys_autoupdate_podman_enabled: true` (default), the script scans `podman_projects_dir` for `docker-compose.yml` files, pulls images via `podman-compose pull`, and recreates containers with `podman-compose up -d` for projects with updated images. Dangling images are pruned after each run. + +The script runs as root (for package management) and uses `sudo -u {{ ansible_user }}` for Podman operations to preserve rootless isolation. + +## Notifications + +| Tag | Meaning | +|-----|---------| +| `white_check_mark` | System update succeeded | +| `x` | Update or pod restart failed | +| `warning` | Distro news requires manual review (Arch) | +| `whale` | Podman images updated | diff --git a/roles/sys_autoupdate/defaults/main.yml b/roles/sys_autoupdate/defaults/main.yml new file mode 100644 index 0000000..517015c --- /dev/null +++ b/roles/sys_autoupdate/defaults/main.yml @@ -0,0 +1,30 @@ +--- +# sys_autoupdate_ntfy_topic: "" # Intentionally undefined - role will fail if not set + +sys_autoupdate_ntfy_server: https://ntfy.sh + +# sys_autoupdate_ntfy_token: "" # Intentionally undefined - unauthenticated if not set + +sys_autoupdate_script_path: /usr/local/bin/sys-autoupdate.sh + +# Schedule: daily at 04:00 with up to 60min jitter +sys_autoupdate_update_hour: 4 +sys_autoupdate_update_minute: 0 +sys_autoupdate_randomized_delay: 60m + +# Arch Linux only: check archlinux.org/news before updating +# Ignored on Debian (sys_autoupdate_has_news_check is false in vars/debian.yml) +sys_autoupdate_check_news: true +sys_autoupdate_news_hours: 24 + +sys_autoupdate_allow_downgrade: false + +# Podman image auto-update (rootless, runs as ansible_user) +# Pulls latest images via podman-compose and recreates containers if changed +sys_autoupdate_podman_enabled: true +sys_autoupdate_podman_projects_dir: "{{ podman_projects_dir | default('/opt/podman') }}" + +# Prune images older than this duration after update. +# Format: hours suffix (e.g. 720h = 30 days). Set to "" to disable age-based prune +# (only dangling images will be removed in that case). +sys_autoupdate_podman_prune_until: "720h" diff --git a/roles/sys_autoupdate/handlers/main.yml b/roles/sys_autoupdate/handlers/main.yml new file mode 100644 index 0000000..7b54599 --- /dev/null +++ b/roles/sys_autoupdate/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart sys-autoupdate timer + ansible.builtin.systemd: + name: sys-autoupdate.timer + daemon_reload: true + state: restarted diff --git a/roles/sys_autoupdate/meta/main.yml b/roles/sys_autoupdate/meta/main.yml new file mode 100644 index 0000000..23d65c7 --- /dev/null +++ b/roles/sys_autoupdate/meta/main.yml @@ -0,0 +1,2 @@ +--- +dependencies: [] diff --git a/roles/sys_autoupdate/tasks/main.yml b/roles/sys_autoupdate/tasks/main.yml new file mode 100644 index 0000000..392f98f --- /dev/null +++ b/roles/sys_autoupdate/tasks/main.yml @@ -0,0 +1,56 @@ +--- +- name: Validate required configuration + ansible.builtin.assert: + that: + - sys_autoupdate_ntfy_topic is defined + - sys_autoupdate_ntfy_topic | length > 0 + fail_msg: | + sys_autoupdate_ntfy_topic is required. + See roles/sys_autoupdate/defaults/main.yml for configuration. + +- name: Load OS-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - debian.yml + +- name: Install required packages + ansible.builtin.package: + name: "{{ sys_autoupdate_packages }}" + state: present + +- name: Deploy autoupdate script + ansible.builtin.template: + src: sys-autoupdate.sh.j2 + dest: "{{ sys_autoupdate_script_path }}" + owner: root + group: root + mode: "0755" + notify: Restart sys-autoupdate timer + +- name: Deploy systemd service + ansible.builtin.template: + src: sys-autoupdate.service.j2 + dest: /etc/systemd/system/sys-autoupdate.service + owner: root + group: root + mode: "0644" + notify: Restart sys-autoupdate timer + +- name: Deploy systemd timer + ansible.builtin.template: + src: sys-autoupdate.timer.j2 + dest: /etc/systemd/system/sys-autoupdate.timer + owner: root + group: root + mode: "0644" + notify: Restart sys-autoupdate timer + +- name: Flush handlers before enabling timer + ansible.builtin.meta: flush_handlers + +- name: Enable and start update timer + ansible.builtin.systemd: + name: sys-autoupdate.timer + enabled: true + state: started diff --git a/roles/sys_autoupdate/templates/sys-autoupdate.service.j2 b/roles/sys_autoupdate/templates/sys-autoupdate.service.j2 new file mode 100644 index 0000000..81ca1cc --- /dev/null +++ b/roles/sys_autoupdate/templates/sys-autoupdate.service.j2 @@ -0,0 +1,8 @@ +[Unit] +Description=System Automated Update +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart={{ sys_autoupdate_script_path }} diff --git a/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 b/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 new file mode 100644 index 0000000..cc8be68 --- /dev/null +++ b/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +set -euo pipefail + +NTFY_SERVER="{{ sys_autoupdate_ntfy_server }}" +NTFY_TOPIC="{{ sys_autoupdate_ntfy_topic }}" +{% if sys_autoupdate_ntfy_token is defined %} +NTFY_TOKEN="{{ sys_autoupdate_ntfy_token }}" +{% else %} +NTFY_TOKEN="" +{% endif %} +PODMAN_ENABLED="{{ sys_autoupdate_podman_enabled | lower }}" +PODMAN_PROJECTS_DIR="{{ sys_autoupdate_podman_projects_dir }}" +PODMAN_USER="{{ ansible_user }}" +PODMAN_PRUNE_UNTIL="{{ sys_autoupdate_podman_prune_until }}" + +DISTRO_NAME="{{ sys_autoupdate_distro_name }}" +OS_KEY="{{ sys_autoupdate_os_key }}" +ALLOW_DOWNGRADE="{{ sys_autoupdate_allow_downgrade | lower }}" +HAS_NEWS_CHECK="{{ sys_autoupdate_has_news_check | lower }}" +CHECK_NEWS="{{ sys_autoupdate_check_news | lower }}" +NEWS_HOURS="{{ sys_autoupdate_news_hours }}" +NEWS_URL="{{ sys_autoupdate_news_url }}" + +{% raw %} +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +send_notification() { + local title="$1" + local message="$2" + local tags="$3" + local priority="${4:-default}" + + local auth_args=() + if [[ -n "$NTFY_TOKEN" ]]; then + auth_args=(-H "Authorization: Bearer $NTFY_TOKEN") + fi + + curl -s --max-time 15 \ + "${auth_args[@]}" \ + -H "Title: $title" \ + -H "Tags: $tags" \ + -H "Priority: $priority" \ + -d "$message" \ + "$NTFY_SERVER/$NTFY_TOPIC" || log "WARNING: Failed to send notification" +} + +# Per-OS dispatchers: keep all package-manager specifics in one place, +# avoid eval on Ansible-injected strings. +check_pending_updates() { + case "$OS_KEY" in + archlinux) + # checkupdates (pacman-contrib) exits 2 when no updates are pending; + # swallow non-zero so set -e doesn't abort the script. + checkupdates 2>/dev/null || true + ;; + debian) + apt-get update -qq >/dev/null 2>&1 + apt-get -s dist-upgrade 2>/dev/null | grep '^Inst ' || true + ;; + *) + log "ERROR: Unsupported OS_KEY='$OS_KEY'" + return 1 + ;; + esac +} + +apply_upgrade() { + case "$OS_KEY" in + archlinux) + if [[ "$ALLOW_DOWNGRADE" == "true" ]]; then + pacman -Syu --noconfirm --allow-downgrade + else + pacman -Syu --noconfirm + fi + ;; + debian) + local -a apt_args=(dist-upgrade -y) + if [[ "$ALLOW_DOWNGRADE" == "true" ]]; then + apt_args+=(--allow-downgrades) + fi + DEBIAN_FRONTEND=noninteractive apt-get "${apt_args[@]}" + ;; + *) + log "ERROR: Unsupported OS_KEY='$OS_KEY'" + return 1 + ;; + esac +} + +check_distro_news() { + local hours="${1:-24}" + local cutoff_epoch + cutoff_epoch=$(date -d "$hours hours ago" +%s 2>/dev/null || date -v-"${hours}"H +%s 2>/dev/null || echo "") + + if [[ -z "$cutoff_epoch" ]]; then + log "WARNING: Cannot determine cutoff date, skipping news check" + return 0 + fi + + local rss_feed + rss_feed=$(curl -s --max-time 10 "${NEWS_URL}feed/" 2>/dev/null || echo "") + + if [[ -z "$rss_feed" ]]; then + log "WARNING: Failed to fetch news feed, skipping news check" + return 0 + fi + + # Parse RSS items: extract title and pubDate pairs + local recent_titles=() + local titles dates + titles=$(echo "$rss_feed" | grep -oP '(?<=