feat(master): registry digest lookup and polling loop (#9)
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// Package poller checks the registry for new digests of the tags the
|
||||
// services follow. A new digest becomes the service's available version, and
|
||||
// a service in auto mode gets an update job.
|
||||
package poller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
// lookupTimeout bounds one registry lookup.
|
||||
const lookupTimeout = 30 * time.Second
|
||||
|
||||
// maxErrorBytes caps the registry error stored in services.last_poll_error.
|
||||
const maxErrorBytes = 500
|
||||
|
||||
// Store is the storage the poller needs. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
ListServices(ctx context.Context) ([]model.Service, error)
|
||||
SetAvailable(ctx context.Context, id int64, a model.Available) error
|
||||
SetPollError(ctx context.Context, ids []int64, msg string) error
|
||||
}
|
||||
|
||||
// Jobs starts updates. *jobs.Service satisfies it.
|
||||
type Jobs interface {
|
||||
Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error)
|
||||
}
|
||||
|
||||
// Registry looks up the digest a "repo:tag" ref points at. *registry.Client
|
||||
// satisfies it.
|
||||
type Registry interface {
|
||||
Digest(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// Publisher receives change signals. *events.Broker satisfies it.
|
||||
type Publisher interface{ Publish(t events.Topic) }
|
||||
|
||||
// Config holds the poller settings.
|
||||
type Config struct {
|
||||
Interval time.Duration // time between the end of a cycle and the start of the next (POLL_INTERVAL)
|
||||
FirstDelay time.Duration // wait before the first cycle; 1 minute in production
|
||||
Workers int // registry lookups in flight at once; 4 in production
|
||||
}
|
||||
|
||||
// Poller runs polling cycles.
|
||||
type Poller struct {
|
||||
store Store
|
||||
jobs Jobs
|
||||
reg Registry
|
||||
pub Publisher
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns a Poller.
|
||||
func New(st Store, j Jobs, reg Registry, pub Publisher, cfg Config, log *slog.Logger) *Poller {
|
||||
if cfg.Workers < 1 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
return &Poller{store: st, jobs: j, reg: reg, pub: pub, cfg: cfg, log: log, now: time.Now}
|
||||
}
|
||||
|
||||
// SkipReason says why svc is not polled: "follows no tag", "built from
|
||||
// source", "no registry digest", or "" when it is polled.
|
||||
func SkipReason(svc model.Service) string {
|
||||
switch {
|
||||
case jobs.FollowedRef(svc.ActualRef) == "":
|
||||
return "follows no tag"
|
||||
case svc.Built:
|
||||
return "built from source"
|
||||
case svc.ActualDigest == "":
|
||||
return "no registry digest"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run polls until ctx is done. The first cycle starts Config.FirstDelay after
|
||||
// the call and each following one Config.Interval after the previous cycle
|
||||
// ended, so cycles never overlap. It blocks.
|
||||
func (p *Poller) Run(ctx context.Context) {
|
||||
timer := time.NewTimer(p.cfg.FirstDelay)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
p.Cycle(ctx)
|
||||
timer.Reset(p.cfg.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
// lookup is the registry's answer for one followed ref.
|
||||
type lookup struct {
|
||||
ref string
|
||||
digest string
|
||||
err error
|
||||
}
|
||||
|
||||
// cycleStats is what one cycle did, for the poll_cycle log line.
|
||||
type cycleStats struct {
|
||||
refs, ok, failed, suggested, jobs, skipped int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
// Cycle runs one poll over all services. It looks up every distinct followed
|
||||
// ref once, with at most Config.Workers lookups at a time, then records the
|
||||
// results. Writing results and starting jobs is not cut short by ctx ending
|
||||
// during the lookups, so a job that was marked is always started.
|
||||
func (p *Poller) Cycle(ctx context.Context) {
|
||||
began := p.now()
|
||||
svcs, err := p.store.ListServices(ctx)
|
||||
if err != nil {
|
||||
p.log.Error("poll_list_failed", "error", err)
|
||||
return
|
||||
}
|
||||
var stats cycleStats
|
||||
byRef := map[string][]model.Service{}
|
||||
var refs []string
|
||||
for _, svc := range svcs {
|
||||
if svc.RemovedAt != nil {
|
||||
continue
|
||||
}
|
||||
if SkipReason(svc) != "" {
|
||||
stats.skipped++
|
||||
continue
|
||||
}
|
||||
ref := jobs.FollowedRef(svc.ActualRef)
|
||||
if _, seen := byRef[ref]; !seen {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
byRef[ref] = append(byRef[ref], svc)
|
||||
}
|
||||
stats.refs = len(refs)
|
||||
|
||||
write := context.WithoutCancel(ctx)
|
||||
for _, l := range p.lookupAll(ctx, refs) {
|
||||
if l.err != nil && ctx.Err() != nil {
|
||||
continue // cancelled mid-lookup: not the registry's fault
|
||||
}
|
||||
p.record(write, l, byRef[l.ref], &stats)
|
||||
}
|
||||
if stats.wrote {
|
||||
p.pub.Publish(events.Services)
|
||||
}
|
||||
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
|
||||
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
|
||||
"duration_ms", p.now().Sub(began).Milliseconds())
|
||||
}
|
||||
|
||||
// lookupAll asks the registry for every ref, Config.Workers at a time. It
|
||||
// returns the answers in ref order and stops starting new lookups once ctx
|
||||
// is done; refs never looked up are missing from the result.
|
||||
func (p *Poller) lookupAll(ctx context.Context, refs []string) []lookup {
|
||||
results := make([]*lookup, len(refs))
|
||||
next := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < p.cfg.Workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
lctx, cancel := context.WithTimeout(ctx, lookupTimeout)
|
||||
d, err := p.reg.Digest(lctx, refs[i])
|
||||
cancel()
|
||||
results[i] = &lookup{ref: refs[i], digest: d, err: err}
|
||||
}
|
||||
}()
|
||||
}
|
||||
feed:
|
||||
for i := range refs {
|
||||
select {
|
||||
case next <- i:
|
||||
case <-ctx.Done():
|
||||
break feed
|
||||
}
|
||||
}
|
||||
close(next)
|
||||
wg.Wait()
|
||||
var out []lookup
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
out = append(out, *r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// record stores one lookup result for the services that follow l.ref.
|
||||
func (p *Poller) record(ctx context.Context, l lookup, svcs []model.Service, stats *cycleStats) {
|
||||
if l.err != nil {
|
||||
stats.failed++
|
||||
msg := truncate(l.err.Error())
|
||||
p.log.Warn("poll_failed", "ref", l.ref, "error", msg)
|
||||
p.setPollError(ctx, svcs, msg, stats, func(s model.Service) bool { return s.LastPollError != msg })
|
||||
return
|
||||
}
|
||||
stats.ok++
|
||||
p.setPollError(ctx, svcs, "", stats, func(s model.Service) bool { return s.LastPollError != "" })
|
||||
for _, svc := range svcs {
|
||||
if svc.ActualDigest == l.digest || (svc.AvailableRef == l.ref && svc.AvailableDigest == l.digest) {
|
||||
continue
|
||||
}
|
||||
err := p.store.SetAvailable(ctx, svc.ID, model.Available{Ref: l.ref, Digest: l.digest, Source: model.SourcePoll, At: p.now()})
|
||||
if err != nil {
|
||||
p.log.Error("poll_set_available_failed", "service_id", svc.ID, "ref", l.ref, "error", err)
|
||||
continue
|
||||
}
|
||||
stats.wrote = true
|
||||
stats.suggested++
|
||||
if svc.UpdateMode != model.ModeAuto {
|
||||
continue
|
||||
}
|
||||
p.startAuto(ctx, svc, l.ref, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// startAuto starts the update job of an auto service to ref. A service that
|
||||
// already has a job or cannot be updated is not an error for the cycle.
|
||||
func (p *Poller) startAuto(ctx context.Context, svc model.Service, ref string, stats *cycleStats) {
|
||||
_, err := p.jobs.Start(ctx, svc, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref})
|
||||
var notUpdatable *model.NotUpdatableError
|
||||
switch {
|
||||
case err == nil:
|
||||
stats.jobs++
|
||||
case errors.Is(err, model.ErrJobActive), errors.As(err, ¬Updatable):
|
||||
p.log.Debug("poll_job_not_started", "service_id", svc.ID, "ref", ref, "reason", err.Error())
|
||||
default:
|
||||
p.log.Warn("poll_job_failed", "service_id", svc.ID, "ref", ref, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// setPollError sets msg as last_poll_error on the services for which
|
||||
// changed returns true; with none, it writes nothing.
|
||||
func (p *Poller) setPollError(ctx context.Context, svcs []model.Service, msg string, stats *cycleStats, changed func(model.Service) bool) {
|
||||
var ids []int64
|
||||
for _, svc := range svcs {
|
||||
if changed(svc) {
|
||||
ids = append(ids, svc.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
if err := p.store.SetPollError(ctx, ids, msg); err != nil {
|
||||
p.log.Error("poll_set_error_failed", "error", err)
|
||||
return
|
||||
}
|
||||
stats.wrote = true
|
||||
}
|
||||
|
||||
// truncate cuts msg to maxErrorBytes without leaving a broken UTF-8
|
||||
// sequence, and drops NUL bytes, which Postgres text cannot hold.
|
||||
func truncate(msg string) string {
|
||||
msg = strings.ReplaceAll(msg, "\x00", "")
|
||||
if len(msg) > maxErrorBytes {
|
||||
msg = strings.ToValidUTF8(msg[:maxErrorBytes], "")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package poller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
)
|
||||
|
||||
type availableCall struct {
|
||||
id int64
|
||||
a model.Available
|
||||
}
|
||||
|
||||
type pollErrorCall struct {
|
||||
ids []int64
|
||||
msg string
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
services []model.Service
|
||||
listErr error
|
||||
available []availableCall
|
||||
pollErrors []pollErrorCall
|
||||
ctxErrs []error // ctx.Err() seen by every write
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) {
|
||||
return f.services, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetAvailable(ctx context.Context, id int64, a model.Available) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||
f.available = append(f.available, availableCall{id, a})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetPollError(ctx context.Context, ids []int64, msg string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||
f.pollErrors = append(f.pollErrors, pollErrorCall{ids, msg})
|
||||
return nil
|
||||
}
|
||||
|
||||
type startCall struct {
|
||||
svcID int64
|
||||
req jobs.StartRequest
|
||||
}
|
||||
|
||||
type fakeJobs struct {
|
||||
mu sync.Mutex
|
||||
starts []startCall
|
||||
errFor map[int64]error // by service ID
|
||||
ctxErrs []error
|
||||
}
|
||||
|
||||
func (f *fakeJobs) Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.ctxErrs = append(f.ctxErrs, ctx.Err())
|
||||
f.starts = append(f.starts, startCall{svc.ID, req})
|
||||
return model.Job{ID: int64(len(f.starts))}, f.errFor[svc.ID]
|
||||
}
|
||||
|
||||
type fakeRegistry struct {
|
||||
mu sync.Mutex
|
||||
digests map[string]string // ref -> digest
|
||||
errs map[string]error // ref -> error
|
||||
calls []string
|
||||
inFlight int
|
||||
maxSeen int
|
||||
delay time.Duration
|
||||
started chan struct{} // receives a value whenever a lookup starts, if set
|
||||
release chan struct{} // lookups wait for it to be closed, if set
|
||||
onResult func() // called after a lookup computed its answer
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) Digest(ctx context.Context, ref string) (string, error) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, ref)
|
||||
f.inFlight++
|
||||
if f.inFlight > f.maxSeen {
|
||||
f.maxSeen = f.inFlight
|
||||
}
|
||||
f.mu.Unlock()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
f.inFlight--
|
||||
f.mu.Unlock()
|
||||
}()
|
||||
|
||||
if f.started != nil {
|
||||
f.started <- struct{}{}
|
||||
}
|
||||
if f.release != nil {
|
||||
select {
|
||||
case <-f.release:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
if f.delay > 0 {
|
||||
time.Sleep(f.delay)
|
||||
}
|
||||
if f.onResult != nil {
|
||||
f.onResult()
|
||||
}
|
||||
if err := f.errs[ref]; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.digests[ref], nil
|
||||
}
|
||||
|
||||
func (f *fakeRegistry) sortedCalls() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := append([]string(nil), f.calls...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
type fakePub struct {
|
||||
mu sync.Mutex
|
||||
topics []events.Topic
|
||||
}
|
||||
|
||||
func (p *fakePub) Publish(t events.Topic) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.topics = append(p.topics, t)
|
||||
}
|
||||
|
||||
const (
|
||||
repoA = "gitea.shcizo.se/shcizo/slusk"
|
||||
repoB = "gitea/gitea"
|
||||
)
|
||||
|
||||
// svc builds a polled service whose running version has digest sha256:old.
|
||||
func svc(id int64, actualRef string) model.Service {
|
||||
return model.Service{ID: id, Kind: "compose", ActualRef: actualRef, ActualDigest: "sha256:old", UpdateMode: model.ModeManual}
|
||||
}
|
||||
|
||||
func newPoller(st *fakeStore, j *fakeJobs, reg *fakeRegistry, cfg Config) (*Poller, *fakePub) {
|
||||
pub := &fakePub{}
|
||||
if cfg.Workers == 0 {
|
||||
cfg.Workers = 4
|
||||
}
|
||||
p := New(st, j, reg, pub, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
p.now = func() time.Time { return time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) }
|
||||
return p, pub
|
||||
}
|
||||
|
||||
func TestSkipReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svc model.Service
|
||||
want string
|
||||
}{
|
||||
{"tag and digest", model.Service{ActualRef: "r:1", ActualDigest: "sha256:a"}, ""},
|
||||
{"bare image follows latest", model.Service{ActualRef: "r", ActualDigest: "sha256:a"}, ""},
|
||||
{"digest only follows no tag", model.Service{ActualRef: "r@sha256:a", ActualDigest: "sha256:a"}, "follows no tag"},
|
||||
{"built", model.Service{ActualRef: "r:1", ActualDigest: "sha256:a", Built: true}, "built from source"},
|
||||
{"no digest", model.Service{ActualRef: "r:1"}, "no registry digest"},
|
||||
{"self and container are polled", model.Service{ActualRef: "r:1", ActualDigest: "sha256:a", Self: true, Kind: "container"}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, SkipReason(tt.svc))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneHeadPerDistinctRef(t *testing.T) {
|
||||
st := &fakeStore{services: []model.Service{
|
||||
svc(1, repoA+":1.4"),
|
||||
svc(2, repoA+":1.4@sha256:old"),
|
||||
svc(3, repoB),
|
||||
svc(4, repoB+":latest"),
|
||||
svc(5, repoB+":1.27"),
|
||||
}}
|
||||
reg := &fakeRegistry{digests: map[string]string{}}
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Equal(t, []string{repoA + ":1.4", repoB + ":1.27", repoB + ":latest"}, reg.sortedCalls())
|
||||
}
|
||||
|
||||
func TestSkipped(t *testing.T) {
|
||||
removed := svc(4, "r:1")
|
||||
removed.RemovedAt = ptr(time.Now())
|
||||
st := &fakeStore{services: []model.Service{
|
||||
{ID: 1, ActualRef: "r@sha256:a", ActualDigest: "sha256:a"},
|
||||
{ID: 2, ActualRef: "r:1", ActualDigest: "sha256:a", Built: true},
|
||||
{ID: 3, ActualRef: "r:1"},
|
||||
removed,
|
||||
}}
|
||||
reg := &fakeRegistry{}
|
||||
j := &fakeJobs{}
|
||||
p, pub := newPoller(st, j, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Empty(t, reg.calls, "no lookups")
|
||||
assert.Empty(t, st.available)
|
||||
assert.Empty(t, st.pollErrors)
|
||||
assert.Empty(t, j.starts)
|
||||
assert.Empty(t, pub.topics)
|
||||
}
|
||||
|
||||
func TestNewDigest(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
wantStart bool
|
||||
}{
|
||||
{"auto gets one poll job to repo:tag", model.ModeAuto, true},
|
||||
{"manual is only marked", model.ModeManual, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := svc(1, ref+"@sha256:old")
|
||||
s.UpdateMode = tt.mode
|
||||
st := &fakeStore{services: []model.Service{s}}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||
j := &fakeJobs{}
|
||||
p, pub := newPoller(st, j, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
require.Len(t, st.available, 1)
|
||||
assert.Equal(t, int64(1), st.available[0].id)
|
||||
assert.Equal(t, model.Available{Ref: ref, Digest: "sha256:new", Source: model.SourcePoll,
|
||||
At: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}, st.available[0].a)
|
||||
if tt.wantStart {
|
||||
assert.Equal(t, []startCall{{1, jobs.StartRequest{Trigger: model.TriggerPoll, Ref: ref}}}, j.starts)
|
||||
} else {
|
||||
assert.Empty(t, j.starts)
|
||||
}
|
||||
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameSuggestionIsIdempotent(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
running := svc(1, ref+"@sha256:old")
|
||||
running.UpdateMode = model.ModeAuto
|
||||
running.ActualDigest = "sha256:new" // already on the registry's digest
|
||||
suggested := svc(2, ref)
|
||||
suggested.UpdateMode = model.ModeAuto
|
||||
suggested.AvailableRef, suggested.AvailableDigest = ref, "sha256:new"
|
||||
staleSuggestion := svc(3, ref)
|
||||
staleSuggestion.AvailableRef, staleSuggestion.AvailableDigest = ref, "sha256:older"
|
||||
st := &fakeStore{services: []model.Service{running, suggested, staleSuggestion}}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||
j := &fakeJobs{}
|
||||
p, _ := newPoller(st, j, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
require.Len(t, st.available, 1, "only the service with an older suggestion is updated")
|
||||
assert.Equal(t, int64(3), st.available[0].id)
|
||||
assert.Empty(t, j.starts, "no new job for an already suggested digest")
|
||||
}
|
||||
|
||||
func TestStartErrorsDoNotAbortCycle(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
var svcs []model.Service
|
||||
for id := int64(1); id <= 3; id++ {
|
||||
s := svc(id, ref)
|
||||
s.UpdateMode = model.ModeAuto
|
||||
svcs = append(svcs, s)
|
||||
}
|
||||
st := &fakeStore{services: svcs}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||
j := &fakeJobs{errFor: map[int64]error{
|
||||
1: model.ErrJobActive,
|
||||
2: &model.NotUpdatableError{Reason: model.ReasonSelf},
|
||||
3: errors.New("database down"),
|
||||
}}
|
||||
p, _ := newPoller(st, j, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Len(t, st.available, 3)
|
||||
assert.Len(t, j.starts, 3)
|
||||
}
|
||||
|
||||
func TestErrorIsPerRef(t *testing.T) {
|
||||
bad, good := repoA+":1.4", repoB+":1.27"
|
||||
badOld := svc(1, bad)
|
||||
badSame := svc(2, bad)
|
||||
badSame.LastPollError = "head: 401 unauthorized"
|
||||
goodSvc := svc(3, good)
|
||||
st := &fakeStore{services: []model.Service{badOld, badSame, goodSvc}}
|
||||
reg := &fakeRegistry{
|
||||
errs: map[string]error{bad: errors.New("head: 401 unauthorized")},
|
||||
digests: map[string]string{good: "sha256:new"},
|
||||
}
|
||||
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Equal(t, []pollErrorCall{{[]int64{1}, "head: 401 unauthorized"}}, st.pollErrors,
|
||||
"only the service whose stored error differs is written")
|
||||
require.Len(t, st.available, 1, "the other ref is still processed")
|
||||
assert.Equal(t, int64(3), st.available[0].id)
|
||||
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||
}
|
||||
|
||||
func TestErrorTextIsTruncated(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
st := &fakeStore{services: []model.Service{svc(1, ref)}}
|
||||
reg := &fakeRegistry{errs: map[string]error{ref: errors.New(strings.Repeat("é", 400) + "\x00")}}
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
require.Len(t, st.pollErrors, 1)
|
||||
msg := st.pollErrors[0].msg
|
||||
assert.LessOrEqual(t, len(msg), maxErrorBytes)
|
||||
assert.Equal(t, strings.Repeat("é", 250), msg, "cut on a character boundary, NUL removed")
|
||||
}
|
||||
|
||||
func TestErrorClearedOnSuccess(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
failing := svc(1, ref)
|
||||
failing.LastPollError = "head: boom"
|
||||
fine := svc(2, ref)
|
||||
failing.ActualDigest, fine.ActualDigest = "sha256:new", "sha256:new"
|
||||
st := &fakeStore{services: []model.Service{failing, fine}}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Equal(t, []pollErrorCall{{[]int64{1}, ""}}, st.pollErrors)
|
||||
assert.Empty(t, st.available)
|
||||
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
|
||||
}
|
||||
|
||||
func TestNothingWrittenNothingPublished(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
s := svc(1, ref)
|
||||
s.ActualDigest = "sha256:new"
|
||||
st := &fakeStore{services: []model.Service{s}}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
|
||||
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Empty(t, st.pollErrors)
|
||||
assert.Empty(t, st.available)
|
||||
assert.Empty(t, pub.topics)
|
||||
}
|
||||
|
||||
func TestListErrorEndsTheCycle(t *testing.T) {
|
||||
st := &fakeStore{listErr: errors.New("db down")}
|
||||
reg := &fakeRegistry{}
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Empty(t, reg.calls)
|
||||
}
|
||||
|
||||
func TestConcurrencyLimit(t *testing.T) {
|
||||
var svcs []model.Service
|
||||
for i := 0; i < 8; i++ {
|
||||
svcs = append(svcs, svc(int64(i+1), fmt.Sprintf("%s%d:1", repoA, i)))
|
||||
}
|
||||
st := &fakeStore{services: svcs}
|
||||
reg := &fakeRegistry{delay: 20 * time.Millisecond}
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{Workers: 3})
|
||||
|
||||
p.Cycle(context.Background())
|
||||
|
||||
assert.Len(t, reg.calls, 8)
|
||||
assert.Equal(t, 3, reg.maxSeen)
|
||||
}
|
||||
|
||||
func TestCycleStopsOnCancel(t *testing.T) {
|
||||
var svcs []model.Service
|
||||
for i := 0; i < 6; i++ {
|
||||
svcs = append(svcs, svc(int64(i+1), fmt.Sprintf("%s%d:1", repoA, i)))
|
||||
}
|
||||
st := &fakeStore{services: svcs}
|
||||
reg := &fakeRegistry{release: make(chan struct{}), started: make(chan struct{}, 6)}
|
||||
p, pub := newPoller(st, &fakeJobs{}, reg, Config{Workers: 2})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { p.Cycle(ctx); close(done) }()
|
||||
<-reg.started
|
||||
<-reg.started
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Cycle did not return after cancel")
|
||||
}
|
||||
assert.Len(t, reg.calls, 2, "no new lookups after cancel")
|
||||
assert.Empty(t, st.pollErrors, "cancelled lookups are not recorded as registry errors")
|
||||
assert.Empty(t, st.available)
|
||||
assert.Empty(t, pub.topics)
|
||||
}
|
||||
|
||||
func TestMarkedJobsAreStartedEvenIfCancelledDuringTheCycle(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
s := svc(1, ref)
|
||||
s.UpdateMode = model.ModeAuto
|
||||
st := &fakeStore{services: []model.Service{s}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}, onResult: cancel}
|
||||
j := &fakeJobs{}
|
||||
p, _ := newPoller(st, j, reg, Config{})
|
||||
|
||||
p.Cycle(ctx)
|
||||
|
||||
require.Len(t, st.available, 1)
|
||||
require.Len(t, j.starts, 1)
|
||||
assert.NoError(t, st.ctxErrs[0], "writes do not see the cancelled context")
|
||||
assert.NoError(t, j.ctxErrs[0])
|
||||
}
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
ref := repoA + ":1.4"
|
||||
st := &fakeStore{services: []model.Service{svc(1, ref)}}
|
||||
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:old"}, delay: 30 * time.Millisecond}
|
||||
p, _ := newPoller(st, &fakeJobs{}, reg, Config{Interval: 5 * time.Millisecond, FirstDelay: 60 * time.Millisecond, Workers: 1})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
began := time.Now()
|
||||
go func() { p.Run(ctx); close(done) }()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
reg.mu.Lock()
|
||||
defer reg.mu.Unlock()
|
||||
return len(reg.calls) >= 3
|
||||
}, 5*time.Second, 5*time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
assert.GreaterOrEqual(t, time.Since(began), 60*time.Millisecond+2*30*time.Millisecond, "first cycle waits FirstDelay")
|
||||
assert.Equal(t, 1, reg.maxSeen, "cycles never overlap")
|
||||
}
|
||||
|
||||
func TestRunReturnsDuringFirstDelay(t *testing.T) {
|
||||
p, _ := newPoller(&fakeStore{}, &fakeJobs{}, &fakeRegistry{}, Config{Interval: time.Hour, FirstDelay: time.Hour})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { p.Run(ctx); close(done) }()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
Reference in New Issue
Block a user