Files
hamlab/internal/store/store.go
T
Mitchell-JohnsonandClaude Opus 5 192504ead3 docker: the eight things Dockhand did that Ham Lab did not
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>
2026-09-24 20:11:36 -07:00

145 lines
4.3 KiB
Go

// Package store persists small runtime state (terminal names, login sessions)
// to a JSON file with atomic writes.
package store
import (
"encoding/json"
"errors"
"log"
"os"
"sync"
"time"
)
type Terminal struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Session is one signed-in browser. The map key is the cookie's token; ID is what the
// UI sees and revokes by, so a session list never shows a usable token.
type Session struct {
ID string `json:"id"`
User string `json:"user"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created,omitempty"`
LastSeen time.Time `json:"last_seen,omitempty"`
IP string `json:"ip,omitempty"`
Agent string `json:"agent,omitempty"`
}
// UnmarshalJSON also reads the pre-users form, a bare expiry time: such a session has no
// user yet; the server gives it to the migrated first account.
func (s *Session) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
return json.Unmarshal(b, &s.Expires)
}
type plain Session
return json.Unmarshal(b, (*plain)(s))
}
// UserState is what follows an account rather than the machine: how the console looks
// to that person and how their dashboard is laid out.
type UserState struct {
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
}
type State struct {
Terminals []Terminal `json:"terminals"`
Sessions map[string]Session `json:"sessions"` // token -> session
Users map[string]*UserState `json:"users,omitempty"`
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
// account. Kept so an old state file still reads.
UI json.RawMessage `json:"ui,omitempty"`
Alerts json.RawMessage `json:"alerts,omitempty"`
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go) // rules, channels and recent events (internal/api/alerts.go)
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
}
// UserState returns the prefs of an account, making an empty one if needed.
func (st *State) UserState(name string) *UserState {
if st.Users == nil {
st.Users = map[string]*UserState{}
}
u := st.Users[name]
if u == nil {
u = &UserState{}
st.Users[name] = u
}
return u
}
type Store struct {
mu sync.Mutex
path string
state State
}
func Open(path string) (*Store, error) {
s := &Store{path: path}
b, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil {
// State is conveniences (sessions, terminal names, layout). A file mangled by a power
// cut must not keep the console down: set it aside and start clean.
if err := json.Unmarshal(b, &s.state); err != nil {
s.state = State{}
_ = os.Rename(path, path+".corrupt")
log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path)
}
}
if s.state.Sessions == nil {
s.state.Sessions = map[string]Session{}
}
return s, nil
}
// Update applies fn under the lock and writes the result to disk.
func (s *Store) Update(fn func(*State)) error {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.state)
return s.save()
}
// View reads state under the lock.
func (s *Store) View(fn func(*State)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.state)
}
func (s *Store) save() error {
b, err := json.MarshalIndent(&s.state, "", " ")
if err != nil {
return err
}
return WriteFileAtomic(s.path, b, 0o600)
}
// WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the
// old version or the new one, never empty or half-written.
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
_, err = f.Write(data)
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}