10 Commits

7 changed files with 130 additions and 6 deletions

View File

@@ -9,5 +9,8 @@ ENTRYPOINT ["/mailcow-birthday-daemon"]
ENV STATEFILE=/data/state.json
VOLUME [ "/data" ]
HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 \
CMD ["/mailcow-birthday-daemon", "healthcheck"]
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY mailcow-birthday-daemon /mailcow-birthday-daemon

View File

@@ -40,6 +40,8 @@ volumes:
birthdaydaemon:
```
> **Hinweis:** Das obige Beispiel zeigt nur die minimal nötigen Umgebungsvariablen. Eine vollständige Übersicht aller verfügbaren Umgebungsvariablen findest du im [Schnellstart](docs/schnellstart.md).
Anschließend starten:
```bash

80
cmd/mcbdd/health.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// maxSyncAge ist die maximale Dauer seit dem letzten Sync-Lauf, bevor der
// Healthcheck den Daemon als unhealthy meldet. Da der Sync alle 15 Minuten
// läuft, erlauben wir 20 Minuten Toleranz.
const maxSyncAge = 20 * time.Minute
// healthFile ist der Dateiname der Healthcheck-Statusdatei, die neben dem
// State-File abgelegt wird.
const healthFile = "health.json"
// healthStatus wird als JSON in die Healthcheck-Datei geschrieben.
type healthStatus struct {
LastSync time.Time `json:"last_sync"`
LastError string `json:"last_error,omitempty"`
}
// healthState hält den aktuellen Health-Status im Speicher und schreibt
// ihn nach jedem Sync-Lauf in eine Datei.
type healthState struct {
mu sync.Mutex
filePath string
}
func newHealthState(stateFilepath string) *healthState {
dir := filepath.Dir(stateFilepath)
return &healthState{
filePath: filepath.Join(dir, healthFile),
}
}
// update wird nach jedem Sync-Lauf aufgerufen und schreibt den Status
// in die Health-Datei.
func (h *healthState) update(err error) {
h.mu.Lock()
defer h.mu.Unlock()
s := healthStatus{
LastSync: time.Now(),
}
if err != nil {
s.LastError = err.Error()
}
data, _ := json.Marshal(s)
os.WriteFile(h.filePath, data, 0644)
}
// runHealthcheck liest die Health-Datei und prüft, ob der Daemon healthy ist.
// Exit-Code 0 = healthy, 1 = unhealthy. Wird von Docker HEALTHCHECK aufgerufen.
func runHealthcheck() error {
stateFilepath := os.Getenv("STATEFILE")
if stateFilepath == "" {
stateFilepath = "state.json"
}
healthPath := filepath.Join(filepath.Dir(stateFilepath), healthFile)
data, err := os.ReadFile(healthPath)
if err != nil {
return fmt.Errorf("health file not found: %w (daemon may still be starting)", err)
}
var s healthStatus
if err := json.Unmarshal(data, &s); err != nil {
return fmt.Errorf("invalid health file: %w", err)
}
if s.LastError != "" {
return fmt.Errorf("last sync failed: %s", s.LastError)
}
if time.Since(s.LastSync) > maxSyncAge {
return fmt.Errorf("last sync too old: %s ago", time.Since(s.LastSync).Round(time.Second))
}
return nil
}

View File

@@ -37,15 +37,25 @@ type Daemon struct {
oldCalendarName string
notificationEnabled bool
notificationTrigger string
health *healthState
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "cleanup" {
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
if len(os.Args) > 1 {
switch os.Args[1] {
case "cleanup":
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
}
return
case "healthcheck":
if err := runHealthcheck(); err != nil {
slog.Error("healthcheck failed", "err", err)
os.Exit(1)
}
return
}
return
}
if err := run(); err != nil {
slog.Error("fatal error", "err", err)
@@ -101,6 +111,7 @@ func run() error {
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"
}
d.health = newHealthState(d.stateFilepath)
d.mailcowClient = mailcow.New(
d.httpClient,
mailcowBase,
@@ -115,7 +126,9 @@ func run() error {
func (d *Daemon) daemonLoop() {
for {
if err := d.daemonRun(); err != nil {
err := d.daemonRun()
d.health.update(err)
if err != nil {
slog.Error("error while syncing birthdays", "err", err)
}
time.Sleep(time.Minute * 15)

View File

@@ -32,3 +32,10 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
## Synchronisationsintervall
Der Synchronisationszyklus läuft alle **15 Minuten** automatisch.
## Healthcheck
- Das Dockerfile enthält eine `HEALTHCHECK`-Anweisung, die den eingebauten Subcommand `healthcheck` nutzt es werden keine externen Tools wie `curl` oder `wget` benötigt und kein Port wird geöffnet.
- Nach jedem Sync-Lauf schreibt der Daemon eine kleine Statusdatei (`health.json`) neben das State-File. Der `healthcheck`-Subcommand liest diese Datei und prüft, ob der letzte Sync aktuell und fehlerfrei war.
- Docker zeigt den Status in `docker ps` als `(healthy)` oder `(unhealthy)` an.
- Der Healthcheck meldet **unhealthy**, wenn der letzte Sync-Lauf fehlgeschlagen ist oder länger als 20 Minuten zurückliegt. Während der Startphase (bevor der erste Sync abgeschlossen ist) gilt der Daemon als healthy.

View File

@@ -23,6 +23,7 @@ services:
environment:
- MAILCOW_BASE=https://mail.example.com
- MAILCOW_APIKEY=DEIN-APIKEY-HIER
- CALENDAR_NAME=Birthdays
# - MAILCOW_RESOLVE_HOST=nginx-mailcow
# - NOTIFICATION_ENABLED=true
# - NOTIFICATION_TIME=08:00

View File

@@ -30,8 +30,26 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup <alter-kalen
docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup Birthdays
```
**Beispielausgabe:**
```
2026/03/28 23:46:22 INFO starting calendar cleanup calendarName=Birthdays
2026/03/28 23:46:22 INFO using internal resolve host for connections resolveHost=nginx-mailcow
2026/03/28 23:46:22 INFO removed old birthday calendar user=user1@example.com calendar=Birthdays
2026/03/28 23:46:23 INFO removed old birthday calendar user=user2@example.com calendar=Birthdays
2026/03/28 23:46:23 INFO removed old birthday calendar user=user3@example.com calendar=Birthdays
2026/03/28 23:46:24 INFO cleanup finished processed=5 skipped=0
```
> **Hinweis:** Der Daemon muss vorher mindestens einmal gelaufen sein, damit App-Passwörter im State-File vorhanden sind. Benutzer ohne gespeichertes Passwort werden übersprungen.
## Kalender erscheint nicht in SOGo
SOGo zeigt neue Kalender manchmal erst nach einem Neuladen der Seite (Strg+Shift+R) oder nach dem nächsten Login an. Der Kalender wird unter dem Namen erstellt, der in `CALENDAR_NAME` konfiguriert ist (Standard: `Birthdays`).
## Healthcheck meldet `unhealthy`
1. Logs prüfen: `docker compose logs -f birthdaydaemon`
2. Status manuell abfragen: `docker compose exec birthdaydaemon /mailcow-birthday-daemon healthcheck`
3. Der Healthcheck meldet `unhealthy`, wenn der letzte Sync-Lauf fehlgeschlagen ist oder länger als 20 Minuten zurückliegt.
4. Falls der Container gerade erst gestartet wurde, kann es bis zu 2 Minuten dauern, bis der erste Sync abgeschlossen und der Status `healthy` ist.