diff --git a/Dockerfile b/Dockerfile index 74705022..d1bc9e4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,7 @@ FROM gcr.io/distroless/static-debian11 COPY unpoller /usr/bin/unpoller COPY --from=builder /etc/unpoller /etc/unpoller +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["/usr/bin/unpoller", "--health"] + ENTRYPOINT [ "/usr/bin/unpoller" ] diff --git a/examples/MANUAL.md b/examples/MANUAL.md index 1bad8477..00f792da 100644 --- a/examples/MANUAL.md +++ b/examples/MANUAL.md @@ -24,7 +24,7 @@ examples and default configurations. OPTIONS --- -`unpoller [-c ,[config-file]] [-j ] [-e ] [-h] [-v]` +`unpoller [-c ,[config-file]] [-j ] [-e ] [--health] [-d] [-h] [-v]` -c, --config ,[config-file] Provide a configuration file (instead of the default). You may provide @@ -39,6 +39,17 @@ OPTIONS -v, --version Display version and exit. + --health + Run a health check and exit with status 0 (healthy) or 1 (unhealthy). + This validates the configuration file, ensures input and output plugins + are properly configured, and performs basic connectivity checks. Useful + for Docker HEALTHCHECK and container orchestration readiness probes. + + -d, --debugio + Debug the inputs and outputs configured and exit. This performs more + verbose validation checks than --health and is useful for troubleshooting + configuration issues. + -j, --dumpjson This is a debug option; use this when you are missing data in your graphs, and/or you want to inspect the raw data coming from the controller. The diff --git a/init/docker/README.md b/init/docker/README.md index 50ad54f0..c2c6ec86 100644 --- a/init/docker/README.md +++ b/init/docker/README.md @@ -9,3 +9,18 @@ in InfluxDB by UniFi Poller. ##### HOWTO **Learn more about how and when to use these *Docker Compose* files in the [Docker Wiki](https://unpoller.com/docs/install/dockercompose).** + +## Health Check + +The UniFi Poller Docker image includes a built-in health check that validates +the configuration and checks plugin connectivity. The health check runs every +30 seconds and marks the container as unhealthy if configuration issues are +detected or if enabled outputs cannot be reached. + +You can manually run the health check: +```bash +docker exec /usr/bin/unpoller --health +``` + +The health check is automatically used by Docker and container orchestration +platforms (Kubernetes, Docker Swarm, etc.) to determine container health status. diff --git a/init/docker/docker-compose.yml b/init/docker/docker-compose.yml index 4c405739..bb33c127 100644 --- a/init/docker/docker-compose.yml +++ b/init/docker/docker-compose.yml @@ -34,6 +34,8 @@ services: unifi-poller: restart: always image: ghcr.io/unpoller/unpoller:${POLLER_TAG} + # Health check is built into the Docker image + # It validates configuration and checks plugin connectivity depends_on: - grafana - influxdb diff --git a/init/synology-docker-compose/docker-compose.yml b/init/synology-docker-compose/docker-compose.yml index 20c02e4c..49a6fbe9 100644 --- a/init/synology-docker-compose/docker-compose.yml +++ b/init/synology-docker-compose/docker-compose.yml @@ -36,6 +36,8 @@ services: unifi-poller: restart: always image: ghcr.io/unpoller/unpoller:${POLLER_TAG} + # Health check is built into the Docker image + # It validates configuration and checks plugin connectivity environment: - UP_INFLUXDB_USER=${INFLUXDB_ADMIN_USER} - UP_INFLUXDB_PASS=${INFLUXDB_ADMIN_PASSWORD} diff --git a/pkg/poller/commands.go b/pkg/poller/commands.go index 52a73740..d5ff9712 100644 --- a/pkg/poller/commands.go +++ b/pkg/poller/commands.go @@ -122,3 +122,66 @@ func (u *UnifiPoller) DebugIO() error { return allErr } + +// HealthCheck performs a basic health check suitable for Docker HEALTHCHECK. +// It validates configuration and checks if inputs/outputs are properly configured. +// Returns nil (exit 0) if healthy, error (exit 1) if unhealthy. +func (u *UnifiPoller) HealthCheck() error { + // Silence output for health checks (Docker doesn't need verbose logs). + u.Quiet = true + + // Load configuration. + cfile, err := getFirstFile(strings.Split(u.Flags.ConfigFile, ",")) + if err != nil { + return fmt.Errorf("health check failed: config file not found: %w", err) + } + + u.Flags.ConfigFile = cfile + + if err := u.ParseConfigs(); err != nil { + return fmt.Errorf("health check failed: config parse error: %w", err) + } + + inputSync.RLock() + defer inputSync.RUnlock() + + outputSync.RLock() + defer outputSync.RUnlock() + + // Check that we have at least one input and one output configured. + if len(inputs) == 0 { + return fmt.Errorf("health check failed: no input plugins configured") + } + + if len(outputs) == 0 { + return fmt.Errorf("health check failed: no output plugins configured") + } + + // Check if at least one output is enabled. + hasEnabledOutput := false + for _, output := range outputs { + if output.Enabled() { + hasEnabledOutput = true + break + } + } + + if !hasEnabledOutput { + return fmt.Errorf("health check failed: no enabled output plugins") + } + + // Perform basic validation checks on enabled outputs. + for _, output := range outputs { + if !output.Enabled() { + continue + } + + ok, err := output.DebugOutput() + if !ok || err != nil { + return fmt.Errorf("health check failed: output %s validation failed: %w", output.Name, err) + } + } + + // All checks passed, application is healthy. + return nil +} diff --git a/pkg/poller/config.go b/pkg/poller/config.go index f831c5da..7388b420 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -73,6 +73,7 @@ type Flags struct { HashPW string ShowVer bool DebugIO bool + Health bool *pflag.FlagSet } diff --git a/pkg/poller/start.go b/pkg/poller/start.go index 141bfd1f..45a85f7f 100644 --- a/pkg/poller/start.go +++ b/pkg/poller/start.go @@ -34,6 +34,10 @@ func (u *UnifiPoller) Start() error { return u.PrintPasswordHash() } + if u.Flags.Health { + return u.HealthCheck() + } + cfile, err := getFirstFile(strings.Split(u.Flags.ConfigFile, ",")) if err != nil { return err @@ -76,6 +80,7 @@ func (f *Flags) Parse(args []string) { f.StringVarP(&f.DumpJSON, "dumpjson", "j", "", "This debug option prints a json payload and exits. See man page for more info.") f.BoolVarP(&f.DebugIO, "debugio", "d", false, "Debug the Inputs and Outputs configured and exit.") + f.BoolVarP(&f.Health, "health", "", false, "Run health check and exit with status 0 (healthy) or 1 (unhealthy).") f.StringVarP(&f.ConfigFile, "config", "c", DefaultConfFile(), "Poller config file path. Separating multiple paths with a comma will load the first config file found.") f.BoolVarP(&f.ShowVer, "version", "v", false, "Print the version and exit.")