Files
hamlab/internal/store/store.go
T
Mitchell-JohnsonandClaude Opus 5 f17a365ea1 stacks: remember where a container's bind mounts pointed
Every stack on core and grid is labelled with a path inside Dockhand's
filesystem — /app/data/stacks/… — because Dockhand ran compose from inside its
own container. Ham Lab resolves those by walking the bind mounts of running
containers, which works right up until the container doing the mounting is
removed. Retiring Dockhand would, in one step, make seventeen stacks on core
and five on grid unfindable.

So the mounts are written down as they are seen, and a path no live container
explains is looked for in what was seen before. A remembered mount resolves
with no container to run in, which is the right answer: compose runs on the
host, against the host's own copy of the file.

The same problem bites a scheduled update, which captures how to invoke
compose when it is made so the cron job never has to rediscover the world at
3am. repoint() checks the named container is still running and falls back the
same way if it is not — and pointedly leaves the spec alone if nothing
explains the path, so it fails loudly rather than deploying a guess.

Out in cron the record is read and never added to: the daemon owns state.json
and would clobber anything written from there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:19:54 -07:00

159 lines
5.0 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
}
// RememberedMount is one bind mount of a container that was running when Ham Lab last
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
// when that container is gone the path is the only way back to the file on disk.
type RememberedMount struct {
Container string `json:"container"`
Dest string `json:"dest"` // the path inside the container
Src string `json:"src"` // the path on the machine
Seen time.Time `json:"seen"`
}
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"` // rules, channels and recent events (internal/api/alerts.go)
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)
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
// orchestrator that has since been removed can still be found on disk. See
// internal/api/stacks.go.
Mounts []RememberedMount `json:"mounts,omitempty"`
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)
}