Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92deb854d2 | |||
| 05e7ee3956 | |||
| aea450dc9d | |||
| 1d00432061 | |||
| 305b8324db |
@@ -134,6 +134,12 @@
|
|||||||
become: false
|
become: false
|
||||||
become_user: "{{ ansible_user }}"
|
become_user: "{{ ansible_user }}"
|
||||||
|
|
||||||
|
- name: Provision TLS certificate for Gitea
|
||||||
|
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
|
||||||
|
vars:
|
||||||
|
certbot_hostname: "{{ gitea_nginx_hostname }}"
|
||||||
|
when: gitea_nginx_enabled
|
||||||
|
|
||||||
- name: Deploy nginx vhost configuration for Gitea
|
- name: Deploy nginx vhost configuration for Gitea
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: nginx-vhost.conf.j2
|
src: nginx-vhost.conf.j2
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Description=Gitea Git Service
|
|||||||
Type=oneshot
|
Type=oneshot
|
||||||
RemainAfterExit=true
|
RemainAfterExit=true
|
||||||
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea
|
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/gitea
|
||||||
ExecStart=/usr/bin/podman play kube --replace gitea.yaml
|
ExecStart=/usr/bin/podman play kube --replace --network=pasta:--map-host-loopback={{ podman_gw_gateway }} gitea.yaml
|
||||||
ExecStop=/usr/bin/podman play kube --down gitea.yaml
|
ExecStop=/usr/bin/podman play kube --down gitea.yaml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ immich_upload_location: "{{ podman_projects_dir }}/immich/data/upload"
|
|||||||
immich_postgres_db_name: immich
|
immich_postgres_db_name: immich
|
||||||
immich_postgres_user: immich
|
immich_postgres_user: immich
|
||||||
# immich_postgres_password: "" # Intentionally undefined - role will fail if not set
|
# immich_postgres_password: "" # Intentionally undefined - role will fail if not set
|
||||||
# immich_postgres_host: "" # Must be set in inventory (e.g., podman_gw_gateway)
|
# immich_postgres_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host postgres)
|
||||||
immich_postgres_port: 5432
|
immich_postgres_port: 5432
|
||||||
|
|
||||||
# Valkey configuration (REQUIRED password - must be set explicitly)
|
# Valkey configuration (REQUIRED password - must be set explicitly)
|
||||||
immich_valkey_user: immich
|
immich_valkey_user: immich
|
||||||
# immich_valkey_password: "" # Intentionally undefined - role will fail if not set
|
# immich_valkey_password: "" # Intentionally undefined - role will fail if not set
|
||||||
# immich_valkey_host: "" # Must be set in inventory (e.g., podman_gw_gateway)
|
# immich_valkey_host: "" # Must be set in inventory (e.g., "{{ podman_gw_gateway }}" to reach host valkey)
|
||||||
immich_valkey_port: 6379
|
immich_valkey_port: 6379
|
||||||
immich_valkey_db: 0 # Dedicated database number for isolation (0-15)
|
immich_valkey_db: 0 # Dedicated database number for isolation (0-15)
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,18 @@
|
|||||||
- earthdistance
|
- earthdistance
|
||||||
- vector
|
- vector
|
||||||
|
|
||||||
|
- name: Update PostgreSQL extensions to latest available version
|
||||||
|
community.postgresql.postgresql_query:
|
||||||
|
login_db: "{{ immich_postgres_db_name }}"
|
||||||
|
query: "ALTER EXTENSION {{ item }} UPDATE"
|
||||||
|
become: false
|
||||||
|
become_user: "{{ postgres_admin_user | default('postgres') }}"
|
||||||
|
loop:
|
||||||
|
- cube
|
||||||
|
- earthdistance
|
||||||
|
- vector
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
- name: Grant schema permissions to Immich user
|
- name: Grant schema permissions to Immich user
|
||||||
community.postgresql.postgresql_privs:
|
community.postgresql.postgresql_privs:
|
||||||
login_db: "{{ immich_postgres_db_name }}"
|
login_db: "{{ immich_postgres_db_name }}"
|
||||||
@@ -144,6 +156,12 @@
|
|||||||
become: false
|
become: false
|
||||||
become_user: "{{ ansible_user }}"
|
become_user: "{{ ansible_user }}"
|
||||||
|
|
||||||
|
- name: Provision TLS certificate for Immich
|
||||||
|
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
|
||||||
|
vars:
|
||||||
|
certbot_hostname: "{{ immich_nginx_hostname }}"
|
||||||
|
when: immich_nginx_enabled
|
||||||
|
|
||||||
- name: Deploy nginx vhost configuration for Immich
|
- name: Deploy nginx vhost configuration for Immich
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: nginx-vhost.conf.j2
|
src: nginx-vhost.conf.j2
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Description=Immich Media Server
|
|||||||
Type=oneshot
|
Type=oneshot
|
||||||
RemainAfterExit=true
|
RemainAfterExit=true
|
||||||
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich
|
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/immich
|
||||||
ExecStart=/usr/bin/podman play kube --replace immich.yaml
|
ExecStart=/usr/bin/podman play kube --replace --network=pasta:--map-host-loopback={{ podman_gw_gateway }} immich.yaml
|
||||||
ExecStop=/usr/bin/podman play kube --down immich.yaml
|
ExecStop=/usr/bin/podman play kube --down immich.yaml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ metadata:
|
|||||||
name: immich
|
name: immich
|
||||||
labels:
|
labels:
|
||||||
app: immich
|
app: immich
|
||||||
annotations:
|
|
||||||
io.podman.annotations.network.mode: bridge
|
|
||||||
io.podman.annotations.network.name: podman-gw
|
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
# Provision a Let's Encrypt certificate for a hostname using the webroot method.
|
||||||
|
#
|
||||||
|
# Required variables:
|
||||||
|
# - certbot_hostname: the domain to provision (e.g. "apk.jokester.fr")
|
||||||
|
# - acme_email: Let's Encrypt account email (typically from host_vars)
|
||||||
|
#
|
||||||
|
# Usage from a service role:
|
||||||
|
# - name: Provision TLS certificate
|
||||||
|
# ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
|
||||||
|
# vars:
|
||||||
|
# certbot_hostname: "{{ myservice_nginx_hostname }}"
|
||||||
|
# when: myservice_nginx_enabled
|
||||||
|
|
||||||
|
- name: Validate certbot requirements
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- certbot_hostname is defined
|
||||||
|
- certbot_hostname | length > 0
|
||||||
|
- acme_email is defined
|
||||||
|
- acme_email | length > 0
|
||||||
|
fail_msg: |
|
||||||
|
certbot_hostname and acme_email are required for certificate provisioning.
|
||||||
|
Set acme_email in host_vars and pass certbot_hostname when including this task file.
|
||||||
|
success_msg: "Certbot requirements validated for {{ certbot_hostname }}"
|
||||||
|
|
||||||
|
- name: Check if certificate already exists for {{ certbot_hostname }}
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
|
||||||
|
register: certbot_cert_file
|
||||||
|
|
||||||
|
- name: Provision certificate for {{ certbot_hostname }}
|
||||||
|
when: not certbot_cert_file.stat.exists
|
||||||
|
block:
|
||||||
|
- name: Deploy temporary HTTP-only vhost for ACME challenge
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: "{{ role_path }}/../nginx/templates/vhost-http-acme.conf.j2"
|
||||||
|
dest: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Reload nginx to activate temporary ACME vhost
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
|
|
||||||
|
- name: Request certificate from Let's Encrypt for {{ certbot_hostname }}
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: >-
|
||||||
|
certbot certonly
|
||||||
|
--webroot
|
||||||
|
-w /var/www/certbot
|
||||||
|
-d {{ certbot_hostname }}
|
||||||
|
--email {{ acme_email }}
|
||||||
|
--agree-tos
|
||||||
|
--non-interactive
|
||||||
|
creates: "/etc/letsencrypt/live/{{ certbot_hostname }}/fullchain.pem"
|
||||||
|
|
||||||
|
- name: Fix letsencrypt directory permissions for nginx
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- /etc/letsencrypt/live
|
||||||
|
- /etc/letsencrypt/archive
|
||||||
|
|
||||||
|
always:
|
||||||
|
- name: Remove temporary ACME vhost
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ nginx_conf_dir | default('/etc/nginx/conf.d') }}/{{ certbot_hostname | replace('.', '_') }}_acme_temp.conf"
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Reload nginx after certificate provisioning
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Temporary HTTP-only vhost for ACME certificate provisioning
|
||||||
|
# Managed by Ansible - automatically removed after certificate issuance
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name {{ certbot_hostname }};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ Description=Ntfy Notification Service
|
|||||||
Type=oneshot
|
Type=oneshot
|
||||||
RemainAfterExit=true
|
RemainAfterExit=true
|
||||||
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy
|
WorkingDirectory={{ podman_projects_dir | default('/opt/podman') }}/ntfy
|
||||||
ExecStart=/usr/bin/podman play kube --replace ntfy.yaml
|
ExecStart=/usr/bin/podman play kube --replace --network=pasta:--map-host-loopback={{ podman_gw_gateway }} ntfy.yaml
|
||||||
ExecStop=/usr/bin/podman play kube --down ntfy.yaml
|
ExecStop=/usr/bin/podman play kube --down ntfy.yaml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ podman_log_driver: journald
|
|||||||
# k8s-file driver settings (only used when podman_log_driver: k8s-file)
|
# k8s-file driver settings (only used when podman_log_driver: k8s-file)
|
||||||
podman_log_max_size: 10mb # Max size per log file before rotation
|
podman_log_max_size: 10mb # Max size per log file before rotation
|
||||||
podman_log_max_files: 5 # Max number of rotated log files to keep
|
podman_log_max_files: 5 # Max number of rotated log files to keep
|
||||||
|
|
||||||
|
# Host gateway address exposed inside rootless containers (pasta --map-host-loopback)
|
||||||
|
# Containers can connect to this address to reach services bound to host loopback.
|
||||||
|
# Pasta translates the destination to 127.0.0.1 on the host side.
|
||||||
|
podman_gw_gateway: 100.64.0.1
|
||||||
|
|||||||
@@ -29,5 +29,12 @@ runtime = "{{ podman_runtime }}"
|
|||||||
network_backend = "netavark"
|
network_backend = "netavark"
|
||||||
|
|
||||||
[network]
|
[network]
|
||||||
# Default rootless network command (pasta for better performance)
|
# Default rootless network command (pasta for better performance).
|
||||||
|
# Note: default_rootless_network_cmd only accepts the mode name ("pasta" or
|
||||||
|
# "slirp4netns"). Extra pasta arguments must be set via pasta_options below;
|
||||||
|
# the "pasta:--arg=value" syntax is only valid for the CLI --network= flag.
|
||||||
default_rootless_network_cmd = "pasta"
|
default_rootless_network_cmd = "pasta"
|
||||||
|
|
||||||
|
# --map-host-loopback exposes the host's loopback to containers via {{ podman_gw_gateway }}.
|
||||||
|
# Containers connecting to {{ podman_gw_gateway }} reach host services bound to 127.0.0.1.
|
||||||
|
pasta_options = ["--map-host-loopback", "{{ podman_gw_gateway }}"]
|
||||||
|
|||||||
@@ -54,6 +54,15 @@
|
|||||||
become_user: "{{ nginx_user }}"
|
become_user: "{{ nginx_user }}"
|
||||||
changed_when: true
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Provision TLS certificates for static web sites
|
||||||
|
ansible.builtin.include_tasks: "{{ role_path }}/../nginx/tasks/certbot.yml"
|
||||||
|
vars:
|
||||||
|
certbot_hostname: "{{ item.key }}"
|
||||||
|
loop: "{{ static_web_sites | dict2items }}"
|
||||||
|
when:
|
||||||
|
- static_web_sites | length > 0
|
||||||
|
- item.value.ssl_enabled | default(true)
|
||||||
|
|
||||||
- name: Deploy nginx vhost configurations
|
- name: Deploy nginx vhost configurations
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: nginx-vhost.conf.j2
|
src: nginx-vhost.conf.j2
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Restart sys-autoupdate timer
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: sys-autoupdate.timer
|
||||||
|
daemon_reload: true
|
||||||
|
state: restarted
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
dependencies: []
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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 '(?<=<title>).*?(?=</title>)' | tail -n +2 | head -10 || echo "")
|
||||||
|
dates=$(echo "$rss_feed" | grep -oP '(?<=<pubDate>).*?(?=</pubDate>)' | head -10 || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$titles" || -z "$dates" ]]; then
|
||||||
|
log "No news items found in feed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local i=0
|
||||||
|
while IFS= read -r pub_date; do
|
||||||
|
local item_epoch
|
||||||
|
item_epoch=$(date -d "$pub_date" +%s 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [[ "$item_epoch" -gt "$cutoff_epoch" ]]; then
|
||||||
|
local title
|
||||||
|
title=$(echo "$titles" | sed -n "$((i + 1))p")
|
||||||
|
recent_titles+=("$title")
|
||||||
|
fi
|
||||||
|
((i++)) || true
|
||||||
|
done <<< "$dates"
|
||||||
|
|
||||||
|
if [[ ${#recent_titles[@]} -gt 0 ]]; then
|
||||||
|
local news_list
|
||||||
|
news_list=$(printf '- %s\n' "${recent_titles[@]}")
|
||||||
|
log "Recent $DISTRO_NAME news detected (${#recent_titles[@]} items within ${hours}h):"
|
||||||
|
log "$news_list"
|
||||||
|
send_notification \
|
||||||
|
"$(hostname): Manual Intervention Required" \
|
||||||
|
"Recent $DISTRO_NAME news (last ${hours}h). Review before updating.\n\n${news_list}\n\n$NEWS_URL" \
|
||||||
|
"warning" \
|
||||||
|
"high"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "No recent $DISTRO_NAME news (last ${hours}h)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
update_system() {
|
||||||
|
log "Checking for pending updates..."
|
||||||
|
local pending_updates
|
||||||
|
pending_updates=$(check_pending_updates)
|
||||||
|
|
||||||
|
if [[ -z "$pending_updates" ]]; then
|
||||||
|
log "No system updates available"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local update_count
|
||||||
|
update_count=$(echo "$pending_updates" | wc -l | tr -d ' ')
|
||||||
|
log "Found $update_count updates pending"
|
||||||
|
|
||||||
|
if apply_upgrade 2>&1; then
|
||||||
|
log "System updates completed successfully ($update_count packages)"
|
||||||
|
send_notification \
|
||||||
|
"$(hostname): System Update Success" \
|
||||||
|
"$update_count packages updated" \
|
||||||
|
"white_check_mark" \
|
||||||
|
"default"
|
||||||
|
else
|
||||||
|
log "ERROR: System update failed"
|
||||||
|
send_notification \
|
||||||
|
"$(hostname): System Update FAILED" \
|
||||||
|
"Update failed. Check: journalctl -u sys-autoupdate.service" \
|
||||||
|
"x" \
|
||||||
|
"urgent"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run a command as the rootless Podman user with a proper login environment.
|
||||||
|
# `runuser -l` invokes a login shell, so XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS,
|
||||||
|
# PATH, and HOME are set correctly for rootless Podman state under
|
||||||
|
# ~/.local/share/containers. Plain `sudo -u` does not set these and breaks
|
||||||
|
# rootless storage discovery and user-systemd interaction.
|
||||||
|
as_podman_user() {
|
||||||
|
runuser -l "$PODMAN_USER" -c "$(printf '%q ' "$@")"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_podman_images() {
|
||||||
|
log "Checking for Podman image updates..."
|
||||||
|
|
||||||
|
local compose_files
|
||||||
|
compose_files=$(find "$PODMAN_PROJECTS_DIR" -maxdepth 2 -name 'docker-compose.yml' -type f 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [[ -z "$compose_files" ]]; then
|
||||||
|
log "No docker-compose.yml files found in $PODMAN_PROJECTS_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local updated_services=()
|
||||||
|
|
||||||
|
while IFS= read -r compose_file; do
|
||||||
|
local project_dir
|
||||||
|
project_dir=$(dirname "$compose_file")
|
||||||
|
local project_name
|
||||||
|
project_name=$(basename "$project_dir")
|
||||||
|
|
||||||
|
log "Checking project $project_name..."
|
||||||
|
|
||||||
|
# Capture image digests before pull. `podman-compose images` lists the
|
||||||
|
# images referenced by the compose file with their current local IDs.
|
||||||
|
# Comparing pre/post sets is more reliable than parsing pull stdout.
|
||||||
|
local digests_before
|
||||||
|
digests_before=$(as_podman_user podman-compose -f "$compose_file" images --format '{{.ID}}' 2>/dev/null | sort -u || echo "")
|
||||||
|
|
||||||
|
local pull_output
|
||||||
|
if ! pull_output=$(as_podman_user podman-compose -f "$compose_file" pull 2>&1); then
|
||||||
|
log "ERROR: Pull failed for $project_name"
|
||||||
|
log "$pull_output"
|
||||||
|
local pull_tail
|
||||||
|
pull_tail=$(echo "$pull_output" | tail -n 5)
|
||||||
|
send_notification \
|
||||||
|
"$(hostname): Podman Pull Failed ($project_name)" \
|
||||||
|
"Failed to pull images.\n\nLast output:\n$pull_tail\n\nFull log: journalctl -u sys-autoupdate.service" \
|
||||||
|
"x" \
|
||||||
|
"high"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local digests_after
|
||||||
|
digests_after=$(as_podman_user podman-compose -f "$compose_file" images --format '{{.ID}}' 2>/dev/null | sort -u || echo "")
|
||||||
|
|
||||||
|
if [[ "$digests_before" == "$digests_after" ]]; then
|
||||||
|
log "Project $project_name: all images up to date"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "New images for $project_name, recreating containers..."
|
||||||
|
local up_output
|
||||||
|
local up_status=0
|
||||||
|
up_output=$(as_podman_user podman-compose -f "$compose_file" up -d 2>&1) || up_status=$?
|
||||||
|
|
||||||
|
if [[ "$up_status" -eq 0 ]]; then
|
||||||
|
log "Project $project_name updated successfully"
|
||||||
|
updated_services+=("$project_name")
|
||||||
|
else
|
||||||
|
log "ERROR: Failed to restart project $project_name (exit $up_status)"
|
||||||
|
log "$up_output"
|
||||||
|
|
||||||
|
# Probe stack state: are containers running despite the non-zero exit?
|
||||||
|
# podman-compose sometimes returns non-zero for partial successes.
|
||||||
|
local running_count
|
||||||
|
running_count=$(as_podman_user podman ps --filter "label=com.docker.compose.project=$project_name" --format '{{.ID}}' 2>/dev/null | wc -l | tr -d ' ' || echo "0")
|
||||||
|
|
||||||
|
# Tail of stderr/stdout for the notification body (truncate to keep ntfy payload small).
|
||||||
|
local up_tail
|
||||||
|
up_tail=$(echo "$up_output" | tail -n 5)
|
||||||
|
|
||||||
|
send_notification \
|
||||||
|
"$(hostname): Podman Restart FAILED ($project_name)" \
|
||||||
|
"up -d exit=$up_status, $running_count container(s) running.\n\nLast output:\n$up_tail\n\nFull log: journalctl -u sys-autoupdate.service" \
|
||||||
|
"x" \
|
||||||
|
"urgent"
|
||||||
|
fi
|
||||||
|
done <<< "$compose_files"
|
||||||
|
|
||||||
|
# Prune images. Always remove dangling; optionally remove tagged images
|
||||||
|
# older than PODMAN_PRUNE_UNTIL to bound disk usage from accumulated
|
||||||
|
# historical tags (compose pulls don't auto-clean prior versions).
|
||||||
|
log "Pruning unused Podman images..."
|
||||||
|
if [[ -n "$PODMAN_PRUNE_UNTIL" ]]; then
|
||||||
|
as_podman_user podman image prune -af --filter "until=$PODMAN_PRUNE_UNTIL" 2>&1 \
|
||||||
|
|| log "WARNING: Image prune (until=$PODMAN_PRUNE_UNTIL) failed"
|
||||||
|
else
|
||||||
|
as_podman_user podman image prune -f 2>&1 || log "WARNING: Image prune failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#updated_services[@]} -gt 0 ]]; then
|
||||||
|
local service_list
|
||||||
|
service_list=$(printf '%s, ' "${updated_services[@]}")
|
||||||
|
service_list="${service_list%, }"
|
||||||
|
send_notification \
|
||||||
|
"$(hostname): Podman Images Updated" \
|
||||||
|
"${#updated_services[@]} project(s) updated: $service_list" \
|
||||||
|
"whale" \
|
||||||
|
"default"
|
||||||
|
else
|
||||||
|
log "All Podman images are up to date"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "=== Starting $DISTRO_NAME auto-update run ==="
|
||||||
|
|
||||||
|
if [[ "$HAS_NEWS_CHECK" == "true" && "$CHECK_NEWS" == "true" ]]; then
|
||||||
|
log "Checking for recent $DISTRO_NAME news..."
|
||||||
|
if ! check_distro_news "$NEWS_HOURS"; then
|
||||||
|
log "ABORTED: Recent $DISTRO_NAME news requires manual intervention"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
update_system
|
||||||
|
|
||||||
|
if [[ "$PODMAN_ENABLED" == "true" ]]; then
|
||||||
|
update_podman_images
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "=== Auto-update run complete ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
|
{% endraw %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=System Automated Update Timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* {{ sys_autoupdate_update_hour }}:{{ '%02d' | format(sys_autoupdate_update_minute) }}
|
||||||
|
RandomizedDelaySec={{ sys_autoupdate_randomized_delay }}
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
sys_autoupdate_packages:
|
||||||
|
- pacman-contrib
|
||||||
|
- curl
|
||||||
|
|
||||||
|
sys_autoupdate_os_key: archlinux
|
||||||
|
|
||||||
|
sys_autoupdate_distro_name: Arch Linux
|
||||||
|
|
||||||
|
# Arch Linux news check via RSS feed - rolling release can require manual intervention
|
||||||
|
# The script fetches ${news_url}feed/ to get pubDate for each item
|
||||||
|
sys_autoupdate_has_news_check: true
|
||||||
|
sys_autoupdate_news_url: "https://archlinux.org/news/"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
sys_autoupdate_packages:
|
||||||
|
- apt-listchanges
|
||||||
|
- curl
|
||||||
|
|
||||||
|
sys_autoupdate_os_key: debian
|
||||||
|
|
||||||
|
sys_autoupdate_distro_name: Debian
|
||||||
|
|
||||||
|
# No news check needed - stable release, no manual intervention expected
|
||||||
|
sys_autoupdate_has_news_check: false
|
||||||
|
sys_autoupdate_news_url: ""
|
||||||
@@ -41,12 +41,6 @@
|
|||||||
state: present
|
state: present
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
- name: Install vi
|
|
||||||
package:
|
|
||||||
name: vi
|
|
||||||
state: present
|
|
||||||
changed_when: false
|
|
||||||
|
|
||||||
- name: Install vim
|
- name: Install vim
|
||||||
package:
|
package:
|
||||||
name: vim
|
name: vim
|
||||||
@@ -58,3 +52,9 @@
|
|||||||
name: nano
|
name: nano
|
||||||
state: present
|
state: present
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Install netcat
|
||||||
|
package:
|
||||||
|
name: "{{ (ansible_facts['os_family'] == 'Archlinux') | ternary('openbsd-netcat', 'netcat-openbsd') }}"
|
||||||
|
state: present
|
||||||
|
changed_when: false
|
||||||
|
|||||||
Reference in New Issue
Block a user