feat(master): suggest newer version tags for third-party images (#11) (#31)
Docker Build / docker (push) Successful in 45s
Docker Build / docker (push) Successful in 45s
This commit was merged in pull request #31.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
// 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.
|
||||
// a service in auto mode gets an update job. It also lists the tags of the
|
||||
// repos whose followed tag looks like a version and suggests a newer one;
|
||||
// that suggestion never starts a job.
|
||||
package poller
|
||||
|
||||
import (
|
||||
@@ -12,14 +14,19 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shcizo/package-updater/internal/imageref"
|
||||
"github.com/shcizo/package-updater/internal/master/events"
|
||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/master/tagver"
|
||||
)
|
||||
|
||||
// lookupTimeout bounds one registry lookup.
|
||||
const lookupTimeout = 30 * time.Second
|
||||
|
||||
// listTimeout bounds one tag listing, which may take several requests.
|
||||
const listTimeout = 60 * time.Second
|
||||
|
||||
// maxErrorBytes caps the registry error stored in services.last_poll_error.
|
||||
const maxErrorBytes = 500
|
||||
|
||||
@@ -29,6 +36,7 @@ type Store interface {
|
||||
GetService(ctx context.Context, id int64) (model.Service, error)
|
||||
SetAvailable(ctx context.Context, id int64, a model.Available) error
|
||||
SetLastTagDigest(ctx context.Context, id int64, digest string) error
|
||||
SetNewer(ctx context.Context, id int64, ref string, at time.Time) error
|
||||
SetPollError(ctx context.Context, ids []int64, msg string) error
|
||||
}
|
||||
|
||||
@@ -37,10 +45,11 @@ 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.
|
||||
// Registry looks up the digest a "repo:tag" ref points at and lists the tags
|
||||
// of a repo. *registry.Client satisfies it.
|
||||
type Registry interface {
|
||||
Digest(ctx context.Context, ref string) (string, error)
|
||||
Tags(ctx context.Context, repo string) ([]string, error)
|
||||
}
|
||||
|
||||
// Publisher receives change signals. *events.Broker satisfies it.
|
||||
@@ -50,7 +59,7 @@ type Publisher interface{ Publish(t events.Topic) }
|
||||
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
|
||||
Workers int // registry requests in flight at once; 4 in production
|
||||
}
|
||||
|
||||
// Poller runs polling cycles.
|
||||
@@ -113,13 +122,16 @@ type lookup struct {
|
||||
// cycleStats is what one cycle did, for the poll_cycle log line.
|
||||
type cycleStats struct {
|
||||
refs, ok, failed, suggested, jobs, skipped int
|
||||
reposListed, tagsFailed, newer 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.
|
||||
// results. Then it lists the tags of each repo that has a service following a
|
||||
// version-like tag, once per repo, and records newer tags. 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)
|
||||
@@ -153,19 +165,30 @@ func (p *Poller) Cycle(ctx context.Context) {
|
||||
}
|
||||
p.record(write, l, byRef[l.ref], &stats)
|
||||
}
|
||||
if stats.wrote {
|
||||
p.pub.Publish(events.Services)
|
||||
}
|
||||
// Publish after the digest phase too: listing tags can take a while and
|
||||
// an "Update available" should not wait for it.
|
||||
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,
|
||||
"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))
|
||||
// publishIfWrote tells the browsers to refresh if a phase of the cycle wrote
|
||||
// anything, and starts the next phase with a clean slate.
|
||||
func (p *Poller) publishIfWrote(stats *cycleStats) {
|
||||
if stats.wrote {
|
||||
p.pub.Publish(events.Services)
|
||||
stats.wrote = false
|
||||
}
|
||||
}
|
||||
|
||||
// forEach calls fn(i) for i in [0, n), Config.Workers at a time, and stops
|
||||
// starting new calls once ctx is done. It returns when all started calls
|
||||
// have returned.
|
||||
func (p *Poller) forEach(ctx context.Context, n int, fn func(i int)) {
|
||||
next := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < p.cfg.Workers; w++ {
|
||||
@@ -173,15 +196,12 @@ func (p *Poller) lookupAll(ctx context.Context, refs []string) []lookup {
|
||||
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}
|
||||
fn(i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
feed:
|
||||
for i := range refs {
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case next <- i:
|
||||
case <-ctx.Done():
|
||||
@@ -190,6 +210,19 @@ feed:
|
||||
}
|
||||
close(next)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// 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))
|
||||
p.forEach(ctx, len(refs), func(i int) {
|
||||
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}
|
||||
})
|
||||
var out []lookup
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
@@ -199,6 +232,118 @@ feed:
|
||||
return out
|
||||
}
|
||||
|
||||
// tagList is the registry's answer for the tags of one repo.
|
||||
type tagList struct {
|
||||
repo string
|
||||
tags []string
|
||||
err error
|
||||
}
|
||||
|
||||
// listAll lists the tags of every repo like lookupAll looks up refs.
|
||||
func (p *Poller) listAll(ctx context.Context, repos []string) []tagList {
|
||||
results := make([]*tagList, len(repos))
|
||||
p.forEach(ctx, len(repos), func(i int) {
|
||||
lctx, cancel := context.WithTimeout(ctx, listTimeout)
|
||||
tags, err := p.reg.Tags(lctx, repos[i])
|
||||
cancel()
|
||||
results[i] = &tagList{repo: repos[i], tags: tags, err: err}
|
||||
})
|
||||
var out []tagList
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
out = append(out, *r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Followed returns the repo and tag svc follows: a bare image follows
|
||||
// "latest", and a digest-only reference follows no tag.
|
||||
func Followed(svc model.Service) (repo, tag string) {
|
||||
ref := imageref.Parse(jobs.FollowedRef(svc.ActualRef))
|
||||
return ref.Repo, ref.Tag
|
||||
}
|
||||
|
||||
// followedComparable returns the repo and tag svc follows. ok is false unless
|
||||
// svc is polled and the tag can have a newer tag.
|
||||
func followedComparable(svc model.Service) (repo, tag string, ok bool) {
|
||||
if SkipReason(svc) != "" {
|
||||
return "", "", false
|
||||
}
|
||||
repo, tag = Followed(svc)
|
||||
return repo, tag, tagver.Comparable(tag)
|
||||
}
|
||||
|
||||
// syncNewer lists the tags of every repo that has a service following a
|
||||
// version-like tag, once per repo, and stores the newer tag of each service.
|
||||
// A service whose tag cannot have a newer tag any more gets its suggestion
|
||||
// cleared; one whose repo could not be listed keeps it, unless it names
|
||||
// another repo than the service follows now. Nothing is written
|
||||
// when the value is unchanged. It never starts a job, not even for an auto
|
||||
// service: a newer tag may be a major step the user has not chosen.
|
||||
func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, stats *cycleStats) {
|
||||
seen := map[string]bool{}
|
||||
var repos []string
|
||||
for _, svc := range svcs {
|
||||
if repo, _, ok := followedComparable(svc); ok && svc.RemovedAt == nil && !seen[repo] {
|
||||
seen[repo] = true
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
}
|
||||
lists := map[string][]string{}
|
||||
for _, l := range p.listAll(ctx, repos) {
|
||||
if l.err != nil {
|
||||
p.log.Warn("poll_tags_failed", "repo", l.repo, "error", truncate(l.err.Error()))
|
||||
if ctx.Err() == nil { // cancelled mid-listing: not the registry's fault
|
||||
stats.tagsFailed++
|
||||
}
|
||||
continue
|
||||
}
|
||||
stats.reposListed++
|
||||
lists[l.repo] = l.tags
|
||||
}
|
||||
|
||||
type repoTag struct{ repo, tag string }
|
||||
newest := map[repoTag]string{}
|
||||
for _, svc := range svcs {
|
||||
if svc.RemovedAt != nil {
|
||||
continue
|
||||
}
|
||||
want := ""
|
||||
if repo, tag, ok := followedComparable(svc); ok {
|
||||
tags, listed := lists[repo]
|
||||
if !listed && imageref.Parse(svc.NewerRef).Repo == repo {
|
||||
if svc.NewerRef != "" {
|
||||
stats.newer++
|
||||
}
|
||||
continue // not listed this cycle: keep what is stored
|
||||
}
|
||||
if listed {
|
||||
key := repoTag{repo, tag}
|
||||
n, done := newest[key]
|
||||
if !done {
|
||||
n, _ = tagver.Newer(tag, tags)
|
||||
newest[key] = n
|
||||
}
|
||||
if n != "" {
|
||||
want = repo + ":" + n
|
||||
}
|
||||
}
|
||||
}
|
||||
if want != "" {
|
||||
stats.newer++
|
||||
}
|
||||
if want == svc.NewerRef {
|
||||
continue
|
||||
}
|
||||
if err := p.store.SetNewer(write, svc.ID, want, p.now()); err != nil {
|
||||
p.log.Error("poll_set_newer_failed", "service_id", svc.ID, "ref", want, "error", err)
|
||||
continue
|
||||
}
|
||||
stats.wrote = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user