feat(master): check the registry now from the service page (#35)

This commit is contained in:
2026-10-14 14:20:29 +02:00
parent 2989fa224d
commit e7c0e2fd61
11 changed files with 356 additions and 18 deletions
+1 -1
View File
@@ -281,7 +281,7 @@ func TestServiceDetail_DriftAndMoveData(t *testing.T) {
store := &fakeStore{services: []model.Service{live, gone, swarm, container, self},
moves: []model.Move{move}, sources: sources}
jobs := &fakeJobs{restorable: tt.restorable}
s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), &fakePoller{}, Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil)
+86
View File
@@ -0,0 +1,86 @@
package web
import (
"context"
"io"
"net/http"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/poller"
)
// fakePoller records the services it was asked to check and answers err.
type fakePoller struct {
mu sync.Mutex
err error
calls []int64
}
func (f *fakePoller) PollService(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, id)
return f.err
}
func (f *fakePoller) called() []int64 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]int64(nil), f.calls...)
}
func TestPollService(t *testing.T) {
tests := []struct {
name string
id string
err error
wantStatus int
wantLocation string
wantBody string
wantCalls []int64
}{
{name: "redirects to the service page", id: "1", wantStatus: http.StatusSeeOther, wantLocation: "/services/1", wantCalls: []int64{1}},
{name: "unknown service is 404", id: "1", err: model.ErrNotFound, wantStatus: http.StatusNotFound, wantCalls: []int64{1}},
{name: "invalid id is 400", id: "abc", wantStatus: http.StatusBadRequest},
{name: "not polled is 409 with the reason", id: "1", err: &poller.NotPolledError{Reason: model.ReasonNoTag},
wantStatus: http.StatusConflict, wantBody: "Cannot check: follows no tag.", wantCalls: []int64{1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fp := &fakePoller{err: tt.err}
srv, _, _, _, _ := newTestEnvFull(t, func(*Config) {}, fp)
resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+tt.id+"/poll", url.Values{"csrf_token": {"csrf"}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, tt.wantStatus, resp.StatusCode)
assert.Equal(t, tt.wantLocation, resp.Header.Get("Location"))
assert.Contains(t, string(body), tt.wantBody)
assert.Equal(t, tt.wantCalls, fp.called())
})
}
}
func TestServicePage_CheckNowForm(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ActualDigest = "sha256:" + "a"
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, `action="/services/1/poll"`)
assert.Contains(t, body, `<button type="submit">Check now</button>`)
assert.NotContains(t, body, "Check unavailable")
}
func TestServicePage_CheckNowDisabledWhenNotPolled(t *testing.T) {
srv, _, _, _, _ := newTestEnv(t)
_, body := get(t, srv, "/services/2") // follows no tag
assert.Contains(t, body, `<button type="submit" disabled>Check now</button>`)
assert.Contains(t, body, "Check unavailable: follows no tag.")
}
+40 -2
View File
@@ -18,6 +18,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/poller"
"github.com/shcizo/package-updater/internal/master/web/icons"
"github.com/shcizo/package-updater/internal/master/webauth"
"github.com/shcizo/package-updater/internal/protocol"
@@ -85,6 +86,13 @@ type Jobs interface {
RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
}
// Poller checks the registry for one service. *poller.Poller satisfies it.
type Poller interface {
// PollService returns *poller.NotPolledError when the service is not
// polled and model.ErrNotFound for an unknown or removed one.
PollService(ctx context.Context, id int64) error
}
// Broker delivers change signals to open streams and accepts signals from
// UI actions. *events.Broker satisfies it.
type Broker interface {
@@ -108,6 +116,7 @@ type Server struct {
hub Hub
jobs Jobs
broker Broker
poller Poller
cfg Config
log *slog.Logger
now func() time.Time
@@ -116,7 +125,7 @@ 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) {
func New(store Store, hub Hub, jobs Jobs, broker Broker, poller Poller, cfg Config, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
@@ -126,7 +135,7 @@ func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.L
}
pages[page] = t
}
return &Server{store: store, hub: hub, jobs: jobs, broker: broker, cfg: cfg, log: log, now: time.Now, pages: pages}, nil
return &Server{store: store, hub: hub, jobs: jobs, broker: broker, poller: poller, cfg: cfg, log: log, now: time.Now, pages: pages}, nil
}
// Register adds the UI routes to mux. protect wraps every route except
@@ -140,6 +149,7 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
route("POST /services/{id}/update", s.updateService)
route("POST /services/{id}/mode", s.setMode)
route("POST /services/{id}/poll", s.pollService)
route("POST /services/{id}/restore", s.restoreDesired)
route("POST /services/{id}/accept", s.acceptRunning)
route("POST /services/{id}/merge", s.mergeService)
@@ -204,6 +214,7 @@ type pageData struct {
ConfigFiles []string
Reason string // why Service cannot be updated; "" when it can
AutoBlocked string // why Service cannot be set to auto; "" when it can
PollBlocked string // why Service cannot be checked against the registry; "" when it can
Versions []versionRow
NewerTag string // Service's newer tag while it still holds; "" otherwise
Polling string // what the service page says about registry polling
@@ -380,6 +391,7 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err
}
data.AutoBlocked = s.autoBlocked(svc)
data.PollBlocked = pollBlocked(svc)
if err := s.jobs.Restorable(svc); errors.As(err, &nu) {
data.RestoreBlocked = nu.Reason
} else if err != nil {
@@ -605,6 +617,32 @@ func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id i
s.page(w, r, status, "service.html", data)
}
// pollService checks the registry for one service now and waits for the
// answer. A registry error is not a failure of the request: it shows on the
// service page as the last check failing. The poller publishes the change
// itself when it writes.
func (s *Server) pollService(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
err := s.poller.PollService(r.Context(), id)
var np *poller.NotPolledError
switch {
case errors.As(err, &np):
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot check: "+np.Reason+".")
return
case r.Context().Err() != nil:
return // the client is gone
case err != nil:
s.failLookup(w, err)
return
}
sess, _ := webauth.SessionFrom(r.Context())
s.log.Info("poll_requested", "service_id", id, "subject", sess.Subject)
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// setMode switches a service between manual and auto. Manual is always
// allowed; auto only when jobs.AutoAllowed says so.
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
+7 -1
View File
@@ -232,6 +232,12 @@ func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeSto
// newTestEnvConfig is newTestEnv with edit applied to the UI's Config.
func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
return newTestEnvFull(t, edit, &fakePoller{})
}
// newTestEnvFull is newTestEnvConfig with p as the poller.
func newTestEnvFull(t *testing.T, edit func(*Config), p Poller) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
agentID, offlineAgentID := int64(2), int64(3)
store := &fakeStore{
@@ -260,7 +266,7 @@ func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fake
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
}
edit(&cfg)
s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
s, err := New(store, hub, jobs, broker, p, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
mux := http.NewServeMux()
s.Register(mux, func(h http.Handler) http.Handler {
@@ -23,6 +23,11 @@
</form>
<p class="muted">Pulls the current tag again and redeploys the service.</p>
{{end}}
<form method="post" action="/services/{{.Service.ID}}/poll">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit"{{if .PollBlocked}} disabled{{end}}>Check now</button>
<p class="muted">{{if .PollBlocked}}Check unavailable: {{.PollBlocked}}.{{else}}Asks the registry now instead of waiting for the next poll.{{end}}</p>
</form>
<form method="post" action="/services/{{.Service.ID}}/mode">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
{{if eq .Service.UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
+9
View File
@@ -281,6 +281,15 @@ func versionRows(versions []model.ImageVersion, actualDigest string) []versionRo
return rows
}
// pollBlocked returns why the registry cannot be checked for svc, or "" when
// it can.
func pollBlocked(svc model.Service) string {
if svc.RemovedAt != nil {
return model.ReasonRemoved
}
return poller.SkipReason(svc)
}
// pollingStatus is the text of the "Polling" row on the service page and
// whether it is a registry error.
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {