Compare commits

...

30 Commits

Author SHA1 Message Date
d4b92e8837 . 2026-04-08 18:47:37 +02:00
5bcfbe84a1 . 2026-04-01 13:37:18 +02:00
4c093b1696 . 2026-04-01 13:31:02 +02:00
d67d0f936e . 2026-04-01 13:27:26 +02:00
f93fb53638 . 2026-04-01 12:19:58 +02:00
b4006cdeec . 2026-04-01 11:56:27 +02:00
38b5371b81 . 2026-03-31 15:25:54 +02:00
5a73ff5d93 . 2026-03-31 15:24:01 +02:00
8e643c59d8 . 2026-03-31 15:23:20 +02:00
9365725f30 . 2026-03-31 15:19:15 +02:00
ee48dc8f82 . 2026-03-31 14:19:50 +02:00
5d61b61ed2 . 2026-03-31 14:18:56 +02:00
c0baa5f8cd . 2026-03-31 14:17:57 +02:00
9142c30332 . 2026-03-31 14:17:19 +02:00
b81692b835 . 2026-03-31 14:15:49 +02:00
a5b73ee528 . 2026-03-31 14:15:14 +02:00
7ca2c9e568 . 2026-03-31 14:14:03 +02:00
663652e821 . 2026-03-31 14:13:11 +02:00
b5fcd0e103 . 2026-03-31 14:10:04 +02:00
9a6d3d6551 . 2026-03-30 20:43:35 +02:00
c8fc975303 . 2026-03-30 20:13:29 +02:00
6d6395c654 . 2026-03-30 20:11:14 +02:00
6378347ba6 . 2026-03-30 20:08:41 +02:00
86fd9cedf6 . 2026-03-30 20:01:53 +02:00
9566387db9 . 2026-03-30 19:59:16 +02:00
cbb6a1bff7 . 2026-03-30 19:25:22 +02:00
6aed7c0492 . 2026-03-30 19:18:42 +02:00
f1c2abb411 . 2026-03-30 19:17:36 +02:00
6635d8a0be . 2026-03-29 19:48:43 +02:00
295fbf3267 . 2026-03-29 19:32:31 +02:00
7 changed files with 707 additions and 454 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
config.conf.own config_own.conf

404
README.md
View File

@@ -1,14 +1,26 @@
# Docker Compose Auto-Updater # Docker Compose Auto-Updater
Dieses Script überprüft mehrere Docker-Compose-Stacks auf Image-Updates und aktualisiert diese automatisch. <p align="center">
<img src="./images/composeupdater.png" alt="Logo" width="200">
</p>
---
> 🔧 Automatisches Update von Docker-Compose-Stacks mit feingranularer Steuerung per Labels
## 🚀 Features ## 🚀 Features
- 🔄 Stack-basiertes Update - 🔄 **Stack-basiertes Update**
- 🧪 Dry-Run Modus Aktualisiert komplette Docker-Compose Stacks strukturiert und kontrolliert
- 📲 ntfy Benachrichtigungen - 🧪 **Dry-Run Modus**
- ⏭️ Exclude-Liste für ganze Stacks oder einzelne Container Zeigt an, was passieren würde, ohne Änderungen durchzuführen
- 🗑️ Prune Funktion - 📲 **ntfy Benachrichtigungen**
Push-Notifications über ntfy bei Updates, Fehlern oder Status
- ⚙️ **Service-Modi** (per Label steuerbar)
Einzelne Container oder komplette Stacks gezielt vom Update ausschließen
- 🗑️ **Prune Funktion**
Entfernt nicht mehr benötigte Images/Container automatisch
--- ---
@@ -16,122 +28,119 @@ Dieses Script überprüft mehrere Docker-Compose-Stacks auf Image-Updates und ak
- Docker + Docker Compose (v2) - Docker + Docker Compose (v2)
- Bash - Bash
- jq
- Optional: ntfy Server - Optional: ntfy Server
--- ---
## ⚙️ Konfiguration (`config.conf`)
```bash
# =============================
# =============================
# Pfade
# =============================
# Pfad zu deinen Compose-Files
COMPOSE_DIR="/pfad/zu/deinen/stacks"
# Logging
LOG_FILE="/pfad/zum/log/update.log"
LOG_LEVEL="INFO" # DEBUG=sehr detailliert, INFO=Standard, WARN=nur wichtige Hinweise/Updates, ERROR=nur Fehler
# Dateimuster
COMPOSE_PATTERN="docker-compose.yml"
# =============================
# =============================
# Allgemein Einstellungen
# =============================
# Verhalten bei gestoppten Containern
UPDATE_STOPPED=true # Image aktualisieren
START_STOPPED=false # danach NICHT starten
# Dry Run (true/false)
DRY_RUN=false
# =============================
# =============================
# Exclude
# =============================
# Exclude Container
EXCLUDE_SERVICES=(
"example_container_1"
"example_container_2"
)
# Exclude Stack
EXCLUDE_STACKS=(
"example_stack_1"
"example_stack_2"
)
# =============================
# =============================
# NTFY
# =============================
NTFY_ENABLED=true
NTFY_TITLE="Docker Update ($(hostname))"
NTFY_TOKEN="DEIN_TOKEN"
NTFY_URL="https://ntfy.example.com/topic"
NTFY_IMAGE_URL="http://dein-server/host-icon.png"
NTFY_TAGS="docker,update"
NTFY_ONLY_ON_CHANGES=false
# Versions Nr. anzeigen (true/false)
SHOW_VERSIONS=true
# =============================
# =============================
# Docker Cleanup
# =============================
ENABLE_CLEANUP=true
CLEANUP_ONLY_ON_UPDATE=true
# Images:
# 🟢 dangling → docker image prune (nur <none> Images)
# 🟡 unused → docker image prune -a (alle ungenutzten Images)
CLEANUP_IMAGES=true
CLEANUP_IMAGES_MODE="unused" # dangling | unused
# Container:
# entfernt gestoppte Container
# 🟢 docker container prune
CLEANUP_CONTAINERS=true
# Volume:
# entfernt ungenutzte Volumes
# ⚠️ kann Daten löschen
CLEANUP_VOLUMES=false
# Networks:
# entfernt ungenutzte Netzwerke
# 🟢 meist unkritisch
CLEANUP_NETWORKS=true
# =============================
```
---
## ▶️ Nutzung
```bash
chmod +x script.sh
./script.sh
```
---
## 🧠 Funktionsweise ## 🧠 Funktionsweise
1. Alle `docker-compose.yml` Dateien werden gefunden 1. Alle `*compose*.yml` Dateien werden rekursiv gefunden
2. Alphabetisch sortiert 2. Verarbeitung erfolgt alphabetisch (deterministische Reihenfolge)
3. Jeder Stack wird geprüft: 3. Für jeden Stack:
- Image wird gepullt - Compose-Konfiguration wird ausgewertet (`docker compose config`)
- Vergleich: Container Image-ID vs. aktuelles Image - Services und deren Images werden ermittelt
4. Wenn ein Service ein Update hat: - Für jedes Image:
- kompletter Stack wird neu deployed - Image wird bei Bedarf gepullt (maximal einmal pro Image und Stack, Cache-basiert)
- Lokale Image-ID wird ermittelt
- Image-ID des vorhandenen Containers wird ermittelt (auch für gestoppte Container)
4. Entscheidungslogik:
- ❌ Container existiert nicht → kein Update (nur Definition vorhanden)
- ❌ Image-ID unverändert → kein Update
- ✅ Image-ID hat sich geändert → Update erkannt
5. Wenn mindestens ein Service ein Update hat:
- **Einzelcontainer:**
- gezieltes Update nur dieses Services (`docker compose up -d <service>`)
- **Mehrere Services:**
- kompletter Stack wird neu deployed (`docker compose up -d`)
- **Optional**
- feste Wartezeit nach dem Deploy (`REDEPLOY_WAIT`)
- anschließendes Warten auf erfolgreiche Healthchecks (`REDEPLOY_WAIT_HEALTHY`)
6. Sonderverhalten:
- Gestoppte Container werden ebenfalls geprüft und bei Updates berücksichtigt
- Gestoppte Container werden nach dem Update optional wieder gestoppt
- Service-Verhalten wird vollständig über Labels gesteuert (`composeupdater.mode`)
- Je nach Modus werden Services:
- komplett ignoriert (`ignore`)
- nur überwacht (`notify-only`)
- oder automatisch aktualisiert (`update`)
---
## ⚙️ Service-Modi (per Label steuerbar)
Du kannst das Verhalten einzelner Services oder ganzer Stacks über Labels steuern:
```yml
labels:
- composeupdater.mode=update
```
### 📊 Übersicht
| Mode | Pull | Compare | Update | ntfy |
| ----------- | ---- | ------- | ------ | ---- |
| update | ✅ | ✅ | ✅ | ✅ |
| notify-only | ✅ | ✅ | ❌ | ✅ |
| ignore | ❌ | ❌ | ❌ | ❌ |
### 🧠 Erklärung der Modi
🔄 `update` (**Standard**)
- Images werden gepullt
- Lokales Image wird mit dem Container verglichen
- Bei Änderungen wird der Service bzw. Stack aktualisiert
- ntfy-Benachrichtigung wird gesendet
🔔 `notify-only`
- Image wird gepullt (für Vergleich notwendig)
- Es wird geprüft, ob ein Update verfügbar ist
- Kein Container-Update / kein Restart
- ntfy informiert über verfügbare Updates
🚫 `ignore`
- Service wird komplett ignoriert
- Kein Pull
- Kein Vergleich
- Kein Update
- Keine Benachrichtigung
### 🧩 Beispiele
#### Service ausschließen (komplett ignorieren)
```yml
services:
db:
image: postgres:15
labels:
- composeupdater.mode=ignore
```
#### Nur Benachrichtigung, kein automatisches Update
```yml
services:
app:
image: myapp:latest
labels:
- composeupdater.mode=notify-only
```
#### Explizit Standardverhalten setzen
```yml
services:
web:
image: nginx:latest
labels:
- composeupdater.mode=update
```
--- ---
@@ -157,7 +166,7 @@ Dabei werden ungenutzte Ressourcen entfernt:
--- ---
## 🔔 ntfy Prioritäten ## 🔔 ntfy
| Zustand | Priorität | | Zustand | Priorität |
|----------------------|----------| |----------------------|----------|
@@ -165,22 +174,163 @@ Dabei werden ungenutzte Ressourcen entfernt:
| 🔄 Updates vorhanden | 3 | | 🔄 Updates vorhanden | 3 |
| ❌ Fehler | 5 | | ❌ Fehler | 5 |
### Anzeigebeispiel
<p align="left">
<img src="./images/ntfy.png" alt="Logo" width="400">
</p>
---
## ⚙️ Konfiguration (`config.conf`)
```bash
# ==========================================================
# DOCKER COMPOSE UPDATER - CONFIG
# ==========================================================
# ----------------------------------------------------------
# PATH
# ----------------------------------------------------------
# Basisverzeichnis der Stacks [ String ]
PATH_COMPOSE_DIR="/pfad/zu/deinen/stacks"
# Compose-Dateiname [ String ]
PATH_COMPOSE_PATTERN="*compose*.yml"
# ----------------------------------------------------------
# LOG
# ----------------------------------------------------------
# Log-Datei [ String ]
LOG_FILE="/pfad/zum/log/update.log"
# Log-Level [ DEBUG | INFO | WARN | ERROR ]
LOG_LEVEL="INFO"
# ----------------------------------------------------------
# UPDATE
# ----------------------------------------------------------
# Nur Simulation, keine Änderungen [ true | false ]
UPDATE_DRY_RUN_ENABLED=false
# Gestoppte Container updaten [ true | false ]
UPDATE_INCLUDE_STOPPED=true
# Danach wieder starten [ true | false ]
UPDATE_START_STOPPED=false
# ----------------------------------------------------------
# REDEPLOY
# ----------------------------------------------------------
# Feste Wartezeit nach Redeploy [ Number ]
REDEPLOY_WAIT=45
# Warten bis Container healthy sind [ true | false ]
REDEPLOY_WAIT_HEALTHY=true
# Timeout in Sekunden für healthy Check [ Number ]
REDEPLOY_WAIT_HEALTHY_TIMEOUT=60
# ----------------------------------------------------------
# NTFY SETTINGS
# ----------------------------------------------------------
# NTFY Zusammenfassung senden [ true | false ]
NTFY_ENABLED=true
# Server URL [ String ]
NTFY_URL="https://ntfy.example.com/topic"
# Token [ String ]
NTFY_TOKEN="DEIN_TOKEN"
# Titel mitsenden (optional) [ String ]
NTFY_TITLE="Autoupdate Report ($(hostname))"
# Tags mitsenden (optional) [ String ]
NTFY_TAGS="docker,update"
# Icon mitsenden (optional) [ String ]
NTFY_IMAGE_URL="http://dein-server/host-icon.png"
# Nur senden wenn Updates vorhanden [ true | false ]
NTFY_ONLY_ON_CHANGES=false
# Versionsnummern anzeigen [ true | false ]
NTFY_SHOW_VERSIONS=true
# ----------------------------------------------------------
# DOCKER CLEANUP
# ----------------------------------------------------------
# Prune Befehle ausführen [ true | false ]
CLEANUP_ENABLED=true
# Nur nach erfolgten Updates ausführen [ true | false ]
CLEANUP_ONLY_ON_UPDATE=true
# Images löschen [ true | false ]
CLEANUP_IMAGES_ENABLED=true
# Image-Prune Modus [ dangling | unused ]
CLEANUP_IMAGES_MODE="unused"
# Container löschen [ true | false ]
CLEANUP_CONTAINERS_ENABLED=true
# Volumes löschen [ true | false ]
CLEANUP_VOLUMES_ENABLED=false
# Networks löschen [ true | false ]
CLEANUP_NETWORKS_ENABLED=true
```
---
## ▶️ Nutzung
```bash
chmod +x script.sh
./script.sh
```
--- ---
## 📄 Beispiel Ausgabe ## 📄 Beispiel Ausgabe
``` ```
Prüfe Stack: homepage 🔍 Prüfe Stack: rss
├─ dockerproxy (image) ├─ read (phpdockerio/readability-js-server) [Mode: 🔄 update]
homepage (image) merc (wangqiru/mercury-parser-api) [Mode: 🔄 update]
├─ full-text-rss (heussd/fivefilters-full-text-rss:latest) [Mode: 🔄 update]
→ Prüfe Stack: app ├─ rss-bridge (rssbridge/rss-bridge:latest) [Mode: 🔄 update]
├─ db (image)
⬆️ UPDATE ⬆️ UPDATE
alt: sha256:abc alt: rssbridge/rss-bridge:latest@sha256:55215923cf81b2fa6fbb7ecc1bd2555405f4fc06029ae9876e91164a735c7b9d
neu: sha256:def neu: rssbridge/rss-bridge:latest@sha256:f3f0218c8b075cbc7c559c8e6852888e95fa6d68258436da6195efc5ab98b025
└─ web (image) └─ freshrss (freshrss/freshrss:latest) [Mode: 🔄 update]
🔄 Stack wird neu deployt ♻️ Stack wird neu deployt (Trigger: rss-bridge)
⏳ Deploy läuft...
✅ Stack erfolgreich aktualisiert
💤 Warte 60s nach Deploy
⏳ Warte auf healthy Container (max 60s)
💚 Alle Container healthy
🕒 Dauer: 78s
→ Prüfe Stack: tinymediamanager
└─ tinymediamanager [Mode: 🚫 ignore]
🕒 Dauer: 1s
``` ```
--- ---

View File

@@ -1,87 +1,108 @@
# ============================= # ==========================================================
# ============================= # DOCKER COMPOSE UPDATER - CONFIG
# Pfade # ==========================================================
# =============================
# Pfad zu deinen Compose-Files
COMPOSE_DIR="/pfad/zu/deinen/stacks" # ----------------------------------------------------------
# Logging # PATH
# ----------------------------------------------------------
# Basisverzeichnis der Stacks [ String ]
PATH_COMPOSE_DIR="/pfad/zu/deinen/stacks"
# Compose-Dateiname [ String ]
PATH_COMPOSE_PATTERN="*compose*.yml"
# ----------------------------------------------------------
# LOG
# ----------------------------------------------------------
# Log-Datei [ String ]
LOG_FILE="/pfad/zum/log/update.log" LOG_FILE="/pfad/zum/log/update.log"
LOG_LEVEL="INFO" # DEBUG=sehr detailliert, INFO=Standard, WARN=nur wichtige Hinweise/Updates, ERROR=nur Fehler
# Dateimuster # Log-Level [ DEBUG | INFO | WARN | ERROR ]
COMPOSE_PATTERN="docker-compose.yml" LOG_LEVEL="INFO"
# =============================
# =============================
# Allgemein Einstellungen
# =============================
# Verhalten bei gestoppten Containern # ----------------------------------------------------------
UPDATE_STOPPED=true # Image aktualisieren # UPDATE
START_STOPPED=false # danach NICHT starten # ----------------------------------------------------------
# Dry Run (true/false) # Nur Simulation, keine Änderungen [ true | false ]
DRY_RUN=false UPDATE_DRY_RUN_ENABLED=false
# ============================= # Gestoppte Container updaten [ true | false ]
# ============================= UPDATE_INCLUDE_STOPPED=true
# Exclude
# =============================
# Exclude Container # Danach wieder starten [ true | false ]
EXCLUDE_SERVICES=( UPDATE_START_STOPPED=false
"example_container_1"
"example_container_2"
)
# Exclude Stack
EXCLUDE_STACKS=(
"example_stack_1"
"example_stack_2"
)
# ============================= # ----------------------------------------------------------
# ============================= # REDEPLOY
# NTFY # ----------------------------------------------------------
# =============================
NTFY_ENABLED=true # Feste Wartezeit nach Redeploy [ Number ]
NTFY_TITLE="Docker Update ($(hostname))" REDEPLOY_WAIT=120
# Warten bis Container healthy sind [ true | false ]
REDEPLOY_WAIT_HEALTHY=true
# Timeout in Sekunden für healthy Check [ Number ]
REDEPLOY_WAIT_HEALTHY_TIMEOUT=60
# ----------------------------------------------------------
# NTFY SETTINGS
# ----------------------------------------------------------
# NTFY Zusammenfassung senden [ true | false ]
NTFY_ENABLED=true
# Server URL [ String ]
NTFY_URL="https://ntfy.example.com/topic"
# Token [ String ]
NTFY_TOKEN="DEIN_TOKEN" NTFY_TOKEN="DEIN_TOKEN"
NTFY_URL="https://ntfy.example.com/topic"
NTFY_IMAGE_URL="http://dein-server/host-icon.png" # Titel mitsenden (optional) [ String ]
NTFY_TITLE="Autoupdate Report ($(hostname))"
# Tags mitsenden (optional) [ String ]
NTFY_TAGS="docker,update" NTFY_TAGS="docker,update"
# Icon mitsenden (optional) [ String ]
NTFY_IMAGE_URL="http://dein-server/host-icon.png"
# Nur senden wenn Updates vorhanden [ true | false ]
NTFY_ONLY_ON_CHANGES=false NTFY_ONLY_ON_CHANGES=false
# Versions Nr. anzeigen (true/false)
SHOW_VERSIONS=true
# ============================= # Versionsnummern anzeigen [ true | false ]
# ============================= NTFY_SHOW_VERSIONS=true
# Docker Cleanup
# =============================
ENABLE_CLEANUP=true
# ----------------------------------------------------------
# DOCKER CLEANUP
# ----------------------------------------------------------
# Prune Befehle ausführen [ true | false ]
CLEANUP_ENABLED=true
# Nur nach erfolgten Updates ausführen [ true | false ]
CLEANUP_ONLY_ON_UPDATE=true CLEANUP_ONLY_ON_UPDATE=true
# Images: # Images löschen [ true | false ]
# 🟢 dangling → docker image prune (nur <none> Images) CLEANUP_IMAGES_ENABLED=true
# 🟢 unused → docker image prune -a (alle ungenutzten Images)
CLEANUP_IMAGES=true
CLEANUP_IMAGES_MODE="unused" # dangling | unused
# Container: # Image-Prune Modus [ dangling | unused ]
# entfernt gestoppte Container CLEANUP_IMAGES_MODE="unused"
# 🟢 docker container prune
CLEANUP_CONTAINERS=true
# Volume: # Container löschen [ true | false ]
# entfernt ungenutzte Volumes CLEANUP_CONTAINERS_ENABLED=true
# ⚠️ kann Daten löschen
CLEANUP_VOLUMES=false
# Networks: # Volumes löschen [ true | false ]
# entfernt ungenutzte Netzwerke CLEANUP_VOLUMES_ENABLED=false
# 🟢 meist unkritisch
CLEANUP_NETWORKS=true
# ============================= # Networks löschen [ true | false ]
CLEANUP_NETWORKS_ENABLED=true

View File

@@ -1,87 +0,0 @@
# =============================
# =============================
# Pfade
# =============================
# Pfad zu deinen Compose-Files
COMPOSE_DIR="/pfad/zu/deinen/stacks"
# Logging
LOG_FILE="/pfad/zum/log/update.log"
LOG_LEVEL="INFO" # DEBUG=sehr detailliert, INFO=Standard, WARN=nur wichtige Hinweise/Updates, ERROR=nur Fehler
# Dateimuster
COMPOSE_PATTERN="docker-compose.yml"
# =============================
# =============================
# Allgemein Einstellungen
# =============================
# Verhalten bei gestoppten Containern
UPDATE_STOPPED=true # Image aktualisieren
START_STOPPED=false # danach NICHT starten
# Dry Run (true/false)
DRY_RUN=false
# =============================
# =============================
# Exclude
# =============================
# Exclude Container
EXCLUDE_SERVICES=(
"example_container_1"
"example_container_2"
)
# Exclude Stack
EXCLUDE_STACKS=(
"example_stack_1"
"example_stack_2"
)
# =============================
# =============================
# NTFY
# =============================
NTFY_ENABLED=true
NTFY_TITLE="Docker Update ($(hostname))"
NTFY_TOKEN="DEIN_TOKEN"
NTFY_URL="https://ntfy.example.com/topic"
NTFY_IMAGE_URL="http://dein-server/composeupdater.png"
NTFY_TAGS="docker,update"
NTFY_ONLY_ON_CHANGES=false
# Versions Nr. anzeigen (true/false)
SHOW_VERSIONS=true
# =============================
# =============================
# Docker Cleanup
# =============================
ENABLE_CLEANUP=true
CLEANUP_ONLY_ON_UPDATE=true
# Images:
# 🟢 dangling → docker image prune (nur <none> Images)
# 🟢 unused → docker image prune -a (alle ungenutzten Images)
CLEANUP_IMAGES=true
CLEANUP_IMAGES_MODE="unused" # dangling | unused
# Container:
# entfernt gestoppte Container
# 🟢 docker container prune
CLEANUP_CONTAINERS=true
# Volume:
# entfernt ungenutzte Volumes
# ⚠️ kann Daten löschen
CLEANUP_VOLUMES=false
# Networks:
# entfernt ungenutzte Netzwerke
# 🟢 meist unkritisch
CLEANUP_NETWORKS=true
# =============================

View File

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 266 KiB

BIN
images/ntfy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -1,5 +1,4 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
BASE_DIR="$(cd "$(dirname "$0")" && pwd)" BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -19,24 +18,22 @@ level_to_int() {
esac esac
} }
INDENT=" "
SUBINDENT=" "
should_log() { should_log() {
[ "$(level_to_int "$1")" -ge "$(level_to_int "$LOG_LEVEL")" ] [ "$(level_to_int "$1")" -ge "$(level_to_int "$LOG_LEVEL")" ]
} }
log() { log() {
local level="$1" local level="$1"; shift
shift
local msg="$*" local msg="$*"
if should_log "$level"; then if should_log "$level"; then
echo "$(date '+%Y-%m-%d %H:%M:%S') | $level | $msg" | tee -a "$LOG_FILE" echo "$(date '+%Y-%m-%d %H:%M:%S') | $level | $msg" | tee -a "$LOG_FILE"
fi fi
} }
# =============================
# Log begrenzen # Log begrenzen
# =============================
if [ -f "$LOG_FILE" ]; then if [ -f "$LOG_FILE" ]; then
line_count=$(wc -l < "$LOG_FILE") line_count=$(wc -l < "$LOG_FILE")
if [ "$line_count" -gt 1000 ]; then if [ "$line_count" -gt 1000 ]; then
@@ -48,34 +45,34 @@ fi
# Helper # Helper
# ============================= # =============================
is_excluded() {
get_service_mode() {
local svc="$1" local svc="$1"
for ex in "${EXCLUDE_SERVICES[@]}"; do
[[ "$svc" == "$ex" ]] && return 0 echo "$compose_json" \
done | jq -r --arg svc "$svc" '
return 1 .services[$svc].labels // {}
| (if type=="array"
then map(split("=") | {(.[0]): .[1]}) | add
else .
end)
| .["composeupdater.mode"] // "update"
'
} }
is_stack_excluded() {
local stack="$1"
for ex in "${EXCLUDE_STACKS[@]}"; do
[[ "$stack" == "$ex" ]] && return 0
done
return 1
}
get_image() { get_image() {
local svc="$1" local svc="$1"
docker compose config | awk "/^ $svc:/,/image:/" | grep image | head -n1 | awk '{print $2}'
echo "$compose_json" \
| jq -r --arg svc "$svc" '.services[$svc].image // empty'
} }
get_container_image_id() { get_container_image_id() {
local svc="$1" local svc="$1"
local cid local cid
cid=$(docker compose ps -aq "$svc" | head -n1)
cid=$(docker compose ps -q "$svc" 2>/dev/null || true)
[ -z "$cid" ] && echo "" && return [ -z "$cid" ] && echo "" && return
docker inspect -f '{{.Image}}' "$cid" 2>/dev/null || echo "" docker inspect -f '{{.Image}}' "$cid" 2>/dev/null || echo ""
} }
@@ -84,33 +81,16 @@ get_local_image_id() {
docker image inspect -f '{{.Id}}' "$image" 2>/dev/null || echo "" docker image inspect -f '{{.Id}}' "$image" 2>/dev/null || echo ""
} }
get_container_image_ref() {
local svc="$1"
local cid
cid=$(docker compose ps -q "$svc" 2>/dev/null || true)
[ -z "$cid" ] && echo "none" && return
docker inspect -f '{{.Config.Image}}' "$cid" 2>/dev/null || echo "unknown"
}
get_local_image_digest() {
local image="$1"
docker inspect --format='{{index .RepoDigests 0}}' "$image" 2>/dev/null || echo "unknown"
}
is_running() { is_running() {
local svc="$1" local svc="$1"
local cid local cid
cid=$(docker compose ps -aq "$svc" 2>/dev/null || true)
cid=$(docker compose ps -q "$svc" 2>/dev/null || true)
[ -z "$cid" ] && return 1 [ -z "$cid" ] && return 1
docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null | grep -q true docker inspect -f '{{.State.Running}}' "$cid" 2>/dev/null | grep -q true
} }
run_cmd() { run_cmd() {
if [ "$DRY_RUN" = true ]; then if [ "$UPDATE_DRY_RUN_ENABLED" = true ]; then
log DEBUG "[DRY RUN] $*" log DEBUG "[DRY RUN] $*"
else else
eval "$@" eval "$@"
@@ -122,33 +102,22 @@ send_ntfy() {
local prio="$2" local prio="$2"
local image_url="${NTFY_IMAGE_URL:-}" local image_url="${NTFY_IMAGE_URL:-}"
if [ -n "$image_url" ]; then curl -s \
curl -s \ -H "Authorization: Bearer $NTFY_TOKEN" \
-H "Authorization: Bearer $NTFY_TOKEN" \ -H "Title: $NTFY_TITLE" \
-H "Title: $NTFY_TITLE" \ -H "Priority: $prio" \
-H "Priority: $prio" \ -H "Tags: $NTFY_TAGS" \
-H "Tags: $NTFY_TAGS" \ -H "Markdown: yes" \
-H "Icon: $image_url" \ ${image_url:+-H "Icon: $image_url"} \
-d "$msg" \ -d "$msg" \
"$NTFY_URL" > /dev/null || true "$NTFY_URL" > /dev/null || true
else
curl -s \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: $NTFY_TITLE" \
-H "Priority: $prio" \
-H "Tags: $NTFY_TAGS" \
-d "$msg" \
"$NTFY_URL" > /dev/null || true
fi
} }
get_docker_disk_usage() { get_docker_disk_usage() {
local total=0 local total=0
while read -r size; do while read -r size; do
num="${size//[!0-9.]/}" num="${size//[!0-9.]/}"
num="${num:-0}" num="${num:-0}"
if [[ "$size" == *GB ]]; then if [[ "$size" == *GB ]]; then
total=$(bc <<< "$total + ($num * 1024)") total=$(bc <<< "$total + ($num * 1024)")
elif [[ "$size" == *MB ]]; then elif [[ "$size" == *MB ]]; then
@@ -161,54 +130,153 @@ get_docker_disk_usage() {
LC_NUMERIC=C printf "%.0f\n" "$total" LC_NUMERIC=C printf "%.0f\n" "$total"
} }
wait_for_healthy() {
local timeout="$1"
shift
local services=("$@")
local start
start=$(date +%s)
# Containerliste bestimmen
if [ ${#services[@]} -gt 0 ]; then
mapfile -t cids < <(docker compose ps -aq "${services[@]}")
else
mapfile -t cids < <(docker compose ps -aq)
fi
# keine Container → nichts zu tun
if [ ${#cids[@]} -eq 0 ]; then
log INFO "${SUBINDENT} Keine Container gefunden → überspringe Healthcheck"
return 0
fi
# prüfen ob überhaupt Healthchecks existieren
local has_healthcheck=false
for cid in "${cids[@]}"; do
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null)
if [ -n "$health" ]; then
has_healthcheck=true
break
fi
done
# wenn keiner vorhanden → direkt raus
if [ "$has_healthcheck" = false ]; then
log INFO "${SUBINDENT} Keine Healthchecks → überspringe warten"
return 0
fi
log INFO "${SUBINDENT}⏳ Warte auf healthy Container (max ${timeout}s)"
while true; do
local all_ok=true
for cid in "${cids[@]}"; do
status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo "none")
if [ "$status" = "starting" ] || [ "$status" = "unhealthy" ]; then
all_ok=false
break
fi
done
if [ "$all_ok" = true ]; then
log INFO "${SUBINDENT}💚 Alle Container healthy"
return 0
fi
local now
now=$(date +%s)
if [ $((now - start)) -ge "$timeout" ]; then
log WARN "${SUBINDENT}⚠️ Healthcheck Timeout erreicht"
return 1
fi
sleep 2
done
}
pull_with_retry() {
local image="$1"
local retries=3
local delay=3
for ((i=1; i<=retries; i++)); do
if docker pull "$image" >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$retries" ]; then
log WARN " ⚠️ Pull fehlgeschlagen Versuch $i/$retries, retry in ${delay}s"
sleep "$delay"
fi
done
return 1
}
container_exists() {
local svc="$1"
docker compose ps -aq "$svc" 2>/dev/null | grep -q .
}
# ============================= # =============================
# Start # Start
# ============================= # =============================
log INFO "==== Docker Compose Update gestartet ====" log INFO "==== Docker Compose Update gestartet ===="
script_start=$(date +%s)
notify_stacks_updated=() notify_stacks_updated=()
notify_excluded_updates=() notify_excluded_updates=()
error_flag=false error_flag=false
cd "$COMPOSE_DIR" declare -A stack_tree
stack_tree=()
cd "$PATH_COMPOSE_DIR"
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
declare -A pulled_images
dir=$(dirname "$file") dir=$(dirname "$file")
stack=$(basename "$dir") stack=$(basename "$dir")
if is_stack_excluded "$stack"; then
log INFO "→ Stack $stack übersprungen (excluded)"
continue
fi
log INFO "" log INFO ""
log INFO " Prüfe Stack: $stack" log INFO "🔍 Prüfe Stack: $stack"
stack_start=$(date +%s)
cd "$dir" cd "$dir"
mapfile -t services < <(docker compose config --services) compose_json=$(docker compose config --format json)
mapfile -t services < <(docker compose config --services)
total_services=${#services[@]} total_services=${#services[@]}
current_index=0 current_index=0
# Running State merken
declare -A was_running declare -A was_running
for svc in "${services[@]}"; do for svc in "${services[@]}"; do
if is_running "$svc"; then is_running "$svc" && was_running[$svc]=1 || was_running[$svc]=0
was_running["$svc"]=1
else
was_running["$svc"]=0
fi
done done
stack_updated=false stack_updated=false
changed_services=() changed_services=()
version_report=() stack_block=""
update_lines=()
for svc in "${services[@]}"; do for svc in "${services[@]}"; do
update_needed=false
current_index=$((current_index + 1)) current_index=$((current_index + 1))
if [ "$current_index" -eq "$total_services" ]; then if [ "$current_index" -eq "$total_services" ]; then
@@ -224,39 +292,110 @@ while IFS= read -r -d '' file; do
continue continue
fi fi
if is_excluded "$svc"; then # =============================
docker pull "$image" >/dev/null 2>&1 || true # Mode (nur Service!)
notify_excluded_updates+=("$stack/$svc") # =============================
log INFO " $prefix $svc (excluded)"
mode=$(get_service_mode "$svc")
case "$mode" in
update) mode_label="Mode: 🔄 update" ;;
notify-only) mode_label="Mode: 🔔 notify-only" ;;
ignore) mode_label="Mode: 🚫 ignore" ;;
*)
mode_label="Mode: ❓ unknown"
mode="update"
;;
esac
case "$mode" in
ignore)
log INFO "${INDENT}$prefix $svc [$mode_label]"
continue
;;
notify-only)
log INFO "${INDENT}$prefix $svc ($image) [$mode_label]"
before_id=$(get_container_image_id "$svc")
if [ -z "$before_id" ]; then
continue
fi
if [ -z "${pulled_images[$image]:-}" ]; then
pull_with_retry "$image" || true
pulled_images[$image]=1
fi
after_id=$(get_local_image_id "$image")
if [ "$before_id" != "$after_id" ]; then
notify_excluded_updates+=("$stack/$svc")
fi
continue
;;
update)
log INFO "${INDENT}$prefix $svc ($image) [$mode_label]"
;;
esac
before_id=$(get_container_image_id "$svc")
if [ -z "$before_id" ] && [ "$UPDATE_INCLUDE_STOPPED" = false ]; then
log INFO "${SUBINDENT}⏭️ übersprungen (kein Container vorhanden)"
continue continue
fi fi
log INFO " $prefix $svc ($image)" # =============================
# Pull + Vergleich
# =============================
before_id=$(get_container_image_id "$svc") if [ -z "${pulled_images[$image]:-}" ]; then
before_digest=$(docker inspect -f '{{.Image}}' "$(docker compose ps -q "$svc")" 2>/dev/null || echo "none") if ! pull_with_retry "$image"; then
log ERROR "${INDENT}❌ Pull fehlgeschlagen"
if ! docker pull "$image" >/dev/null 2>&1; then error_flag=true
log ERROR " $prefix ❌ Pull fehlgeschlagen" continue
error_flag=true fi
continue pulled_images[$image]=1
else
log DEBUG "${SUBINDENT}⏩ Pull übersprungen (bereits gemacht)"
fi fi
after_id=$(get_local_image_id "$image") after_id=$(get_local_image_id "$image")
after_digest=$(get_local_image_digest "$image")
if [ -n "$before_id" ] && [ "$before_id" != "$after_id" ]; then if ! container_exists "$svc"; then
update_needed=false
elif [ "$before_id" != "$after_id" ]; then
update_needed=true
else
update_needed=false
fi
# =============================
# Update erkannt
# =============================
if [ "$update_needed" = true ]; then
stack_updated=true stack_updated=true
changed_services+=("$svc") changed_services+=("$svc")
if [ "$SHOW_VERSIONS" = true ]; then log INFO "${SUBINDENT}⬆️ UPDATE"
log INFO " ⬆️ UPDATE" log INFO "${SUBINDENT} alt: ${image}@${before_id}"
log INFO " alt: $before_digest" log INFO "${SUBINDENT} neu: ${image}@${after_id}"
log INFO " neu: $after_digest"
version_report+=("$svc: ${after_digest##*@}${after_digest##*@}") short_before="${before_id#sha256:}"
else short_before="${short_before:0:6}"
log INFO " ⬆️ UPDATE" short_after="${after_id#sha256:}"
fi short_after="${short_after:0:6}"
update_lines+=("$svc|$short_before|$short_after")
fi fi
done done
@@ -269,64 +408,108 @@ while IFS= read -r -d '' file; do
if [ "$total_services" -eq 1 ]; then if [ "$total_services" -eq 1 ]; then
svc="${services[0]}" svc="${services[0]}"
log INFO " 🔄 Einzelcontainer-Update: $svc" log INFO ""
log INFO "${INDENT}🔄 Einzelcontainer-Update: $svc"
if [ "${was_running[$svc]}" = 1 ]; then if [ "${was_running[$svc]}" = 1 ]; then
if ! run_cmd docker compose up -d "$svc" --remove-orphans --no-color >/dev/null 2>&1; then run_cmd docker compose up -d "$svc" --remove-orphans >/dev/null 2>&1
log ERROR " ❌ Update fehlgeschlagen für $svc"
error_flag=true
else
log INFO " ✔️ Container $svc aktualisiert"
fi
else else
if ! run_cmd docker compose create "$svc" >/dev/null 2>&1; then if [ "$UPDATE_START_STOPPED" = true ]; then
log ERROR " ❌ Create fehlgeschlagen für $svc" run_cmd docker compose up -d "$svc" --remove-orphans >/dev/null 2>&1
error_flag=true
else else
log INFO " ✔️ Container $svc aktualisiert (gestoppt)" run_cmd docker compose create "$svc"
fi fi
fi fi
notify_stacks_updated+=("$stack ($svc)") log INFO "${SUBINDENT}✅ Container aktualisiert"
if [ "${REDEPLOY_WAIT:-0}" -gt 0 ]; then
log INFO "${SUBINDENT}💤 Warte ${REDEPLOY_WAIT}s nach Deploy"
sleep "$REDEPLOY_WAIT"
fi
if [ "$REDEPLOY_WAIT_HEALTHY" = true ]; then
wait_for_healthy "$REDEPLOY_WAIT_HEALTHY_TIMEOUT" "$svc"
else
log INFO "${SUBINDENT} Keine Healthchecks → überspringe warten"
fi
else else
log INFO " 🔄 Stack wird neu deployt (Trigger: ${changed_services[*]})" log INFO ""
log INFO "${INDENT}♻️ Stack wird neu deployt (Trigger: ${changed_services[*]})"
log INFO "${SUBINDENT}⏳ Deploy läuft..."
log INFO " ⏳ Deploy läuft..." if ! run_cmd docker compose up -d --remove-orphans >/dev/null 2>&1; then
if ! run_cmd docker compose up -d --remove-orphans --no-color >/dev/null 2>&1; then log ERROR "${SUBINDENT}❌ Stack Update fehlgeschlagen"
log ERROR " ❌ Stack Update fehlgeschlagen"
error_flag=true error_flag=true
else else
log INFO " ✔️ Stack erfolgreich aktualisiert" log INFO "${SUBINDENT} Stack erfolgreich aktualisiert"
if [ "${REDEPLOY_WAIT:-0}" -gt 0 ]; then
log INFO "${SUBINDENT}💤 Warte ${REDEPLOY_WAIT}s nach Deploy"
sleep "$REDEPLOY_WAIT"
fi
if [ "$REDEPLOY_WAIT_HEALTHY" = true ]; then
wait_for_healthy "$REDEPLOY_WAIT_HEALTHY_TIMEOUT" "${changed_services[@]}"
else
log INFO "${SUBINDENT} Keine Healthchecks → überspringe warten"
fi
# vorher gestoppte wieder stoppen
for svc in "${services[@]}"; do for svc in "${services[@]}"; do
if [ "${was_running[$svc]}" = 0 ]; then if [ "${was_running[$svc]}" = 0 ]; then
log INFO " ⏹️ Stoppe $svc (war vorher gestoppt)" log INFO "${SUBINDENT}⏹️ Stoppe $svc (war vorher gestoppt)"
run_cmd docker compose stop "$svc" >/dev/null 2>&1 || true run_cmd docker compose stop "$svc" >/dev/null 2>&1 || true
fi fi
done done
notify_stacks_updated+=("$stack (${changed_services[*]})")
fi fi
fi fi
else
log DEBUG " ✔️ Keine Updates" # =============================
# Baum für ntfy bauen
# =============================
if [ ${#update_lines[@]} -gt 0 ]; then
stack_block=""
for i in "${!update_lines[@]}"; do
IFS="|" read -r svc before after <<< "${update_lines[$i]}"
if [ "$i" -eq $((${#update_lines[@]} - 1)) ]; then
prefix="└─"
else
prefix="├─"
fi
if [ "$NTFY_SHOW_VERSIONS" = true ]; then
stack_block+=$'\n'"$prefix **$svc** \`$before$after\`"
else
stack_block+=$'\n'"$prefix **$svc**"
fi
done
stack_tree["$stack"]="$stack_block"
notify_stacks_updated+=("$stack")
fi
fi fi
cd "$COMPOSE_DIR" cd "$PATH_COMPOSE_DIR"
done < <(find . -name "$COMPOSE_PATTERN" -print0 | sort -z) stack_end=$(date +%s)
log INFO "${INDENT}🕒 Dauer: $((stack_end - stack_start))s"
done < <(find . -name "$PATH_COMPOSE_PATTERN" -print0 | sort -z)
# ============================= # =============================
# Cleanup (mit Statistik) # Cleanup
# ============================= # =============================
freed_space="0" freed_space="0"
if [ "$ENABLE_CLEANUP" = true ]; then log INFO ""
if [ "$CLEANUP_ENABLED" = true ]; then
if [ "$CLEANUP_ONLY_ON_UPDATE" = true ] && \ if [ "$CLEANUP_ONLY_ON_UPDATE" = true ] && \
[ ${#notify_stacks_updated[@]} -eq 0 ]; then [ ${#notify_stacks_updated[@]} -eq 0 ]; then
@@ -334,42 +517,32 @@ if [ "$ENABLE_CLEANUP" = true ]; then
else else
before_size=$(get_docker_disk_usage) before_size=$(get_docker_disk_usage)
log INFO ""
log INFO "${INDENT}🧹 Docker Cleanup läuft..."
log INFO "🧹 Docker Cleanup läuft..." if [ "$CLEANUP_IMAGES_ENABLED" = true ]; then
if [ "$CLEANUP_IMAGES" = true ]; then
case "$CLEANUP_IMAGES_MODE" in case "$CLEANUP_IMAGES_MODE" in
unused) unused) run_cmd docker image prune -a -f >/dev/null 2>&1 ;;
log INFO " → Entferne ungenutzte Images" dangling) run_cmd docker image prune -f >/dev/null 2>&1 ;;
run_cmd docker image prune -a -f >/dev/null 2>&1
;;
dangling)
log INFO " → Entferne dangling Images"
run_cmd docker image prune -f >/dev/null 2>&1
;;
esac esac
fi fi
if [ "$CLEANUP_CONTAINERS" = true ]; then if [ "$CLEANUP_CONTAINERS_ENABLED" = true ]; then
log INFO " → Entferne gestoppte Container"
run_cmd docker container prune -f >/dev/null 2>&1 run_cmd docker container prune -f >/dev/null 2>&1
fi fi
if [ "$CLEANUP_VOLUMES" = true ]; then if [ "$CLEANUP_VOLUMES_ENABLED" = true ]; then
log WARN " → Entferne ungenutzte Volumes"
run_cmd docker volume prune -f >/dev/null 2>&1 run_cmd docker volume prune -f >/dev/null 2>&1
fi fi
if [ "$CLEANUP_NETWORKS" = true ]; then if [ "$CLEANUP_NETWORKS_ENABLED" = true ]; then
log INFO " → Entferne ungenutzte Netzwerke"
run_cmd docker network prune -f >/dev/null 2>&1 run_cmd docker network prune -f >/dev/null 2>&1
fi fi
after_size=$(get_docker_disk_usage) after_size=$(get_docker_disk_usage)
freed_space=$((after_size < before_size ? before_size - after_size : 0))
freed_space=$(awk "BEGIN {print $before_size - $after_size}") log INFO "${INDENT}✅ Cleanup abgeschlossen (${freed_space} MB freigegeben)"
log INFO "✔️ Cleanup abgeschlossen (freigegeben: ${freed_space} MB)"
fi fi
fi fi
@@ -379,50 +552,46 @@ fi
if [ "$NTFY_ENABLED" = true ]; then if [ "$NTFY_ENABLED" = true ]; then
msg="Docker Compose Update Report" msg=""
if [ ${#notify_stacks_updated[@]} -gt 0 ]; then if [ ${#stack_tree[@]} -gt 0 ]; then
msg+=$'\n\n🔄 Aktualisierte Stacks' msg+=$'#### 🔄 Stack Updates\n'
for s in "${notify_stacks_updated[@]}"; do
msg+=$'\n - '"$s" for stack in $(printf "%s\n" "${!stack_tree[@]}" | sort); do
msg+=$'\n'"$stack"
msg+="${stack_tree[$stack]}"
# msg+=$'\n' # Leerzeile nach jedem Stack
done done
fi fi
if [ ${#notify_excluded_updates[@]} -gt 0 ]; then if [ ${#notify_excluded_updates[@]} -gt 0 ]; then
msg+=$'\n\n⏭ Excluded (Update verfügbar)' msg+=$'\n\n#### ⏭️ Excluded (Update verfügbar)'
for s in "${notify_excluded_updates[@]}"; do for s in "${notify_excluded_updates[@]}"; do
msg+=$'\n - '"$s" msg+=$'\n - '"$s"
done done
fi fi
if [ ${#stack_tree[@]} -eq 0 ] && \
[ ${#notify_excluded_updates[@]} -eq 0 ]; then
msg+=$'\n\n#### ✔️ Alles aktuell'
fi
if [ "$freed_space" != "0" ]; then
msg+=$'\n\n#### 🧹 Cleanup: '"${freed_space} MB freigegeben"
fi
if [ "$error_flag" = true ]; then if [ "$error_flag" = true ]; then
msg+=$'\n\n❗ Fehler sind aufgetreten Logs prüfen'
PRIORITY=5 PRIORITY=5
elif [ ${#notify_stacks_updated[@]} -gt 0 ]; then elif [ ${#stack_tree[@]} -gt 0 ]; then
PRIORITY=3 PRIORITY=3
else else
PRIORITY=1 PRIORITY=1
fi fi
if [ ${#notify_stacks_updated[@]} -eq 0 ] && \ send_ntfy "$msg" "$PRIORITY"
[ ${#notify_excluded_updates[@]} -eq 0 ]; then log INFO "📨 ntfy Nachricht gesendet (prio=$PRIORITY)"
msg+=$'\n\n✔ Alles aktuell'
fi
if [ "$freed_space" != "0" ]; then
msg+=$'\n\n🧹 Cleanup: '"${freed_space} MB freigegeben"
fi
if [ "$NTFY_ONLY_ON_CHANGES" = false ] || \
[ ${#notify_stacks_updated[@]} -gt 0 ] || \
[ ${#notify_excluded_updates[@]} -gt 0 ] || \
[ "$error_flag" = true ]; then
send_ntfy "$msg" "$PRIORITY"
log INFO "ntfy Nachricht gesendet (prio=$PRIORITY)"
else
log INFO "keine Änderungen, keine ntfy Nachricht"
fi
fi fi
script_end=$(date +%s)
log INFO "🕒 Gesamtzeit: $((script_end - script_start))s"
log INFO "==== Update beendet ====" log INFO "==== Update beendet ===="