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
170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
//go:build integration
|
|
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/shcizo/package-updater/internal/master/model"
|
|
"github.com/shcizo/package-updater/internal/pin"
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
func TestIdentity_CreatedOnceThenReused(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
k1, err := s.LoadOrCreateIdentity(ctx, pin.GenerateKey)
|
|
require.NoError(t, err)
|
|
k2, err := s.LoadOrCreateIdentity(ctx, pin.GenerateKey)
|
|
require.NoError(t, err)
|
|
assert.True(t, k1.Equal(k2), "a restarted master must keep its key or every agent breaks")
|
|
}
|
|
|
|
func TestMigrationsAreIdempotent(t *testing.T) {
|
|
s := openTestStore(t)
|
|
require.NoError(t, migrate(context.Background(), s.pool))
|
|
}
|
|
|
|
func TestAgents_CreateLookupRevoke(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
created, err := s.CreateAgent(ctx, "node-2", "hash-2")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "node-2", created.Name)
|
|
assert.Nil(t, created.RevokedAt)
|
|
|
|
found, err := s.AgentByTokenHash(ctx, "hash-2")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, found.ID)
|
|
|
|
_, err = s.AgentByTokenHash(ctx, "nope")
|
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
|
|
|
_, err = s.RevokeAgent(ctx, created.ID, time.Now())
|
|
require.NoError(t, err)
|
|
found, err = s.AgentByTokenHash(ctx, "hash-2")
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, found.RevokedAt)
|
|
}
|
|
|
|
func TestAgents_DuplicateNameIsConflict(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
_, err := s.CreateAgent(ctx, "node-2", "h1")
|
|
require.NoError(t, err)
|
|
_, err = s.CreateAgent(ctx, "node-2", "h2")
|
|
assert.ErrorIs(t, err, model.ErrConflict)
|
|
}
|
|
|
|
func TestAgents_RecordHelloAndMarkSeen(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
a, err := s.CreateAgent(ctx, "swarm-mgr", "h")
|
|
require.NoError(t, err)
|
|
|
|
at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
|
require.NoError(t, s.RecordHello(ctx, a.ID, protocol.Hello{
|
|
Hostname: "swarm-mgr", AgentVersion: "v1", SwarmManager: true, SwarmClusterID: "c1",
|
|
}, at))
|
|
require.NoError(t, s.MarkSeen(ctx, a.ID, at.Add(time.Minute)))
|
|
|
|
agents, err := s.ListAgents(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, agents, 1)
|
|
assert.Equal(t, "swarm-mgr", agents[0].Hostname)
|
|
assert.Equal(t, "v1", agents[0].AgentVersion)
|
|
assert.True(t, agents[0].SwarmManager)
|
|
assert.Equal(t, "c1", agents[0].SwarmClusterID)
|
|
require.NotNil(t, agents[0].LastSeenAt)
|
|
assert.True(t, agents[0].LastSeenAt.Equal(at.Add(time.Minute)))
|
|
}
|
|
|
|
func TestAgents_RevokedNameCanBeReused(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
old, err := s.CreateAgent(ctx, "node-2", "h1")
|
|
require.NoError(t, err)
|
|
_, err = s.RevokeAgent(ctx, old.ID, time.Now())
|
|
require.NoError(t, err)
|
|
|
|
fresh, err := s.CreateAgent(ctx, "node-2", "h2")
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, old.ID, fresh.ID)
|
|
|
|
_, err = s.CreateAgent(ctx, "node-2", "h3")
|
|
assert.ErrorIs(t, err, model.ErrConflict, "a second active agent with the name is still a conflict")
|
|
}
|
|
|
|
func TestAgents_RevokeRemovesOwnComposeAndContainerServices(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
id := newAgent(t, s, "swarm-mgr")
|
|
require.NoError(t, s.ApplySnapshot(ctx, id, manager, []protocol.Service{slusk, adhoc, gitea}, t0))
|
|
|
|
_, err := s.RevokeAgent(ctx, id, t0.Add(time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
got, err := s.ListServices(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"gitea_gitea"}, names(got), "swarm rows are shared and must survive a revoke")
|
|
}
|
|
|
|
func TestRevokedAgent_InventoryWritesRefused(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
id := newAgent(t, s, "node-2")
|
|
_, err := s.RevokeAgent(ctx, id, t0)
|
|
require.NoError(t, err)
|
|
|
|
assert.ErrorIs(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0.Add(time.Minute)), model.ErrRevoked)
|
|
assert.ErrorIs(t, s.UpsertService(ctx, id, "", adhoc, t0.Add(time.Minute)), model.ErrRevoked)
|
|
|
|
got, err := s.ListServices(ctx)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, got, "a revoked agent must not write inventory")
|
|
}
|
|
|
|
func TestRevokedAgent_UpsertDoesNotResurrectRemovedRow(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
id := newAgent(t, s, "node-2")
|
|
require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{slusk}, t0))
|
|
_, err := s.RevokeAgent(ctx, id, t0.Add(time.Hour))
|
|
require.NoError(t, err)
|
|
|
|
assert.ErrorIs(t, s.UpsertService(ctx, id, "", slusk, t0.Add(2*time.Hour)), model.ErrRevoked)
|
|
|
|
got, err := s.ListServices(ctx)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, got, "the revoked agent's removed row must stay removed")
|
|
}
|
|
|
|
func TestRevokeAgent_FailsActiveJobs(t *testing.T) {
|
|
s := openTestStore(t)
|
|
ctx := context.Background()
|
|
a, svcA := jobFixture(t, s, "a")
|
|
b, svcB := jobFixture(t, s, "b")
|
|
ja, err := s.CreateJob(ctx, newJob(svcA, a))
|
|
require.NoError(t, err)
|
|
jb, err := s.CreateJob(ctx, newJob(svcB, b))
|
|
require.NoError(t, err)
|
|
|
|
failed, err := s.RevokeAgent(ctx, a, t0)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []int64{ja.ID}, failed, "the failed job IDs are returned so the UI can signal them")
|
|
|
|
got, err := s.GetJob(ctx, ja.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.JobFailed, got.Status)
|
|
assert.Equal(t, "agent revoked", got.Error)
|
|
got, err = s.GetJob(ctx, jb.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.JobQueued, got.Status)
|
|
}
|