diff --git a/internal/master/web/server.go b/internal/master/web/server.go index a6433ea..761d684 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -78,9 +78,9 @@ type Server struct { // New parses the templates. Each page is its own template set so every page // can define its own "content" block. func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) { - funcs := template.FuncMap{"shortRef": shortRef} + funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration} pages := map[string]*template.Template{} - for _, page := range []string{"overview.html", "agents.html", "agent_created.html"} { + for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} { t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page) if err != nil { return nil, fmt.Errorf("parse %s: %w", page, err) @@ -97,6 +97,13 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha route := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, protect(h)) } route("GET /{$}", s.overview) route("GET /fragments/services", s.servicesFragment) + route("GET /services/{id}", s.servicePage) + route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment) + route("POST /services/{id}/update", s.updateService) + route("GET /jobs", s.jobsPage) + route("GET /fragments/jobs", s.jobsFragment) + route("GET /jobs/{id}", s.jobPage) + route("GET /fragments/jobs/{id}", s.jobFragment) route("GET /agents", s.agents) route("GET /fragments/agents", s.agentsFragment) route("POST /agents", s.createAgent) @@ -112,6 +119,12 @@ type pageData struct { Error string Created model.Agent Snippet string + + Service model.Service + ConfigFiles []string + Reason string // why Service cannot be updated; "" when it can + Jobs []model.Job + Job model.Job } func (s *Server) base(r *http.Request, title string) pageData { @@ -155,6 +168,151 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) { s.render(w, http.StatusOK, "overview.html", "services", data) } +// pathID parses the {id} path value. On failure it has already answered 400. +func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return 0, false + } + return id, true +} + +// failLookup answers 404 for model.ErrNotFound and 500 otherwise. +func (s *Server) failLookup(w http.ResponseWriter, err error) { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + s.fail(w, err) +} + +func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) { + svc, err := s.store.GetService(r.Context(), id) + if err != nil { + return pageData{}, err + } + data := s.base(r, svc.DisplayName()) + data.Service = svc + if svc.ConfigFiles != "" { + data.ConfigFiles = strings.Split(svc.ConfigFiles, ",") + } + var nu *model.NotUpdatableError + if err := s.jobs.Updatable(svc); errors.As(err, &nu) { + data.Reason = nu.Reason + } else if err != nil { + return data, err + } + data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: 20}) + return data, err +} + +func (s *Server) servicePage(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + data, err := s.serviceDetail(r, id) + if err != nil { + s.failLookup(w, err) + return + } + s.render(w, http.StatusOK, "service.html", "layout", data) +} + +func (s *Server) serviceJobsFragment(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + jobs, err := s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: 20}) + if err != nil { + s.fail(w, err) + return + } + data := s.base(r, "Service") + data.Jobs = jobs + s.render(w, http.StatusOK, "service.html", "history", data) +} + +func (s *Server) updateService(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + sess, _ := webauth.SessionFrom(r.Context()) + job, err := s.jobs.Trigger(r.Context(), id, sess.Subject) + var nu *model.NotUpdatableError + switch { + case err == nil || errors.Is(err, model.ErrJobActive): + http.Redirect(w, r, "/jobs/"+strconv.FormatInt(job.ID, 10), http.StatusSeeOther) + case errors.As(err, &nu): + data, derr := s.serviceDetail(r, id) + if derr != nil { + s.failLookup(w, derr) + return + } + data.Error = "Cannot update: " + nu.Reason + "." + s.render(w, http.StatusConflict, "service.html", "layout", data) + default: + s.failLookup(w, err) + } +} + +func (s *Server) jobsData(r *http.Request) (pageData, error) { + data := s.base(r, "Jobs") + var err error + data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{Limit: 100}) + return data, err +} + +func (s *Server) jobsPage(w http.ResponseWriter, r *http.Request) { + data, err := s.jobsData(r) + if err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "jobs.html", "layout", data) +} + +func (s *Server) jobsFragment(w http.ResponseWriter, r *http.Request) { + data, err := s.jobsData(r) + if err != nil { + s.fail(w, err) + return + } + s.render(w, http.StatusOK, "jobs.html", "jobs", data) +} + +// jobData always reads the job from the store, so a fragment fetched after +// a dropped signal still shows the full current output. +func (s *Server) jobData(w http.ResponseWriter, r *http.Request) (pageData, bool) { + id, ok := pathID(w, r) + if !ok { + return pageData{}, false + } + job, err := s.store.GetJob(r.Context(), id) + if err != nil { + s.failLookup(w, err) + return pageData{}, false + } + data := s.base(r, fmt.Sprintf("Job #%d", job.ID)) + data.Job = job + return data, true +} + +func (s *Server) jobPage(w http.ResponseWriter, r *http.Request) { + if data, ok := s.jobData(w, r); ok { + s.render(w, http.StatusOK, "job.html", "layout", data) + } +} + +func (s *Server) jobFragment(w http.ResponseWriter, r *http.Request) { + if data, ok := s.jobData(w, r); ok { + s.render(w, http.StatusOK, "job.html", "job", data) + } +} + func (s *Server) agentData(r *http.Request) (pageData, error) { data := s.base(r, "Agents") agents, err := s.store.ListAgents(r.Context()) @@ -234,6 +392,7 @@ func (s *Server) revokeAgent(w http.ResponseWriter, r *http.Request) { } s.hub.Disconnect(id) s.broker.Publish(events.Services) // the agent's services were marked removed + s.broker.Publish(events.Jobs) // the agent's active jobs were failed http.Redirect(w, r, "/agents", http.StatusSeeOther) } @@ -293,7 +452,12 @@ func agentSnippet(cfg Config, token string) string { MASTER_URL: %s MASTER_KEY: %s AGENT_TOKEN: %s + UPDATE_TIMEOUT: 5m + # Optional registry credentials (a config.json written by docker login): + # DOCKER_CONFIG: /docker-config volumes: - /var/run/docker.sock:/var/run/docker.sock + # - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write + # - /root/.docker/config.json:/docker-config/config.json:ro `, cfg.AgentImage, cfg.AgentURL, cfg.MasterKey, token) } diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index d2f5494..9cb0c26 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -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, "boom", 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, "Job #{{.Job.ID}} +
+| Status | {{.Job.Status}} |
|---|---|
| From | {{shortRef .Job.FromRef}} |
| To | {{shortRef .Job.ToRef}} |
| Requested by | {{.Job.RequestedBy}} |
| Created | {{.Job.CreatedAt.Format "2006-01-02 15:04:05"}} |
| Duration | {{duration .Job}} |
{{.Job.Error}}
{{end}} +{{if .Job.OutputTruncated}}Earlier output truncated.
{{end}} +{{.Job.Output}}
+{{end}}
diff --git a/internal/master/web/templates/jobs.html b/internal/master/web/templates/jobs.html
new file mode 100644
index 0000000..a226ee5
--- /dev/null
+++ b/internal/master/web/templates/jobs.html
@@ -0,0 +1,27 @@
+{{define "content"}}
+| Job | Service | Status | From → to | Requested by | Created | Duration |
|---|---|---|---|---|---|---|
| #{{.ID}} | +{{.ServiceName}} | +{{.Status}} | +{{shortRef .FromRef}} → {{shortRef .ToRef}} |
+ {{.RequestedBy}} | +{{.CreatedAt.Format "2006-01-02 15:04"}} | +{{duration .}} | +
| No jobs yet. | ||||||