Docker Build / docker (push) Successful in 41s
## Summary
- Rebuilds the master's **Services** and **Agents** pages per the "Ledger" design: sidebar shell with live nav counts, machine filter and Live indicator; dark/light theme (follows the OS, toggle overrides); embedded Schibsted Grotesk and JetBrains Mono (fontsource 5.3.0, OFL).
- Services: Swarm grouped per cluster with its machines under it, offline banner ("Agent offline since HH:MM", browser-local), Simple Icons derived from the image ref (letter circle fallback), existing badges and mode switch restyled, client-side search, machine filter and Icon / Icon + text / Text toggle.
- Agents: Online / Offline / Never connected / Revoked, master version in the header with outdated agent versions marked, revoke via popover instead of `confirm()`, "Add agent" as a three-step panel that completes when the agent says hello (SSE `agents`).
- Store: migration `0005_agent_cluster.sql` persists each agent's `swarm_cluster_id` (needed to list a cluster's managers and its offline state after a master restart).
- SSE keepalive is now a named `ping` event so the Live indicator also shows the connection is alive. `revokeAgent` now publishes `agents`.
Closes #18
## Notes
- Workers never report a cluster id (Docker only sets `Swarm.Cluster` on managers), so their Swarm role shows "—". Fixing that needs a new hello field.
- Master binary grows by about 2.16 MB, almost all Simple Icons 16.35.0 path data (gzipped JSON, regenerated with `go generate ./internal/master/web/icons`). The agent binary does not import it.
- Jobs, Job and Service detail pages only get the new shell; their own artboards are out of scope, as are Update buttons, Update all, header stats and the drift badge.
- Unchanged: CSRF on every POST, `Cache-Control: no-store` on the token response, 401 + `HX-Redirect` without a session, no `template.HTML` on agent- or user-controlled values, no Node build step.
## Test plan
- [x] `go test ./...`, `go test -race ./internal/master/...`
- [x] `go test -tags integration ./internal/master/store/` against Postgres 17
- [x] `docker build`
- [x] Browser check on fake data: both themes, 1440 px and 390 px, add-agent flow through connect
- [ ] Services grouping and icons against real stacks and a real Swarm cluster
- [ ] "Agent offline since" after stopping an agent
- [ ] Theme toggle, search and machine filter across reloads and mode changes
- [ ] Add agent end to end with a real agent, Copy buttons
- [ ] Phone at 390 px
https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
Reviewed-on: #29
220 lines
7.1 KiB
Go
220 lines
7.1 KiB
Go
// Package model holds the master's domain types shared by storage, the agent
|
|
// hub and the web UI.
|
|
package model
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shcizo/package-updater/internal/imageref"
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
// Sentinel errors returned by storage.
|
|
var (
|
|
ErrNotFound = errors.New("not found")
|
|
// ErrConflict means the row already exists, or that it changed meanwhile
|
|
// (AcceptRunning, MergeService).
|
|
ErrConflict = errors.New("already exists")
|
|
ErrRevoked = errors.New("agent revoked")
|
|
)
|
|
|
|
// Agent is a registered agent. Whether it is connected right now is held in
|
|
// memory by the hub, not here.
|
|
type Agent struct {
|
|
ID int64
|
|
Name string
|
|
Hostname string
|
|
AgentVersion string
|
|
SwarmManager bool
|
|
// SwarmClusterID is the Swarm cluster the agent's daemon was in when it last
|
|
// connected; "" outside a swarm.
|
|
SwarmClusterID string
|
|
LastSeenAt *time.Time
|
|
RevokedAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Service is one service as last reported by an agent.
|
|
type Service struct {
|
|
ID int64
|
|
Kind protocol.Kind
|
|
LocationKey string
|
|
AgentID *int64
|
|
AgentName string
|
|
ComposeProject string
|
|
ComposeService string
|
|
WorkingDir string
|
|
ConfigFiles string // raw config_files label, paths joined by ","
|
|
SwarmClusterID string
|
|
SwarmService string
|
|
ContainerName string
|
|
ImageRepo string
|
|
DesiredRef string // set by the last successful job; "" if never updated
|
|
ActualRef string
|
|
ActualDigest string
|
|
Status string
|
|
Self bool
|
|
Built bool // Compose service with a build: section; never updated
|
|
UpdateMode string // ModeManual or ModeAuto
|
|
// AvailableRef is the newest ref a webhook or poll reported for this
|
|
// service; "" if none, or if a later job already deployed it.
|
|
AvailableRef string
|
|
AvailableDigest string // digest of AvailableRef when known (poll); "" from the webhook
|
|
AvailableSource string // SourceWebhook or SourcePoll; "" when AvailableRef is empty
|
|
AvailableAt *time.Time
|
|
LastPollError string // last registry error for the followed ref; "" when fine
|
|
// LastTagDigest is the newest digest of the followed tag that was deployed
|
|
// or started: set when the poller starts an auto job, when it sees the service
|
|
// already running that digest, and when any plain tag job succeeds (the
|
|
// digest the agent pinned); "" if never. The poller does
|
|
// not start another job for the same digest, so a rollback sticks until the
|
|
// registry shows a different one.
|
|
LastTagDigest string
|
|
// AwaitingInventory is true from a successful job until the next inventory
|
|
// write for the row. Until then DesiredRef is newer than ActualRef without
|
|
// the service having drifted.
|
|
AwaitingInventory bool
|
|
FirstSeenAt time.Time
|
|
UpdatedAt time.Time
|
|
RemovedAt *time.Time
|
|
}
|
|
|
|
// Update modes of a service.
|
|
const (
|
|
ModeManual = "manual"
|
|
ModeAuto = "auto"
|
|
)
|
|
|
|
// Sources of an available ref and of an ImageVersion.
|
|
const (
|
|
SourceWebhook = "webhook"
|
|
SourcePoll = "poll"
|
|
// SourceRunning is used by image_versions only: an agent reported the
|
|
// version as running.
|
|
SourceRunning = "running"
|
|
)
|
|
|
|
// Available is a newer ref reported for a service.
|
|
type Available struct {
|
|
Ref string
|
|
Digest string // "" when unknown (webhook)
|
|
Source string // SourceWebhook or SourcePoll
|
|
At time.Time
|
|
}
|
|
|
|
// ImageVersion is one version of an image repo the master has seen. Tag and
|
|
// Digest are "" when the version has no tag or its digest is unknown; they
|
|
// are never both empty.
|
|
type ImageVersion struct {
|
|
ID int64
|
|
Repo string
|
|
Tag string
|
|
Digest string
|
|
Source string // SourceWebhook, SourcePoll or SourceRunning: the first source that saw it
|
|
FirstSeenAt time.Time
|
|
}
|
|
|
|
// Ref returns "repo:tag@digest", "repo:tag" or "repo@digest".
|
|
func (v ImageVersion) Ref() string {
|
|
return imageref.Ref{Repo: v.Repo, Tag: v.Tag, Digest: v.Digest}.String()
|
|
}
|
|
|
|
// Drifted reports whether the service runs something other than the version
|
|
// the last job deployed. A service the updater never touched, a removed one
|
|
// and one whose inventory has not caught up with the last job do not drift.
|
|
func (s Service) Drifted() bool {
|
|
return s.DesiredRef != "" && s.RemovedAt == nil && !s.AwaitingInventory &&
|
|
!RunsRef(s.DesiredRef, s.ActualRef, s.ActualDigest)
|
|
}
|
|
|
|
// RunsRef reports whether a service reporting actualRef and actualDigest runs
|
|
// the desired ref. The digest decides when both sides know one, so
|
|
// "repo:tag@D" is run by "repo:tag" reporting digest D, and by "repo@D".
|
|
// Otherwise the repo (compared exactly, without registry normalisation) and
|
|
// the followed tag must be equal.
|
|
func RunsRef(desired, actualRef, actualDigest string) bool {
|
|
d, a := imageref.Parse(desired), imageref.Parse(actualRef)
|
|
if d.Repo != a.Repo {
|
|
return false
|
|
}
|
|
ad := a.Digest
|
|
if ad == "" {
|
|
ad = actualDigest
|
|
}
|
|
if d.Digest != "" && ad != "" {
|
|
return d.Digest == ad
|
|
}
|
|
return followedTag(d) == followedTag(a)
|
|
}
|
|
|
|
// followedTag is the tag a ref follows: a bare image follows "latest" and a
|
|
// ref with a digest but no tag follows nothing.
|
|
func followedTag(r imageref.Ref) string {
|
|
if r.Tag == "" && r.Digest == "" {
|
|
return "latest"
|
|
}
|
|
return r.Tag
|
|
}
|
|
|
|
// RunningRef is the ref to record as the desired one when the running version
|
|
// is accepted: actualRef itself if it carries a digest, "repo:tag@digest" if
|
|
// only actualDigest is known (a bare image is "latest"), else actualRef.
|
|
func RunningRef(actualRef, actualDigest string) string {
|
|
r := imageref.Parse(actualRef)
|
|
if r.Digest != "" || !imageref.IsDigest(actualDigest) {
|
|
return actualRef
|
|
}
|
|
r.Tag, r.Digest = followedTag(r), actualDigest
|
|
return r.String()
|
|
}
|
|
|
|
// RunningRef returns the ref accepting the running version records as the
|
|
// desired one; see the function RunningRef. Templates post it as the guard of
|
|
// the accept form.
|
|
func (s Service) RunningRef() string { return RunningRef(s.ActualRef, s.ActualDigest) }
|
|
|
|
// Move says that the removed service FromID has probably moved to the live
|
|
// service ToID on another agent. The names are the agents' own; a revoked
|
|
// agent keeps its name.
|
|
type Move struct {
|
|
FromID, ToID int64
|
|
FromAgent, ToAgent string
|
|
}
|
|
|
|
// DisplayName is the human-facing name of the service.
|
|
func (s Service) DisplayName() string {
|
|
switch s.Kind {
|
|
case protocol.KindCompose:
|
|
return s.ComposeProject + "/" + s.ComposeService
|
|
case protocol.KindSwarm:
|
|
return s.SwarmService
|
|
default:
|
|
return s.ContainerName
|
|
}
|
|
}
|
|
|
|
// Session is a logged-in browser session.
|
|
type Session struct {
|
|
ID string
|
|
Subject string
|
|
Name string
|
|
Email string
|
|
CSRFToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// LocationKey turns an agent-relative protocol key into the fleet-wide key
|
|
// stored in services.location_key. Compose services and containers belong to
|
|
// the agent's machine; Swarm services belong to the cluster, so two manager
|
|
// agents in one cluster report the same row.
|
|
func LocationKey(agentID int64, clusterID, agentKey string) string {
|
|
kind, rest, _ := strings.Cut(agentKey, "|")
|
|
if kind == string(protocol.KindSwarm) {
|
|
return kind + "|" + clusterID + "|" + rest
|
|
}
|
|
return kind + "|" + strconv.FormatInt(agentID, 10) + "|" + rest
|
|
}
|