feat(master): check the registry now from the service page (#35)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user