Files
HODA/internal/discovery/discovery.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

180 lines
4.3 KiB
Go

// Package discovery keeps the store in step with this host's labeled containers.
package discovery
import (
"context"
"log/slog"
"strings"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/docker"
)
const (
settle = 300 * time.Millisecond // lets a burst of events become one re-list
minBackoff = time.Second
maxBackoff = 30 * time.Second
)
// IconQueue is told about the current apps so their icons get resolved.
type IconQueue interface {
Ensure([]apps.App)
}
// Discovery lists this host's labeled containers into the store.
type Discovery struct {
docker *docker.Client
store *apps.Store
icons IconQueue
prefix string
host string
resync time.Duration
log *slog.Logger
reachable bool
count int
warned map[string]string // container name -> the label problem already logged
}
// New returns a Discovery for the Docker host behind dc, named host on the dashboard.
func New(dc *docker.Client, store *apps.Store, icons IconQueue, prefix, host string, resync time.Duration, log *slog.Logger) *Discovery {
return &Discovery{
docker: dc,
store: store,
icons: icons,
prefix: prefix,
host: host,
resync: resync,
log: log,
reachable: true,
count: -1,
warned: map[string]string{},
}
}
// Run lists containers now, again after every relevant Docker event and every
// resync interval, until ctx ends.
func (d *Discovery) Run(ctx context.Context) {
changed := make(chan struct{}, 1)
go d.watch(ctx, func() {
select {
case changed <- struct{}{}:
default:
}
})
ticker := time.NewTicker(d.resync)
defer ticker.Stop()
d.sync(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-changed:
select {
case <-ctx.Done():
return
case <-time.After(settle):
}
select {
case <-changed:
default:
}
}
d.sync(ctx)
}
}
func (d *Discovery) sync(ctx context.Context) {
listCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
containers, err := d.docker.ListContainers(listCtx, d.prefix+".enable")
if err != nil {
if ctx.Err() != nil {
return
}
d.store.SetError(apps.LocalSource, d.host)
if d.reachable {
d.log.Warn("can't reach Docker, keeping the last known apps", "err", err)
}
d.reachable = false
return
}
if !d.reachable {
d.log.Info("Docker is reachable again")
}
d.reachable = true
list := make([]apps.App, 0, len(containers))
seen := make(map[string]bool, len(containers))
for _, c := range containers {
app, ok, err := apps.FromContainer(c, d.prefix, d.host)
if !ok {
continue
}
name := c.Name()
seen[name] = true
if err != nil {
if d.warned[name] != err.Error() {
d.log.Warn("container left off the dashboard", "container", name, "reason", err)
d.warned[name] = err.Error()
}
continue
}
delete(d.warned, name)
list = append(list, app)
}
for name := range d.warned {
if !seen[name] {
delete(d.warned, name)
}
}
if len(list) != d.count {
d.log.Info("apps on this host", "count", len(list))
d.count = len(list)
}
d.store.SetApps(apps.LocalSource, d.host, list)
d.icons.Ensure(list)
}
// watch follows Docker events and reconnects with backoff when the stream breaks.
func (d *Discovery) watch(ctx context.Context, changed func()) {
backoff := minBackoff
for {
started := time.Now()
err := d.docker.Events(ctx, d.prefix+".enable", func(ev docker.Event) {
if relevant(ev.Action) {
changed()
}
})
if ctx.Err() != nil {
return
}
if time.Since(started) > time.Minute {
backoff = minBackoff
}
d.log.Debug("Docker event stream ended, reconnecting", "err", err, "in", backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff = min(backoff*2, maxBackoff)
changed() // events may have been missed while disconnected
}
}
// relevant drops the event chatter of healthchecks and `docker exec`, which
// never changes what the dashboard shows.
func relevant(action string) bool {
switch {
case strings.HasPrefix(action, "exec_"), strings.HasPrefix(action, "archive-path"):
return false
case action == "attach", action == "detach", action == "resize", action == "top",
action == "commit", action == "copy", action == "export", action == "extract-to-dir":
return false
}
return true
}