feat(master): POLL_INTERVAL, poller wiring and version picker in the UI (#9)

This commit is contained in:
2026-10-13 08:14:43 +02:00
parent c6ea2d3710
commit 2390b6151e
10 changed files with 438 additions and 11 deletions
+46 -2
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -20,6 +21,8 @@ import (
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/hub"
"github.com/shcizo/package-updater/internal/master/jobs"
"github.com/shcizo/package-updater/internal/master/poller"
"github.com/shcizo/package-updater/internal/master/registry"
"github.com/shcizo/package-updater/internal/master/store"
"github.com/shcizo/package-updater/internal/master/web"
"github.com/shcizo/package-updater/internal/master/webauth"
@@ -37,6 +40,29 @@ func main() {
}
}
// pollFirstDelay is how long the master runs before its first registry
// polling cycle, so agents have reported their inventory.
const pollFirstDelay = time.Minute
// pollWorkers is how many registry lookups run at once.
const pollWorkers = 4
// registryCredentialsDir returns the directory the registry client reads
// credentials from (DOCKER_CONFIG, else ~/.docker) and whether a config.json
// is in it. Only the path is logged, never the file.
func registryCredentialsDir() (dir string, found bool) {
dir = os.Getenv("DOCKER_CONFIG")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", false
}
dir = filepath.Join(home, ".docker")
}
_, err := os.Stat(filepath.Join(dir, "config.json"))
return dir, err == nil
}
func run() error {
cfg, err := config.Load()
if err != nil {
@@ -81,6 +107,7 @@ func run() error {
ui, err := web.New(st, agentHub, jobService, broker, web.Config{
AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage,
PollInterval: cfg.PollInterval,
}, logger)
if err != nil {
return err
@@ -127,22 +154,39 @@ func run() error {
BaseContext: baseContext,
}
credDir, credFound := registryCredentialsDir()
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound)
pollCtx, stopPolling := context.WithCancel(ctx)
defer stopPolling()
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version), broker, poller.Config{
Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers,
}, logger)
pollDone := make(chan struct{})
go func() {
defer close(pollDone)
registryPoller.Run(pollCtx)
}()
errCh := make(chan error, 2)
go func() { errCh <- webSrv.ListenAndServe() }()
go func() { errCh <- agentSrv.ListenAndServeTLS("", "") }()
logger.Info("listening", "web", webSrv.Addr, "agents", agentSrv.Addr)
var serveErr error
select {
case <-ctx.Done():
logger.Info("shutdown_signal")
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
return err
serveErr = err
}
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = webSrv.Shutdown(shutdownCtx)
_ = agentSrv.Shutdown(shutdownCtx)
return nil
// The poller writes to the store, which is closed when run returns.
stopPolling()
<-pollDone
return serveErr
}
+14
View File
@@ -28,11 +28,17 @@ type Config struct {
// UpdateTimeout bounds how long one webhook request waits for its auto
// jobs (UPDATE_TIMEOUT, default 5m).
UpdateTimeout time.Duration
// PollInterval is the time between registry polling cycles
// (POLL_INTERVAL, default 6h).
PollInterval time.Duration
}
// defaultUpdateTimeout is used when UPDATE_TIMEOUT is unset.
const defaultUpdateTimeout = 5 * time.Minute
// defaultPollInterval is used when POLL_INTERVAL is unset.
const defaultPollInterval = 6 * time.Hour
// Load reads and validates the master configuration, failing fast on
// anything missing so a half-configured master never starts.
func Load() (*Config, error) {
@@ -50,6 +56,7 @@ func Load() (*Config, error) {
LogLevel: getenvDefault("LOG_LEVEL", "info"),
UpdaterAPIKey: os.Getenv("UPDATER_API_KEY"),
UpdateTimeout: defaultUpdateTimeout,
PollInterval: defaultPollInterval,
}
for _, r := range []struct{ name, value string }{
{"DATABASE_URL", cfg.DatabaseURL},
@@ -77,6 +84,13 @@ func Load() (*Config, error) {
}
cfg.UpdateTimeout = d
}
if v := os.Getenv("POLL_INTERVAL"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return nil, fmt.Errorf("POLL_INTERVAL must be a positive duration such as 6h, got %q", v)
}
cfg.PollInterval = d
}
return cfg, nil
}
+21 -1
View File
@@ -22,7 +22,7 @@ func setAll(t *testing.T) {
for k, v := range required {
t.Setenv(k, v)
}
for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL", "UPDATER_API_KEY", "UPDATE_TIMEOUT"} {
for _, k := range []string{"PORT", "AGENT_PORT", "AGENT_IMAGE", "LOG_LEVEL", "UPDATER_API_KEY", "UPDATE_TIMEOUT", "POLL_INTERVAL"} {
t.Setenv(k, "")
}
}
@@ -38,6 +38,26 @@ func TestLoad_Defaults(t *testing.T) {
assert.Equal(t, "updater-admins", cfg.AllowedGroup)
assert.Empty(t, cfg.UpdaterAPIKey)
assert.Equal(t, 5*time.Minute, cfg.UpdateTimeout)
assert.Equal(t, 6*time.Hour, cfg.PollInterval)
}
func TestLoad_PollInterval(t *testing.T) {
setAll(t)
t.Setenv("POLL_INTERVAL", "30m")
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, 30*time.Minute, cfg.PollInterval)
}
func TestLoad_InvalidPollInterval(t *testing.T) {
for _, v := range []string{"abc", "0s", "-1h", "6"} {
t.Run(v, func(t *testing.T) {
setAll(t)
t.Setenv("POLL_INTERVAL", v)
_, err := Load()
assert.ErrorContains(t, err, "POLL_INTERVAL must be a positive duration such as 6h, got")
})
}
}
func TestLoad_WebhookSettings(t *testing.T) {
+36 -6
View File
@@ -26,12 +26,18 @@ var assets embed.FS
// serviceJobsLimit is how many jobs the service page lists.
const serviceJobsLimit = 20
// versionsLimit is how many image versions the service page lists.
const versionsLimit = 10
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// Store is the storage the UI reads and writes. *store.Store satisfies it.
type Store interface {
ListServices(ctx context.Context) ([]model.Service, error)
GetService(ctx context.Context, id int64) (model.Service, error)
// RecentVersions returns up to limit versions of repo the master has
// seen, most recently first seen first.
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
// SetUpdateMode returns model.ErrNotFound for an unknown or removed service.
SetUpdateMode(ctx context.Context, id int64, mode string) error
GetJob(ctx context.Context, id int64) (model.Job, error)
@@ -54,6 +60,9 @@ type Jobs interface {
Updatable(svc model.Service) error
AutoAllowed(svc model.Service) error
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
// TriggerVersion updates to exactly the image version versionID of the
// service's repo; model.ErrNotFound for an unknown service or version.
TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
}
// Broker delivers change signals to open streams and accepts signals from
@@ -63,11 +72,12 @@ type Broker interface {
Publish(t events.Topic)
}
// Config holds values shown in the "add agent" snippet.
// Config holds values shown in the "add agent" snippet and on the service page.
type Config struct {
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
MasterKey string // the master's fingerprint
AgentImage string
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
MasterKey string // the master's fingerprint
AgentImage string
PollInterval time.Duration // shown as "every <interval>" on the service page
}
// Server renders the UI.
@@ -85,7 +95,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) {
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration}
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag}
pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
@@ -132,6 +142,9 @@ 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
Versions []versionRow
Polling string // what the service page says about registry polling
PollFailed bool // Polling is the last registry error
Jobs []model.Job
Job model.Job
}
@@ -222,6 +235,12 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err
}
data.AutoBlocked = s.autoBlocked(svc)
versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit)
if err != nil {
return data, err
}
data.Versions = versionRows(versions, svc.ActualDigest)
data.Polling, data.PollFailed = pollingStatus(svc, s.cfg.PollInterval)
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
return data, err
}
@@ -260,7 +279,18 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
return
}
sess, _ := webauth.SessionFrom(r.Context())
job, err := s.jobs.Trigger(r.Context(), id, sess.Subject)
var job model.Job
var err error
if v := r.PostFormValue("version"); v == "" {
job, err = s.jobs.Trigger(r.Context(), id, sess.Subject)
} else {
versionID, perr := strconv.ParseInt(v, 10, 64)
if perr != nil {
http.Error(w, "invalid version", http.StatusBadRequest)
return
}
job, err = s.jobs.TriggerVersion(r.Context(), id, versionID, sess.Subject)
}
var nu *model.NotUpdatableError
switch {
case err == nil || errors.Is(err, model.ErrJobActive):
+28 -1
View File
@@ -34,6 +34,17 @@ type fakeStore struct {
created map[string]string // name → token hash
revokedID int64
revokeFailed []int64 // job IDs RevokeAgent reports as failed
versions []model.ImageVersion
}
func (f *fakeStore) RecentVersions(_ context.Context, repo string, limit int) ([]model.ImageVersion, error) {
var out []model.ImageVersion
for _, v := range f.versions {
if v.Repo == repo && len(out) < limit {
out = append(out, v)
}
}
return out, nil
}
func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil }
@@ -128,6 +139,9 @@ type fakeJobs struct {
mu sync.Mutex
requestedBy string
triggered []int64 // version IDs passed to TriggerVersion
triggerSvc []int64 // service IDs passed to TriggerVersion
plainCalls int // Trigger calls
}
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
@@ -138,6 +152,16 @@ func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, er
f.mu.Lock()
defer f.mu.Unlock()
f.requestedBy = by
f.plainCalls++
return f.job, f.err
}
func (f *fakeJobs) TriggerVersion(_ context.Context, serviceID, versionID int64, by string) (model.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.requestedBy = by
f.triggerSvc = append(f.triggerSvc, serviceID)
f.triggered = append(f.triggered, versionID)
return f.job, f.err
}
@@ -177,7 +201,9 @@ func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *event
return srv, store, hub, broker
}
func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
// newTestEnv starts the UI on fake storage. pollInterval is what the service
// page shows as the polling interval; it defaults to zero.
func newTestEnv(t *testing.T, pollInterval ...time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) {
t.Helper()
agentID := int64(2)
store := &fakeStore{
@@ -201,6 +227,7 @@ func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.B
jobs := &fakeJobs{}
s, err := New(store, hub, jobs, broker, Config{
AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest",
PollInterval: append(pollInterval, 0)[0],
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
mux := http.NewServeMux()
+1
View File
@@ -29,5 +29,6 @@ table.details { width: auto; }
table.details th { padding-right: 24px; }
pre.log { white-space: pre-wrap; }
.badge-update { color: var(--warn); border-color: var(--warn); }
.badge-error { color: var(--bad); border-color: var(--bad); }
form.mode { display: inline; margin-left: 8px; }
form.mode button { font-size: 12px; padding: 0 6px; }
+1 -1
View File
@@ -14,7 +14,7 @@
<tbody>
{{range .Services}}
<tr>
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}</td>
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}{{if followsNoTag .Service}} <span class="badge">follows no tag</span>{{end}}{{if .LastPollError}} <span class="badge badge-error" title="{{.LastPollError}}">poll failed</span>{{end}}</td>
<td><code>{{shortRef .ActualRef}}</code></td>
<td class="status status-{{.Status}}">{{.Status}}</td>
<td>{{.UpdateMode}}
@@ -10,6 +10,7 @@
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
{{if .Service.AvailableRef}}<tr><th>Available</th><td><code>{{.Service.AvailableRef}}</code> <span class="muted">via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}</span></td></tr>{{end}}
<tr><th>Mode</th><td>{{.Service.UpdateMode}}</td></tr>
<tr><th>Polling</th><td{{if .PollFailed}} class="error"{{end}}>{{.Polling}}</td></tr>
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
</table>
{{if .Reason}}
@@ -31,6 +32,28 @@
<p class="muted">{{if .AutoBlocked}}Auto unavailable: {{.AutoBlocked}}.{{else}}The webhook will update this service as soon as CI pushes a new image.{{end}}</p>
{{end}}
</form>
<h2>Versions</h2>
{{if eq .Service.UpdateMode "auto"}}<p class="warning">This service is in auto mode: a version picked here is replaced again when a newer version is announced or found by polling.</p>{{end}}
<table>
<thead><tr><th>Version</th><th>Seen via</th><th>First seen</th><th></th></tr></thead>
<tbody>
{{range .Versions}}
<tr>
<td><code title="{{.Ref}}">{{shortRef .Ref}}</code>{{if .Running}} <span class="badge">running</span>{{end}}{{if not .Tag}} <span class="muted">the service will follow no tag afterwards</span>{{end}}</td>
<td>{{.Source}}</td>
<td>{{.FirstSeenAt.Format "2006-01-02 15:04"}}</td>
<td>{{if not $.AutoBlocked}}
<form method="post" action="/services/{{$.Service.ID}}/update">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="version" value="{{.ID}}">
<button type="submit">Deploy</button>
</form>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="4">No versions seen yet.</td></tr>
{{end}}
</tbody>
</table>
<h2>Update history</h2>
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
{{template "history" .}}
+221
View File
@@ -0,0 +1,221 @@
package web
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
)
const sluskRepo = "gitea.shcizo.se/shcizo/slusk"
func TestServicePage_VersionPicker(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.services[0].ActualDigest = "sha256:cur"
first := time.Date(2026, 10, 12, 9, 5, 0, 0, time.UTC)
store.versions = []model.ImageVersion{
{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:" + strings.Repeat("a", 40), Source: "poll", FirstSeenAt: first},
{ID: 32, Repo: sluskRepo, Tag: "1.4.2", Digest: "sha256:cur", Source: "running", FirstSeenAt: first},
{ID: 33, Repo: sluskRepo, Digest: "sha256:" + strings.Repeat("b", 40), Source: "running", FirstSeenAt: first},
{ID: 34, Repo: sluskRepo, Tag: "main", Source: "webhook", FirstSeenAt: first},
{ID: 40, Repo: "other/repo", Tag: "x", Source: "poll", FirstSeenAt: first},
}
_, body := get(t, srv, "/services/1")
for _, id := range []string{"31", "32", "33", "34"} {
assert.Contains(t, body, `<input type="hidden" name="version" value="`+id+`">`, id)
}
assert.NotContains(t, body, `name="version" value="40"`, "versions of other repos are not listed")
assert.Equal(t, 4, strings.Count(body, `name="version"`))
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
assert.Contains(t, body, `title="`+sluskRepo+`:1.5.0@sha256:`+strings.Repeat("a", 40)+`"`, "full ref as title")
assert.Contains(t, body, sluskRepo+":1.5.0@sha256:"+strings.Repeat("a", 12), "short ref as text")
assert.Equal(t, 1, strings.Count(body, `<span class="badge">running</span>`), "only the running digest is marked")
assert.Equal(t, 1, strings.Count(body, "the service will follow no tag afterwards"), "only the tagless row")
assert.Contains(t, body, "2026-10-12 09:05")
assert.NotContains(t, body, "auto mode", "no auto warning for a manual service")
}
func TestServicePage_VersionPickerShowsAtMostTenRows(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
for i := int64(1); i <= 12; i++ {
store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"})
}
_, body := get(t, srv, "/services/1")
assert.Equal(t, 10, strings.Count(body, `name="version"`))
}
func TestServicePage_VersionPickerWarnsForAutoServices(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].UpdateMode = "auto"
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "This service is in auto mode")
}
func TestServicePage_VersionPickerHiddenWhenServiceCannotBeUpdated(t *testing.T) {
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} {
t.Run(reason, func(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Source: "poll"}}
jobs.autoErr = &model.NotUpdatableError{Reason: reason}
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "<h2>Versions</h2>")
assert.Contains(t, body, "1.5.0", "the history is still shown")
assert.NotContains(t, body, `name="version"`)
})
}
}
func TestServicePage_VersionPickerOffersVersionsToAServiceThatFollowsNoTag(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:a", Source: "poll"}}
jobs.updatable = &model.NotUpdatableError{Reason: model.ReasonNoTag} // autoErr stays nil: an explicit ref is fine
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, `name="version" value="31"`)
assert.NotContains(t, body, `<button type="submit">Update</button>`, "the plain Update button stays hidden")
}
func TestServicePage_PollingRow(t *testing.T) {
tests := []struct {
name string
edit func(*model.Service)
want string
failed bool
}{
{"polled", func(s *model.Service) { s.ActualDigest = "sha256:cur" }, "every 6h", false},
{"poll error", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.LastPollError = "head x: 401 <b>" }, "last check failed: head x: 401 &lt;b&gt;", true},
{"built", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.Built = true }, "not polled: built from source", false},
{"no digest", func(s *model.Service) {}, "not polled: no registry digest", false},
{"no tag", func(s *model.Service) { s.ActualRef = "r@sha256:a"; s.ActualDigest = "sha256:a" }, "not polled: follows no tag", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t, 6*time.Hour)
tt.edit(&store.services[0])
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "<th>Polling</th><td")
assert.Contains(t, body, tt.want)
assert.Equal(t, tt.failed, strings.Contains(body, `<td class="error">`+tt.want))
})
}
}
func TestShortDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{6 * time.Hour, "6h"},
{90 * time.Minute, "1h30m"},
{30 * time.Minute, "30m"},
{90 * time.Second, "1m30s"},
{45 * time.Second, "45s"},
{6*time.Hour + 5*time.Second, "6h0m5s"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
assert.Equal(t, tt.want, shortDuration(tt.d))
})
}
}
func postVersion(t *testing.T, srv *httptest.Server, version string) (*http.Response, string) {
t.Helper()
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}, "version": {version}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp, string(body)
}
func TestUpdateService_VersionStartsThatVersion(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job = model.Job{ID: 42}
resp, _ := postVersion(t, srv, "31")
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
assert.Equal(t, []int64{31}, jobs.triggered)
assert.Equal(t, []int64{1}, jobs.triggerSvc)
assert.Equal(t, "alice", jobs.triggeredBy())
assert.Zero(t, jobs.plainCalls)
}
func TestUpdateService_WithoutVersionTriggersTheFollowedTag(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job = model.Job{ID: 42}
resp, _ := postVersion(t, srv, "")
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, 1, jobs.plainCalls)
assert.Empty(t, jobs.triggered)
}
func TestUpdateService_VersionErrors(t *testing.T) {
tests := []struct {
name string
version string
err error
wantCode int
wantBody string
wantJob string // Location header
}{
{"not a number", "abc", nil, http.StatusBadRequest, "invalid version", ""},
{"a ref instead of an id", "repo:evil@sha256:x", nil, http.StatusBadRequest, "invalid version", ""},
{"unknown version or other repo", "77", model.ErrNotFound, http.StatusNotFound, "not found", ""},
{"not updatable", "31", &model.NotUpdatableError{Reason: model.ReasonBuilt}, http.StatusConflict, "Cannot update: " + model.ReasonBuilt + ".", ""},
{"job already active", "31", model.ErrJobActive, http.StatusSeeOther, "", "/jobs/41"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.err, jobs.job = tt.err, model.Job{ID: 41}
resp, body := postVersion(t, srv, tt.version)
assert.Equal(t, tt.wantCode, resp.StatusCode)
assert.Contains(t, body, tt.wantBody)
assert.Equal(t, tt.wantJob, resp.Header.Get("Location"))
if tt.wantCode == http.StatusBadRequest {
assert.Empty(t, jobs.triggered, "no job for a malformed version")
}
})
}
}
func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].LastPollError = `head x: "boom" <script>`
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Equal(t, 1, strings.Count(body, "follows no tag"), "only the digest-only Swarm service: "+path)
assert.Contains(t, body, `class="badge badge-error" title="head x: &#34;boom&#34; &lt;script&gt;">poll failed</span>`, path)
assert.Equal(t, 1, strings.Count(body, "poll failed"), path)
}
}
+47
View File
@@ -5,7 +5,9 @@ import (
"time"
"github.com/shcizo/package-updater/internal/imageref"
"github.com/shcizo/package-updater/internal/master/jobs"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/poller"
"github.com/shcizo/package-updater/internal/protocol"
)
@@ -54,6 +56,51 @@ func shortRef(ref string) string {
return r.String()
}
// followsNoTag says the service's image reference has a digest but no tag,
// so there is no tag to poll or update.
func followsNoTag(svc model.Service) bool {
return jobs.FollowedRef(svc.ActualRef) == ""
}
// versionRow is an image version as shown on the service page.
type versionRow struct {
model.ImageVersion
Running bool // the service runs exactly this digest
}
// versionRows marks the version the service runs now, by digest.
func versionRows(versions []model.ImageVersion, actualDigest string) []versionRow {
rows := make([]versionRow, len(versions))
for i, v := range versions {
rows[i] = versionRow{ImageVersion: v, Running: v.Digest != "" && v.Digest == actualDigest}
}
return rows
}
// 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) {
switch {
case poller.SkipReason(svc) != "":
return "not polled: " + poller.SkipReason(svc), false
case svc.LastPollError != "":
return "last check failed: " + svc.LastPollError, true
}
return "every " + shortDuration(interval), false
}
// shortDuration formats d without trailing zero units: "6h", "1h30m", "90s".
func shortDuration(d time.Duration) string {
s := d.String()
if strings.HasSuffix(s, "m0s") {
s = s[:len(s)-2]
}
if strings.HasSuffix(s, "h0m") {
s = s[:len(s)-2]
}
return s
}
type agentRow struct {
model.Agent
Online bool