feat(web): service page, manual update, jobs list and live job log (#7)

Also publishes the jobs topic on agent revoke and updates the agent
snippet to mount stacks read-write at the same path.
This commit is contained in:
2026-10-12 18:41:46 +02:00
parent 9d3a6f808c
commit 574f425267
11 changed files with 526 additions and 23 deletions
+231 -19
View File
@@ -20,6 +20,7 @@ import (
"github.com/shcizo/package-updater/internal/agenttoken"
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/webauth"
"github.com/shcizo/package-updater/internal/protocol"
)
@@ -28,20 +29,42 @@ import (
type fakeStore struct {
mu sync.Mutex
services []model.Service
jobs []model.Job
agents []model.Agent
created map[string]string // name → token hash
revokedID int64
}
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
func (f *fakeStore) GetService(context.Context, int64) (model.Service, error) {
func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, error) {
for _, s := range f.services {
if s.ID == id {
return s, nil
}
}
return model.Service{}, model.ErrNotFound
}
func (f *fakeStore) GetJob(context.Context, int64) (model.Job, error) {
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
for _, j := range f.jobs {
if j.ID == id {
return j, nil
}
}
return model.Job{}, model.ErrNotFound
}
func (f *fakeStore) ListJobs(context.Context, model.JobFilter) ([]model.Job, error) { return nil, nil }
func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil }
func (f *fakeStore) ListJobs(_ context.Context, flt model.JobFilter) ([]model.Job, error) {
var out []model.Job
for _, j := range f.jobs {
if flt.ServiceID == 0 || j.ServiceID == flt.ServiceID {
out = append(out, j)
}
}
return out, nil
}
func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil }
func (f *fakeStore) CreateAgent(_ context.Context, name, hash string) (model.Agent, error) {
f.mu.Lock()
@@ -72,11 +95,29 @@ func (f *fakeStore) revoked() int64 {
return f.revokedID
}
type fakeJobs struct{}
// fakeJobs returns scripted results and records who asked for the update.
type fakeJobs struct {
updatable error
job model.Job
err error
func (fakeJobs) Updatable(model.Service) error { return nil }
func (fakeJobs) Trigger(context.Context, int64, string) (model.Job, error) {
return model.Job{}, model.ErrNotFound
mu sync.Mutex
requestedBy string
}
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.requestedBy = by
return f.job, f.err
}
func (f *fakeJobs) triggeredBy() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.requestedBy
}
type fakeHub struct {
@@ -104,16 +145,22 @@ func (h *fakeHub) lastDisconnected() int64 {
}
func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker) {
t.Helper()
srv, store, hub, broker, _ := newTestEnv(t)
return srv, store, hub, broker
}
func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
agentID := int64(2)
store := &fakeStore{
created: map[string]string{"node-2": "x"},
services: []model.Service{
{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2",
ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"},
{Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
{ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin",
ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"},
{Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
{ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2",
ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true},
},
agents: []model.Agent{
@@ -124,15 +171,21 @@ func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *event
}
hub := &fakeHub{online: map[int64]bool{2: true}}
broker := events.NewBroker()
s, err := New(store, hub, fakeJobs{}, broker, Config{
jobs := &fakeJobs{}
s, err := New(store, hub, jobs, broker, Config{
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
mux := http.NewServeMux()
s.Register(mux, func(h http.Handler) http.Handler { return h })
s.Register(mux, func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := webauth.WithSession(r.Context(), model.Session{Subject: "alice", CSRFToken: "csrf"})
h.ServeHTTP(w, r.WithContext(ctx))
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, store, hub, broker
return srv, store, hub, broker, jobs
}
func ptr[T any](v T) *T { return &v }
@@ -233,12 +286,17 @@ func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
assert.Equal(t, "/agents", resp.Header.Get("Location"))
assert.Equal(t, int64(2), store.revoked())
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
select {
case topic := <-ch:
assert.Equal(t, events.Services, topic, "the overview must drop the revoked agent's services")
default:
t.Fatal("revoke published no services event")
var got []events.Topic
for len(got) < 2 {
select {
case topic := <-ch:
got = append(got, topic)
default:
t.Fatalf("revoke published %v, want services and jobs", got)
}
}
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs}, got,
"the overview drops the agent's services and job lists show its failed jobs")
}
func TestRevokeAgent_BadID(t *testing.T) {
@@ -277,3 +335,157 @@ func TestEvents_StreamsTopics(t *testing.T) {
}
assert.Equal(t, "event: services\n", line)
}
func TestAgentSnippet(t *testing.T) {
snippet := agentSnippet(Config{AgentURL: "wss://m/agent", MasterKey: "sha256:abc", AgentImage: "img:1"}, "tok")
assert.NotContains(t, snippet, "STACKS_ROOT")
assert.Contains(t, snippet, "UPDATE_TIMEOUT: 5m")
assert.Contains(t, snippet, " # DOCKER_CONFIG: /docker-config\n")
assert.Contains(t, snippet, " # - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write\n")
assert.Contains(t, snippet, "AGENT_TOKEN: tok")
}
func TestOverview_LinksToServicePage(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/")
assert.Contains(t, body, `href="/services/1"`)
assert.Contains(t, body, `href="/jobs"`)
}
func TestServicePage_ShowsUpdateButton(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ConfigFiles = "/srv/slusk/compose.yaml,/srv/slusk/compose.override.yaml"
store.jobs = []model.Job{{ID: 7, ServiceID: 1, Status: model.JobSucceeded, FromRef: "a:1", ToRef: "a:1", RequestedBy: "alice", CreatedAt: time.Now()}}
code, body := get(t, srv, "/services/1")
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `action="/services/1/update"`)
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
assert.Contains(t, body, "/srv/slusk/compose.override.yaml")
assert.Contains(t, body, `href="/jobs/7"`)
assert.Contains(t, body, "sse:jobs")
}
func TestServicePage_DisabledReason(t *testing.T) {
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved} {
t.Run(reason, func(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.updatable = &model.NotUpdatableError{Reason: reason}
code, body := get(t, srv, "/services/1")
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, reason)
assert.NotContains(t, body, `action="/services/1/update"`)
})
}
}
func TestServicePage_NotFoundAndBadID(t *testing.T) {
srv, _, _, _ := newTestServer(t)
code, _ := get(t, srv, "/services/99")
assert.Equal(t, http.StatusNotFound, code)
code, _ = get(t, srv, "/services/abc")
assert.Equal(t, http.StatusBadRequest, code)
}
func postUpdate(t *testing.T, srv *httptest.Server) (*http.Response, string) {
t.Helper()
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp, string(body)
}
func TestUpdate_RedirectsToJob(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job = model.Job{ID: 42}
resp, _ := postUpdate(t, srv)
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
assert.Equal(t, "alice", jobs.triggeredBy())
}
func TestUpdate_ActiveJobRedirectsToExisting(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job, jobs.err = model.Job{ID: 41}, model.ErrJobActive
resp, _ := postUpdate(t, srv)
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/jobs/41", resp.Header.Get("Location"))
}
func TestUpdate_NotUpdatableShows409(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.err = &model.NotUpdatableError{Reason: model.ReasonNoTag}
jobs.updatable = jobs.err
resp, body := postUpdate(t, srv)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
assert.Contains(t, body, model.ReasonNoTag)
}
func TestUpdate_UnknownService404(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.err = model.ErrNotFound
resp, _ := postUpdate(t, srv)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestServiceJobsFragment_IsNotAFullPage(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.jobs = []model.Job{{ID: 7, ServiceID: 1, Status: model.JobFailed, CreatedAt: time.Now()}}
code, body := get(t, srv, "/fragments/services/1/jobs")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "<html")
assert.Contains(t, body, "/jobs/7")
}
func TestJobsPage_ListsJobs(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
started, finished := time.Now(), time.Now().Add(42*time.Second)
store.jobs = []model.Job{
{ID: 7, ServiceID: 1, ServiceName: "slusk/web", Status: model.JobSucceeded, FromRef: "a:1", ToRef: "a:1@sha256:abc", RequestedBy: "alice", CreatedAt: started, StartedAt: &started, FinishedAt: &finished},
{ID: 8, ServiceID: 2, ServiceName: "jellyfin_jellyfin", Status: model.JobFailed, CreatedAt: started},
}
for _, path := range []string{"/jobs", "/fragments/jobs"} {
code, body := get(t, srv, path)
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `href="/jobs/7"`)
assert.Contains(t, body, `href="/services/2"`)
assert.Contains(t, body, "succeeded")
assert.Contains(t, body, "failed")
assert.Contains(t, body, "42s")
assert.Equal(t, path == "/jobs", strings.Contains(body, "<html"))
}
}
func TestJobPage_ShowsOutputAndError(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.jobs = []model.Job{{ID: 42, ServiceID: 1, ServiceName: "slusk/web", Status: model.JobFailed,
Output: "Pulling web\n<b>boom</b>", Error: "pull failed", OutputTruncated: true, CreatedAt: time.Now()}}
code, body := get(t, srv, "/jobs/42")
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, body, "Pulling web")
assert.Contains(t, body, "&lt;b&gt;boom", "output is escaped")
assert.Contains(t, body, "pull failed")
assert.Contains(t, body, "Earlier output truncated")
assert.Contains(t, body, `hx-trigger="sse:job-42"`)
}
func TestJobFragment_RereadsFromStore(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.jobs = []model.Job{{ID: 42, ServiceID: 1, Status: model.JobRunning, Output: "step 1", CreatedAt: time.Now()}}
code, body := get(t, srv, "/fragments/jobs/42")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "<html")
assert.Contains(t, body, "step 1")
store.jobs[0].Output = "step 1\nstep 2"
_, body = get(t, srv, "/fragments/jobs/42")
assert.Contains(t, body, "step 2")
}
func TestJob_NotFound(t *testing.T) {
srv, _, _, _ := newTestServer(t)
for _, path := range []string{"/jobs/99", "/fragments/jobs/99"} {
code, _ := get(t, srv, path)
assert.Equal(t, http.StatusNotFound, code)
}
}