Dockhand is being retired, so the Docker tab has to cover what it actually earned its keep doing. Eight pieces, all per container or per machine: - Stats: CPU, memory and IO over time, from sensors already collected. - Health: the container's own healthcheck, with the last few probe outputs — which is where a failing check says why. - Events: `docker events` keeps a minute of backlog and nothing more, so Ham Lab follows the stream and keeps a ring of its own. A restart at 4am is still there at 9. - Shell: `docker exec` into a container, in the terminal already here. - Limits: memory, CPU and restart policy changed live, without hand-editing the compose file for a number you want to try. - Updates: what the registry has that this machine does not, for every image at once rather than one stack at a time. - The same across every machine, asked of the hub — which is the only thing that can reach them all. - Schedules: a real line in /etc/cron.d/hamlab-docker calling the binary, in the same shape as the ZFS snapshot schedules. Dockhand updated DNS and ingress nightly from a ticker inside a web app; a cron line keeps running when the console does not, and is visible to anyone reading the machine the normal way. internal/compose is new and shared, so a deploy from the console and a deploy from cron invoke compose identically — including the case where compose has to run inside the orchestrator's own container, where the paths are that container's and not the host's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
5.6 KiB
Go
230 lines
5.6 KiB
Go
package api
|
|
|
|
// A container's history.
|
|
//
|
|
// `docker events` is a live stream with only a short in-memory backlog — ask it for the
|
|
// last thirty days and you get the last few minutes, because the daemon keeps events in a
|
|
// small ring and loses them all when it restarts. That is why Dockhand kept its own copy,
|
|
// and why this does too: a watcher follows the stream and appends the lifecycle events to a
|
|
// ring kept beside the config, so "what happened to this container last Tuesday" has an
|
|
// answer.
|
|
//
|
|
// Only lifecycle events are kept. A health check produces three exec events every probe,
|
|
// which is thousands a day and tells you nothing that the health panel does not.
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlab/internal/priv"
|
|
)
|
|
|
|
const (
|
|
eventsKept = 4000
|
|
eventsSaveEvery = 30 * time.Second
|
|
)
|
|
|
|
// ContainerEvent is one thing that happened to one container.
|
|
type ContainerEvent struct {
|
|
T int64 `json:"t"` // unix ms
|
|
Name string `json:"name"`
|
|
Action string `json:"action"`
|
|
Image string `json:"image,omitempty"`
|
|
ExitCode string `json:"exit_code,omitempty"`
|
|
Signal string `json:"signal,omitempty"`
|
|
Health string `json:"health,omitempty"`
|
|
}
|
|
|
|
type eventLog struct {
|
|
mu sync.Mutex
|
|
ring []ContainerEvent
|
|
dirty bool
|
|
path string
|
|
}
|
|
|
|
func (e *eventLog) load(path string) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.path = path
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var list []ContainerEvent
|
|
if json.Unmarshal(b, &list) == nil {
|
|
e.ring = list
|
|
}
|
|
}
|
|
|
|
func (e *eventLog) add(ev ContainerEvent) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.ring = append(e.ring, ev)
|
|
if len(e.ring) > eventsKept {
|
|
e.ring = append([]ContainerEvent(nil), e.ring[len(e.ring)-eventsKept:]...)
|
|
}
|
|
e.dirty = true
|
|
}
|
|
|
|
// forName is one container's events, newest first.
|
|
func (e *eventLog) forName(name string, since time.Time, limit int) []ContainerEvent {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
out := []ContainerEvent{}
|
|
for _, ev := range e.ring {
|
|
if ev.Name == name && ev.T >= since.UnixMilli() {
|
|
out = append(out, ev)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].T > out[j].T })
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (e *eventLog) save() {
|
|
e.mu.Lock()
|
|
if !e.dirty || e.path == "" {
|
|
e.mu.Unlock()
|
|
return
|
|
}
|
|
b, err := json.Marshal(e.ring)
|
|
e.dirty = false
|
|
path := e.path
|
|
e.mu.Unlock()
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(path), 0o700)
|
|
tmp := path + ".tmp"
|
|
if os.WriteFile(tmp, b, 0o600) == nil {
|
|
_ = os.Rename(tmp, path)
|
|
}
|
|
}
|
|
|
|
// watchDockerEvents follows the stream, restarting if Docker goes away. It is the only
|
|
// thing that writes the log, so nothing contends with it.
|
|
func (s *Server) watchDockerEvents(ctx context.Context) {
|
|
s.events.load(filepath.Join(s.cfg.DataDir(), "docker-events.json"))
|
|
go func() { // a periodic flush, so a crash loses at most half a minute of history
|
|
t := time.NewTicker(eventsSaveEvery)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
s.events.save()
|
|
return
|
|
case <-t.C:
|
|
s.events.save()
|
|
}
|
|
}
|
|
}()
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if dockerTool() == "" {
|
|
time.Sleep(time.Minute) // no Docker here; look again later in case it is installed
|
|
continue
|
|
}
|
|
s.followEvents(ctx)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(10 * time.Second): // the daemon restarted, or the pipe broke
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) followEvents(ctx context.Context) {
|
|
args := []string{"events", "--format", "{{json .}}", "--filter", "type=container"}
|
|
for _, e := range []string{"create", "start", "restart", "stop", "kill", "die", "destroy", "oom", "pause", "unpause", "rename", "update", "health_status"} {
|
|
args = append(args, "--filter", "event="+e)
|
|
}
|
|
tool := dockerTool()
|
|
full := append([]string{tool}, args...)
|
|
if !dockerReachable() && priv.Available() {
|
|
full = priv.Wrap(full...)
|
|
}
|
|
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return
|
|
}
|
|
sc := bufio.NewScanner(out)
|
|
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
|
n := 0
|
|
for sc.Scan() {
|
|
var e struct {
|
|
Action string `json:"Action"`
|
|
Time int64 `json:"time"`
|
|
TimeNano int64 `json:"timeNano"`
|
|
Actor struct {
|
|
Attributes map[string]string `json:"Attributes"`
|
|
} `json:"Actor"`
|
|
}
|
|
if json.Unmarshal(sc.Bytes(), &e) != nil || e.Action == "" {
|
|
continue
|
|
}
|
|
at := e.Actor.Attributes
|
|
ev := ContainerEvent{
|
|
T: e.Time * 1000, Name: at["name"], Image: at["image"],
|
|
Action: e.Action, ExitCode: at["exitCode"], Signal: at["signal"],
|
|
}
|
|
if e.TimeNano > 0 {
|
|
ev.T = e.TimeNano / 1e6
|
|
}
|
|
// health_status comes through as "health_status: healthy"
|
|
if a, h, ok := strings.Cut(e.Action, ": "); ok {
|
|
ev.Action, ev.Health = a, h
|
|
}
|
|
if ev.Name == "" {
|
|
continue
|
|
}
|
|
s.events.add(ev)
|
|
n++
|
|
}
|
|
_ = cmd.Wait()
|
|
if n > 0 {
|
|
log.Printf("docker events: stream ended after %d events; reconnecting", n)
|
|
}
|
|
}
|
|
|
|
// dockerEvents answers from Ham Lab's own history, not Docker's short memory.
|
|
func (s *Server) dockerEvents(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !dockerRef.MatchString(id) {
|
|
jsonErr(w, 400, "bad container")
|
|
return
|
|
}
|
|
since := r.URL.Query().Get("since")
|
|
d := 7 * 24 * time.Hour
|
|
switch since {
|
|
case "24h":
|
|
d = 24 * time.Hour
|
|
case "30d":
|
|
d = 30 * 24 * time.Hour
|
|
default:
|
|
since = "7d"
|
|
}
|
|
ev := s.events.forName(id, time.Now().Add(-d), 500)
|
|
s.events.mu.Lock()
|
|
total := len(s.events.ring)
|
|
s.events.mu.Unlock()
|
|
jsonOK(w, 200, map[string]any{"events": ev, "since": since, "kept": total, "capacity": eventsKept})
|
|
}
|