12 Commits

Author SHA1 Message Date
67c3f10454 feat: Dateibasierten Docker-Healthcheck hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m49s
Run Tests / test (pull_request) Successful in 4m45s
2026-03-29 20:41:51 +02:00
766b69aa4a Merge pull request 'docs' (#13) from docs into master
Reviewed-on: #13
2026-03-29 00:10:40 +00:00
c623e39b4c Merge branch 'master' into docs 2026-03-29 00:10:31 +00:00
c5337d7d63 Merge branch 'docs' of https://git.techniverse.net/scriptos/mailcow-birthday-daemon into docs 2026-03-29 01:10:05 +01:00
efcbd04aa2 docs: Hinweis auf Schnellstart für Umgebungsvariablen in README ergänzt 2026-03-29 01:09:58 +01:00
dc01480b8b Merge pull request 'docs: CALENDAR_NAME in Beispiel Compose ergänzt' (#12) from docs into master
Reviewed-on: #12
2026-03-29 00:01:51 +00:00
78ebe7a499 Merge branch 'master' into docs 2026-03-29 00:01:31 +00:00
b9c81bd04e docs: CALENDAR_NAME in Beispiel Compose ergänzt 2026-03-29 01:00:56 +01:00
2568258794 Merge pull request 'docs: Beispielausgabe für Cleanup-Befehl ergänzt' (#11) from cleanup-docs into master
Reviewed-on: #11
2026-03-28 23:54:39 +00:00
882fb6448d docs: Beispielausgabe für Cleanup-Befehl ergänzt 2026-03-29 00:52:07 +01:00
a296efbb86 Merge pull request 'fix(ci): gitea_urls auf Top-Level verschoben – YAML-Unmarshal-Fehler behoben' (#10) from release-v0.3.2 into master
Some checks failed
Run Tests / test (push) Successful in 4m39s
Make Release / release (push) Failing after 1m48s
Reviewed-on: #10
2026-03-28 23:36:59 +00:00
cb8192640d fix(ci): gitea_urls auf Top-Level verschoben – YAML-Unmarshal-Fehler behoben
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m38s
Run Tests / test (pull_request) Successful in 4m41s
2026-03-29 00:23:40 +01:00
8 changed files with 134 additions and 10 deletions

View File

@@ -29,11 +29,11 @@ release:
gitea:
owner: "{{ .Env.REGISTRY_USER }}"
name: mailcow-birthday-daemon
gitea_urls:
api: "{{ .Env.REGISTRY_URL }}/api/v1/"
download: "{{ .Env.REGISTRY_URL }}"
skip_tls_verify: false
prerelease: auto
gitea_urls:
api: "{{ .Env.REGISTRY_URL }}/api/v1/"
download: "{{ .Env.REGISTRY_URL }}"
skip_tls_verify: false
changelog:
sort: asc
filters:

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.