Compare commits
2
Commits
v0.9.1
...
75bfa72145
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75bfa72145 | ||
|
|
e7c0e2fd61 |
@@ -38,7 +38,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
|
||||
- `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello
|
||||
- `internal/master/registry` — HEAD-only digest lookup and tag listing against a registry (go-containerregistry, `DOCKER_CONFIG` credentials)
|
||||
- `internal/master/tagver` — which tag is a newer version of a followed tag (pure functions)
|
||||
- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion
|
||||
- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion; `PollService` does the same for one service (UI Check now)
|
||||
- `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent
|
||||
- `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them
|
||||
- `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons`
|
||||
|
||||
@@ -156,6 +156,9 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s
|
||||
- **What is not polled.** Services that follow no tag (`repo@digest`), `build:` services, and
|
||||
services without a registry digest (locally built images). The service page says which.
|
||||
Plain containers are polled, so their badge works, but jobs for them are refused.
|
||||
- **Check now.** The service page's *Check now* button runs the same check immediately for that
|
||||
service only (digest, auto job, newer tag); other services that follow the same tag are
|
||||
updated at the next cycle.
|
||||
- **Errors.** A failed lookup is stored for the services that follow that tag and shown as a
|
||||
"poll failed" badge on the overview and in the "Polling" row of the service page; other
|
||||
tags continue. A later success clears it.
|
||||
|
||||
+11
-10
@@ -115,7 +115,17 @@ func run() error {
|
||||
}
|
||||
auth := webauth.New(provider, st, cfg.AllowedGroup, strings.HasPrefix(cfg.PublicURL, "https://"), logger)
|
||||
|
||||
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
|
||||
credDir, credFound, credReadErr := registryCredentialsDir()
|
||||
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
|
||||
"config_json_readable", credFound && credReadErr == nil)
|
||||
if credReadErr != nil {
|
||||
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
|
||||
}
|
||||
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
|
||||
Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers,
|
||||
}, logger)
|
||||
|
||||
ui, err := web.New(st, agentHub, jobService, broker, registryPoller, web.Config{
|
||||
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
||||
PollInterval: cfg.PollInterval, Version: version,
|
||||
}, logger)
|
||||
@@ -164,17 +174,8 @@ func run() error {
|
||||
BaseContext: baseContext,
|
||||
}
|
||||
|
||||
credDir, credFound, credReadErr := registryCredentialsDir()
|
||||
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
|
||||
"config_json_readable", credFound && credReadErr == nil)
|
||||
if credReadErr != nil {
|
||||
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
|
||||
}
|
||||
pollCtx, stopPolling := context.WithCancel(ctx)
|
||||
defer stopPolling()
|
||||
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
|
||||
Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers,
|
||||
}, logger)
|
||||
pollDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pollDone)
|
||||
|
||||
@@ -139,6 +139,17 @@ func (p *Poller) Cycle(ctx context.Context) {
|
||||
p.log.Error("poll_list_failed", "error", err)
|
||||
return
|
||||
}
|
||||
stats := p.pollServices(ctx, svcs)
|
||||
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
|
||||
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
|
||||
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
|
||||
"duration_ms", p.now().Sub(began).Milliseconds())
|
||||
}
|
||||
|
||||
// pollServices does the work of a cycle for svcs: it looks up every distinct
|
||||
// followed ref once, records the results and starts auto jobs, then syncs the
|
||||
// newer-tag suggestions of svcs.
|
||||
func (p *Poller) pollServices(ctx context.Context, svcs []model.Service) cycleStats {
|
||||
var stats cycleStats
|
||||
byRef := map[string][]model.Service{}
|
||||
var refs []string
|
||||
@@ -170,10 +181,41 @@ func (p *Poller) Cycle(ctx context.Context) {
|
||||
p.publishIfWrote(&stats)
|
||||
p.syncNewer(ctx, write, svcs, &stats)
|
||||
p.publishIfWrote(&stats)
|
||||
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
|
||||
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
|
||||
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
|
||||
return stats
|
||||
}
|
||||
|
||||
// NotPolledError says a service cannot be checked against the registry.
|
||||
type NotPolledError struct{ Reason string }
|
||||
|
||||
func (e *NotPolledError) Error() string { return "not polled: " + e.Reason }
|
||||
|
||||
// PollService does what Cycle does, for the service id only: it looks up the
|
||||
// followed ref, records the digest, starts an auto job if one is wanted and
|
||||
// syncs the newer-tag suggestion. Other services that follow the same ref are
|
||||
// updated at the next cycle. It is safe to run while Run is running: the
|
||||
// unique index update_jobs_one_active prevents duplicate jobs. A registry
|
||||
// error is not returned; it is stored as last_poll_error, like in a cycle. It
|
||||
// returns the store's error when the service cannot be read, model.ErrNotFound
|
||||
// for a removed service, *NotPolledError when the service is not polled, and
|
||||
// ctx.Err() when ctx ended.
|
||||
func (p *Poller) PollService(ctx context.Context, id int64) error {
|
||||
began := p.now()
|
||||
svc, err := p.store.GetService(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if svc.RemovedAt != nil {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
if r := SkipReason(svc); r != "" {
|
||||
return &NotPolledError{Reason: r}
|
||||
}
|
||||
stats := p.pollServices(ctx, []model.Service{svc})
|
||||
p.log.Info("poll_service", "service_id", id, "ref", jobs.FollowedRef(svc.ActualRef),
|
||||
"ok", stats.ok, "failed", stats.failed, "suggested", stats.suggested, "jobs", stats.jobs,
|
||||
"tags_failed", stats.tagsFailed, "newer", stats.newer,
|
||||
"duration_ms", p.now().Sub(began).Milliseconds())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// publishIfWrote tells the browsers to refresh if a phase of the cycle wrote
|
||||
|
||||
@@ -938,3 +938,160 @@ func TestDigestWritesArePublishedBeforeTagsAreListed(t *testing.T) {
|
||||
close(reg.tagRelease)
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestPollService(t *testing.T) {
|
||||
ref := repoA + ":latest" // not version-like: no tag listing
|
||||
newDigest := map[string]string{ref: "sha256:new"}
|
||||
auto := func(edit func(*model.Service)) model.Service {
|
||||
s := svc(1, ref)
|
||||
s.UpdateMode = model.ModeAuto
|
||||
if edit != nil {
|
||||
edit(&s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
svc model.Service
|
||||
reg *fakeRegistry
|
||||
startErr error
|
||||
wantErr error // matched with ErrorIs
|
||||
wantNotPoll string
|
||||
wantHeads []string
|
||||
wantTags []string
|
||||
check func(t *testing.T, st *fakeStore, j *fakeJobs)
|
||||
}{
|
||||
{name: "manual with a new digest is only marked",
|
||||
svc: svc(1, ref), reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
|
||||
require.Len(t, st.available, 1)
|
||||
assert.Equal(t, model.SourcePoll, st.available[0].a.Source)
|
||||
assert.Empty(t, j.starts)
|
||||
}},
|
||||
{name: "auto with a new digest starts a poll job",
|
||||
svc: auto(nil), reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
|
||||
assert.Equal(t, []startCall{{1, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref}}}, j.starts)
|
||||
assert.Equal(t, []lastTagCall{{1, "sha256:new"}}, st.lastTag)
|
||||
}},
|
||||
{name: "auto that cannot be updated is marked but last_tag_digest stays",
|
||||
svc: auto(nil), reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
|
||||
startErr: &model.NotUpdatableError{Reason: model.ReasonDrifted},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
|
||||
assert.Len(t, st.available, 1)
|
||||
assert.Len(t, j.starts, 1)
|
||||
assert.Empty(t, st.lastTag)
|
||||
}},
|
||||
{name: "auto that already got the digest is not started",
|
||||
svc: auto(func(s *model.Service) { s.LastTagDigest = "sha256:new" }),
|
||||
reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) { assert.Empty(t, j.starts) }},
|
||||
{name: "auto whose desired_ref names the digest is not started",
|
||||
svc: auto(func(s *model.Service) { s.DesiredRef = ref + "@sha256:new" }),
|
||||
reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) { assert.Empty(t, j.starts) }},
|
||||
{name: "registry error is stored, not returned",
|
||||
svc: svc(1, ref), reg: &fakeRegistry{errs: map[string]error{ref: errors.New("head: boom")}}, wantHeads: []string{ref},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
|
||||
assert.Equal(t, []pollErrorCall{{[]int64{1}, "head: boom"}}, st.pollErrors)
|
||||
}},
|
||||
{name: "a previous poll error is cleared on success",
|
||||
svc: auto(func(s *model.Service) {
|
||||
s.UpdateMode, s.ActualDigest, s.LastPollError = model.ModeManual, "sha256:new", "head: boom"
|
||||
}),
|
||||
reg: &fakeRegistry{digests: newDigest}, wantHeads: []string{ref},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
|
||||
assert.Equal(t, []pollErrorCall{{[]int64{1}, ""}}, st.pollErrors)
|
||||
}},
|
||||
{name: "no tag is not polled",
|
||||
svc: svc(1, repoA+"@sha256:old"), reg: &fakeRegistry{}, wantNotPoll: model.ReasonNoTag},
|
||||
{name: "built is not polled",
|
||||
svc: auto(func(s *model.Service) { s.Built = true }), reg: &fakeRegistry{}, wantNotPoll: "built from source"},
|
||||
{name: "no digest is not polled",
|
||||
svc: auto(func(s *model.Service) { s.ActualDigest = "" }), reg: &fakeRegistry{}, wantNotPoll: "no registry digest"},
|
||||
{name: "removed is not found",
|
||||
svc: auto(func(s *model.Service) { s.RemovedAt = ptr(time.Now()) }), reg: &fakeRegistry{}, wantErr: model.ErrNotFound},
|
||||
{name: "a comparable tag lists the repo once and suggests the newer tag",
|
||||
svc: newerSvc(1, repoB, "1.27.3"),
|
||||
reg: newerRegistry(map[string][]string{repoB: {"1.27.4", "1.28.0"}}, repoB+":1.27.3"),
|
||||
wantHeads: []string{repoB + ":1.27.3"}, wantTags: []string{repoB},
|
||||
check: func(t *testing.T, st *fakeStore, j *fakeJobs) {
|
||||
require.Len(t, st.newer, 1)
|
||||
assert.Equal(t, newerCall{1, repoB + ":1.28.0", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}, st.newer[0])
|
||||
}},
|
||||
{name: "a tag that cannot have a newer tag lists nothing",
|
||||
svc: newerSvc(1, repoB, "latest"), reg: newerRegistry(nil, repoB+":latest"), wantHeads: []string{repoB + ":latest"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
st := &fakeStore{services: []model.Service{tt.svc}}
|
||||
j := &fakeJobs{errFor: map[int64]error{1: tt.startErr}}
|
||||
p, _ := newPoller(st, j, tt.reg, Config{})
|
||||
|
||||
err := p.PollService(context.Background(), 1)
|
||||
|
||||
var np *NotPolledError
|
||||
switch {
|
||||
case tt.wantNotPoll != "":
|
||||
require.ErrorAs(t, err, &np)
|
||||
assert.Equal(t, tt.wantNotPoll, np.Reason)
|
||||
case tt.wantErr != nil:
|
||||
assert.ErrorIs(t, err, tt.wantErr)
|
||||
default:
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantHeads, tt.reg.sortedCalls())
|
||||
assert.Equal(t, tt.wantTags, tt.reg.tagCalls)
|
||||
if tt.check != nil {
|
||||
tt.check(t, st, j)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollServiceUnknownID(t *testing.T) {
|
||||
reg := &fakeRegistry{}
|
||||
p, _ := newPoller(&fakeStore{}, &fakeJobs{}, reg, Config{})
|
||||
|
||||
err := p.PollService(context.Background(), 9)
|
||||
|
||||
assert.ErrorIs(t, err, model.ErrNotFound)
|
||||
assert.Empty(t, reg.calls)
|
||||
}
|
||||
|
||||
func TestPollServiceOnlyThisService(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
st := &fakeStore{services: []model.Service{svc(1, ref), svc(2, ref)}}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
require.NoError(t, p.PollService(context.Background(), 1))
|
||||
|
||||
require.Len(t, st.available, 1)
|
||||
assert.Equal(t, int64(1), st.available[0].id)
|
||||
assert.Equal(t, []string{ref}, reg.sortedCalls())
|
||||
for _, c := range st.pollErrors {
|
||||
assert.NotContains(t, c.ids, int64(2))
|
||||
}
|
||||
for _, c := range st.newer {
|
||||
assert.Equal(t, int64(1), c.id)
|
||||
}
|
||||
for _, c := range st.lastTag {
|
||||
assert.Equal(t, int64(1), c.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollServiceCancelled(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
st := &fakeStore{services: []model.Service{svc(1, ref)}}
|
||||
reg := &fakeRegistry{release: make(chan struct{})} // lookups only end by cancel
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := p.PollService(ctx, 1)
|
||||
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
assert.Empty(t, st.pollErrors)
|
||||
assert.Empty(t, st.available)
|
||||
}
|
||||
|
||||
@@ -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,34 @@ 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:
|
||||
// The client is gone or the master is shutting down. Redirect anyway:
|
||||
// a browser still waiting gets the page, not an empty 200.
|
||||
case err != nil:
|
||||
s.failLookup(w, err)
|
||||
return
|
||||
default:
|
||||
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