Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7671221343 | ||
|
|
2989fa224d |
@@ -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/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/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/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/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/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`
|
- `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
|
- **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.
|
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.
|
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
|
- **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
|
"poll failed" badge on the overview and in the "Polling" row of the service page; other
|
||||||
tags continue. A later success clears it.
|
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)
|
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,
|
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
|
||||||
PollInterval: cfg.PollInterval, Version: version,
|
PollInterval: cfg.PollInterval, Version: version,
|
||||||
}, logger)
|
}, logger)
|
||||||
@@ -164,17 +174,8 @@ func run() error {
|
|||||||
BaseContext: baseContext,
|
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)
|
pollCtx, stopPolling := context.WithCancel(ctx)
|
||||||
defer stopPolling()
|
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{})
|
pollDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(pollDone)
|
defer close(pollDone)
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ func (p *Poller) Cycle(ctx context.Context) {
|
|||||||
p.log.Error("poll_list_failed", "error", err)
|
p.log.Error("poll_list_failed", "error", err)
|
||||||
return
|
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
|
var stats cycleStats
|
||||||
byRef := map[string][]model.Service{}
|
byRef := map[string][]model.Service{}
|
||||||
var refs []string
|
var refs []string
|
||||||
@@ -170,10 +181,41 @@ func (p *Poller) Cycle(ctx context.Context) {
|
|||||||
p.publishIfWrote(&stats)
|
p.publishIfWrote(&stats)
|
||||||
p.syncNewer(ctx, write, svcs, &stats)
|
p.syncNewer(ctx, write, svcs, &stats)
|
||||||
p.publishIfWrote(&stats)
|
p.publishIfWrote(&stats)
|
||||||
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
|
return stats
|
||||||
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
|
}
|
||||||
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
|
|
||||||
|
// 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())
|
"duration_ms", p.now().Sub(began).Milliseconds())
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishIfWrote tells the browsers to refresh if a phase of the cycle wrote
|
// 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)
|
close(reg.tagRelease)
|
||||||
<-done
|
<-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},
|
store := &fakeStore{services: []model.Service{live, gone, swarm, container, self},
|
||||||
moves: []model.Move{move}, sources: sources}
|
moves: []model.Move{move}, sources: sources}
|
||||||
jobs := &fakeJobs{restorable: tt.restorable}
|
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)
|
require.NoError(t, err)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil)
|
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/agenttoken"
|
||||||
"github.com/shcizo/package-updater/internal/master/events"
|
"github.com/shcizo/package-updater/internal/master/events"
|
||||||
"github.com/shcizo/package-updater/internal/master/model"
|
"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/web/icons"
|
||||||
"github.com/shcizo/package-updater/internal/master/webauth"
|
"github.com/shcizo/package-updater/internal/master/webauth"
|
||||||
"github.com/shcizo/package-updater/internal/protocol"
|
"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)
|
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
|
// Broker delivers change signals to open streams and accepts signals from
|
||||||
// UI actions. *events.Broker satisfies it.
|
// UI actions. *events.Broker satisfies it.
|
||||||
type Broker interface {
|
type Broker interface {
|
||||||
@@ -108,6 +116,7 @@ type Server struct {
|
|||||||
hub Hub
|
hub Hub
|
||||||
jobs Jobs
|
jobs Jobs
|
||||||
broker Broker
|
broker Broker
|
||||||
|
poller Poller
|
||||||
cfg Config
|
cfg Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
now func() time.Time
|
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
|
// New parses the templates. Each page is its own template set so every page
|
||||||
// can define its own "content" block.
|
// 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}
|
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle}
|
||||||
pages := map[string]*template.Template{}
|
pages := map[string]*template.Template{}
|
||||||
for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} {
|
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
|
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
|
// 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("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
|
||||||
route("POST /services/{id}/update", s.updateService)
|
route("POST /services/{id}/update", s.updateService)
|
||||||
route("POST /services/{id}/mode", s.setMode)
|
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}/restore", s.restoreDesired)
|
||||||
route("POST /services/{id}/accept", s.acceptRunning)
|
route("POST /services/{id}/accept", s.acceptRunning)
|
||||||
route("POST /services/{id}/merge", s.mergeService)
|
route("POST /services/{id}/merge", s.mergeService)
|
||||||
@@ -204,6 +214,7 @@ type pageData struct {
|
|||||||
ConfigFiles []string
|
ConfigFiles []string
|
||||||
Reason string // why Service cannot be updated; "" when it can
|
Reason string // why Service cannot be updated; "" when it can
|
||||||
AutoBlocked string // why Service cannot be set to auto; "" 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
|
Versions []versionRow
|
||||||
NewerTag string // Service's newer tag while it still holds; "" otherwise
|
NewerTag string // Service's newer tag while it still holds; "" otherwise
|
||||||
Polling string // what the service page says about registry polling
|
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
|
return data, err
|
||||||
}
|
}
|
||||||
data.AutoBlocked = s.autoBlocked(svc)
|
data.AutoBlocked = s.autoBlocked(svc)
|
||||||
|
data.PollBlocked = pollBlocked(svc)
|
||||||
if err := s.jobs.Restorable(svc); errors.As(err, &nu) {
|
if err := s.jobs.Restorable(svc); errors.As(err, &nu) {
|
||||||
data.RestoreBlocked = nu.Reason
|
data.RestoreBlocked = nu.Reason
|
||||||
} else if err != nil {
|
} 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)
|
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
|
// setMode switches a service between manual and auto. Manual is always
|
||||||
// allowed; auto only when jobs.AutoAllowed says so.
|
// allowed; auto only when jobs.AutoAllowed says so.
|
||||||
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -628,6 +668,10 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var nu *model.NotUpdatableError
|
var nu *model.NotUpdatableError
|
||||||
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
||||||
|
if isHTMX(r) {
|
||||||
|
// htmx does not swap a 409; the service page shows the reason.
|
||||||
|
w.Header().Set("HX-Redirect", "/services/"+strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot enable auto: "+nu.Reason+".")
|
s.renderServiceError(w, r, id, http.StatusConflict, "Cannot enable auto: "+nu.Reason+".")
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -641,6 +685,11 @@ func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.broker.Publish(events.Services)
|
s.broker.Publish(events.Services)
|
||||||
if r.PostFormValue("return") == "overview" {
|
if r.PostFormValue("return") == "overview" {
|
||||||
|
if isHTMX(r) {
|
||||||
|
// Swap the list in place instead of redirecting, so the page keeps its scroll position.
|
||||||
|
s.servicesFragment(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -881,6 +930,11 @@ func (s *Server) fail(w http.ResponseWriter, err error) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isHTMX reports whether r was sent by htmx rather than by a plain form submit.
|
||||||
|
func isHTMX(r *http.Request) bool {
|
||||||
|
return r.Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
|
|
||||||
// agentSnippet is the compose file shown once when an agent is added.
|
// agentSnippet is the compose file shown once when an agent is added.
|
||||||
func agentSnippet(cfg Config, token string) string {
|
func agentSnippet(cfg Config, token string) string {
|
||||||
return fmt.Sprintf(`services:
|
return fmt.Sprintf(`services:
|
||||||
|
|||||||
@@ -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.
|
// 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) {
|
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()
|
t.Helper()
|
||||||
agentID, offlineAgentID := int64(2), int64(3)
|
agentID, offlineAgentID := int64(2), int64(3)
|
||||||
store := &fakeStore{
|
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",
|
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
|
||||||
}
|
}
|
||||||
edit(&cfg)
|
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)
|
require.NoError(t, err)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.Register(mux, func(h http.Handler) http.Handler {
|
s.Register(mux, func(h http.Handler) http.Handler {
|
||||||
@@ -707,6 +713,47 @@ func TestSetMode_ReturnToOverview(t *testing.T) {
|
|||||||
assert.Equal(t, "auto", store.modeOf(1))
|
assert.Equal(t, "auto", store.modeOf(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postModeHTMX(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
form.Set("csrf_token", "csrf")
|
||||||
|
req, err := http.NewRequest(http.MethodPost, srv.URL+"/services/"+id+"/mode", strings.NewReader(form.Encode()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
resp, err := noRedirectClient().Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_HTMXFromOverviewReturnsServicesFragment(t *testing.T) {
|
||||||
|
srv, store, _, broker, _ := newTestEnv(t)
|
||||||
|
sub, cancel := broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, body := postModeHTMX(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "no redirect, so the page keeps its scroll position")
|
||||||
|
assert.Empty(t, resp.Header.Get("Location"))
|
||||||
|
assert.NotContains(t, body, "<html")
|
||||||
|
assert.Contains(t, body, `id="machines" hx-swap-oob`)
|
||||||
|
assert.Contains(t, body, `action="/services/1/mode"`)
|
||||||
|
assert.Equal(t, "auto", store.modeOf(1))
|
||||||
|
assert.Equal(t, events.Services, <-sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_HTMXRefusedRedirectsToService(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
|
resp, _ := postModeHTMX(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/services/1", resp.Header.Get("HX-Redirect"), "htmx does not swap a 409, so send the user to the page that shows why")
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSetMode_AutoRefusedShows409(t *testing.T) {
|
func TestSetMode_AutoRefusedShows409(t *testing.T) {
|
||||||
srv, store, _, _, jobs := newTestEnv(t)
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ summary.btn::-webkit-details-marker{display:none}
|
|||||||
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
|
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
|
||||||
.seg button:hover{color:var(--fg)}
|
.seg button:hover{color:var(--fg)}
|
||||||
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
|
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
|
||||||
|
/* htmx inserts the new list before removing the old one; scroll anchoring on the
|
||||||
|
focused switch would scroll by the list height and then land at the bottom. */
|
||||||
|
#services{overflow-anchor:none}
|
||||||
.grp{margin-top:28px}
|
.grp{margin-top:28px}
|
||||||
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
|
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
|
||||||
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
|
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
{{if .Self}}<span class="tag f-out">This agent</span>{{end}}
|
{{if .Self}}<span class="tag f-out">This agent</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="mode">
|
<div class="mode">
|
||||||
<form method="post" action="/services/{{.ID}}/mode">
|
<form method="post" action="/services/{{.ID}}/mode" hx-post="/services/{{.ID}}/mode" hx-target="#services" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
<input type="hidden" name="return" value="overview">
|
<input type="hidden" name="return" value="overview">
|
||||||
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
</form>
|
</form>
|
||||||
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
||||||
{{end}}
|
{{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">
|
<form method="post" action="/services/{{.Service.ID}}/mode">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
{{if eq .Service.UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
{{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
|
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
|
// pollingStatus is the text of the "Polling" row on the service page and
|
||||||
// whether it is a registry error.
|
// whether it is a registry error.
|
||||||
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {
|
func pollingStatus(svc model.Service, interval time.Duration) (text string, failed bool) {
|
||||||
|
|||||||
Reference in New Issue
Block a user