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:
@@ -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, "<b>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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user