This commit was merged in pull request #36.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user