CI / build (push) Successful in 30s
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>
180 lines
4.3 KiB
Go
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
|
|
}
|